- OAuth authentication via Authentik - WebSocket connection to OpenClaw gateway - Configurable gateway URL with first-run setup - User preferences sync across devices - Multi-user support with custom assistant names - ElevenLabs TTS integration (local + remote) - FCM push notifications for alarms - Voice input via Google Speech API - No hardcoded secrets or internal IPs in tracked files
18 KiB
18 KiB
Android App - OAuth Integration Guide
Your Authentik Configuration
Authentik URL: https://auth.dnspegasus.net
Client ID: QeSNaZPqZUz5pPClZMA2bakSsddkStiEhqbE4QZR
Redirect URI: alfredmobile://oauth/callback
Gateway URL: wss://alfred-app.dnspegasus.net
Step 1: Add Dependencies
app/build.gradle.kts:
dependencies {
// Existing dependencies...
// OAuth2 Authentication
implementation("net.openid:appauth:0.11.1")
// WebSocket (OkHttp)
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// Lifecycle
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
}
Step 2: Update AndroidManifest.xml
Add the OAuth callback handler:
app/src/main/AndroidManifest.xml:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:name=".AlfredApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.AlfredMobile">
<!-- Main Activity -->
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:theme="@style/Theme.AlfredMobile">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- OAuth Callback Activity - IMPORTANT! -->
<activity
android:name=".auth.OAuthCallbackActivity"
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="alfredmobile"
android:host="oauth"
android:path="/callback" />
</intent-filter>
</activity>
</application>
</manifest>
Step 3: Create Configuration Classes
app/src/main/java/com/example/alfredmobile/auth/OAuthConfig.kt:
package com.example.alfredmobile.auth
object OAuthConfig {
const val AUTHENTIK_URL = "https://auth.dnspegasus.net"
const val CLIENT_ID = "QeSNaZPqZUz5pPClZMA2bakSsddkStiEhqbE4QZR"
const val REDIRECT_URI = "alfredmobile://oauth/callback"
const val SCOPE = "openid profile email"
const val AUTHORIZATION_ENDPOINT = "$AUTHENTIK_URL/application/o/authorize/"
const val TOKEN_ENDPOINT = "$AUTHENTIK_URL/application/o/token/"
const val USERINFO_ENDPOINT = "$AUTHENTIK_URL/application/o/userinfo/"
}
object AlfredConfig {
const val GATEWAY_URL = "wss://alfred-app.dnspegasus.net"
}
Step 4: Create Auth Manager
app/src/main/java/com/example/alfredmobile/auth/AuthManager.kt:
package com.example.alfredmobile.auth
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import net.openid.appauth.*
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
class AuthManager(private val context: Context) {
companion object {
private const val TAG = "AuthManager"
const val AUTH_REQUEST_CODE = 1001
private const val PREFS_NAME = "alfred_auth"
private const val KEY_ACCESS_TOKEN = "access_token"
private const val KEY_REFRESH_TOKEN = "refresh_token"
private const val KEY_ID_TOKEN = "id_token"
private const val KEY_TOKEN_EXPIRY = "token_expiry"
}
private val serviceConfig = AuthorizationServiceConfiguration(
Uri.parse(OAuthConfig.AUTHORIZATION_ENDPOINT),
Uri.parse(OAuthConfig.TOKEN_ENDPOINT)
)
private val authService = AuthorizationService(context)
/**
* Start the OAuth login flow
* Opens the browser for user authentication
*/
fun startLogin(activity: Activity) {
Log.d(TAG, "Starting OAuth login flow")
val authRequest = AuthorizationRequest.Builder(
serviceConfig,
OAuthConfig.CLIENT_ID,
ResponseTypeValues.CODE,
Uri.parse(OAuthConfig.REDIRECT_URI)
)
.setScope(OAuthConfig.SCOPE)
.build()
val authIntent = authService.getAuthorizationRequestIntent(authRequest)
activity.startActivityForResult(authIntent, AUTH_REQUEST_CODE)
}
/**
* Handle the OAuth redirect response
* Call this from your Activity's onActivityResult or callback activity
*/
suspend fun handleAuthResponse(intent: Intent): AuthResult = suspendCancellableCoroutine { continuation ->
val authResponse = AuthorizationResponse.fromIntent(intent)
val authException = AuthorizationException.fromIntent(intent)
when {
authResponse != null -> {
Log.d(TAG, "Authorization successful, exchanging code for token")
// Exchange authorization code for access token
val tokenRequest = authResponse.createTokenExchangeRequest()
authService.performTokenRequest(tokenRequest) { tokenResponse, exception ->
when {
tokenResponse != null -> {
val accessToken = tokenResponse.accessToken ?: ""
val refreshToken = tokenResponse.refreshToken
val idToken = tokenResponse.idToken
val expiryTime = tokenResponse.accessTokenExpirationTime ?: 0L
Log.d(TAG, "Token exchange successful")
// Save tokens securely
saveTokens(accessToken, refreshToken, idToken, expiryTime)
continuation.resume(
AuthResult.Success(
accessToken = accessToken,
refreshToken = refreshToken,
idToken = idToken
)
)
}
exception != null -> {
Log.e(TAG, "Token exchange failed", exception)
continuation.resumeWithException(
Exception("Token exchange failed: ${exception.message}")
)
}
}
}
}
authException != null -> {
Log.e(TAG, "Authorization failed", authException)
continuation.resumeWithException(
Exception("Authorization failed: ${authException.message}")
)
}
else -> {
Log.e(TAG, "No auth response or exception found")
continuation.resumeWithException(
Exception("No auth response received")
)
}
}
}
/**
* Get the stored access token
*/
fun getAccessToken(): String? {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val token = prefs.getString(KEY_ACCESS_TOKEN, null)
val expiry = prefs.getLong(KEY_TOKEN_EXPIRY, 0L)
// Check if token is expired
if (token != null && System.currentTimeMillis() < expiry) {
return token
}
// Token expired or doesn't exist
return null
}
/**
* Check if user is logged in
*/
fun isLoggedIn(): Boolean {
return getAccessToken() != null
}
/**
* Clear stored tokens (logout)
*/
fun logout() {
Log.d(TAG, "Logging out, clearing tokens")
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit()
.clear()
.apply()
}
/**
* Save tokens to secure storage
*/
private fun saveTokens(
accessToken: String,
refreshToken: String?,
idToken: String?,
expiryTime: Long
) {
Log.d(TAG, "Saving tokens to secure storage")
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit()
.putString(KEY_ACCESS_TOKEN, accessToken)
.putString(KEY_REFRESH_TOKEN, refreshToken)
.putString(KEY_ID_TOKEN, idToken)
.putLong(KEY_TOKEN_EXPIRY, expiryTime)
.apply()
}
/**
* Clean up resources
*/
fun dispose() {
authService.dispose()
}
}
/**
* Result of authentication
*/
sealed class AuthResult {
data class Success(
val accessToken: String,
val refreshToken: String?,
val idToken: String?
) : AuthResult()
data class Error(val message: String) : AuthResult()
}
Step 5: Create OAuth Callback Activity
app/src/main/java/com/example/alfredmobile/auth/OAuthCallbackActivity.kt:
package com.example.alfredmobile.auth
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import com.example.alfredmobile.ui.MainActivity
import kotlinx.coroutines.launch
class OAuthCallbackActivity : ComponentActivity() {
private lateinit var authManager: AuthManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
authManager = AuthManager(this)
Log.d("OAuthCallback", "Received OAuth callback")
// Handle the OAuth redirect
lifecycleScope.launch {
try {
val result = authManager.handleAuthResponse(intent)
when (result) {
is AuthResult.Success -> {
Log.d("OAuthCallback", "Login successful!")
Toast.makeText(
this@OAuthCallbackActivity,
"Login successful!",
Toast.LENGTH_SHORT
).show()
// Navigate to main app
val mainIntent = Intent(this@OAuthCallbackActivity, MainActivity::class.java)
mainIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(mainIntent)
finish()
}
is AuthResult.Error -> {
Log.e("OAuthCallback", "Login failed: ${result.message}")
Toast.makeText(
this@OAuthCallbackActivity,
"Login failed: ${result.message}",
Toast.LENGTH_LONG
).show()
finish()
}
}
} catch (e: Exception) {
Log.e("OAuthCallback", "Error handling auth response", e)
Toast.makeText(
this@OAuthCallbackActivity,
"Login error: ${e.message}",
Toast.LENGTH_LONG
).show()
finish()
}
}
}
override fun onDestroy() {
super.onDestroy()
authManager.dispose()
}
}
Step 6: Create Login Screen
app/src/main/java/com/example/alfredmobile/ui/LoginScreen.kt:
package com.example.alfredmobile.ui
import android.app.Activity
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.example.alfredmobile.auth.AuthManager
@Composable
fun LoginScreen(
onLoginClick: () -> Unit
) {
val context = LocalContext.current
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "🤵",
style = MaterialTheme.typography.displayLarge,
modifier = Modifier.padding(bottom = 16.dp)
)
Text(
text = "Alfred",
style = MaterialTheme.typography.displayMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
text = "Your AI assistant",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 48.dp)
)
Button(
onClick = onLoginClick,
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
) {
Text("Sign in with Authentik")
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "You'll be redirected to authenticate\nwith your Authentik account",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}
Step 7: Update MainActivity
app/src/main/java/com/example/alfredmobile/ui/MainActivity.kt:
package com.example.alfredmobile.ui
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.*
import com.example.alfredmobile.auth.AuthManager
import com.example.alfredmobile.ui.theme.AlfredMobileTheme
class MainActivity : ComponentActivity() {
private lateinit var authManager: AuthManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
authManager = AuthManager(this)
setContent {
AlfredMobileTheme {
var isLoggedIn by remember { mutableStateOf(authManager.isLoggedIn()) }
if (isLoggedIn) {
// Show main app UI
MainScreen(
onLogout = {
authManager.logout()
isLoggedIn = false
}
)
} else {
// Show login screen
LoginScreen(
onLoginClick = {
authManager.startLogin(this)
}
)
}
}
}
}
override fun onResume() {
super.onResume()
// Refresh login state when returning to activity
setContent {
AlfredMobileTheme {
var isLoggedIn by remember { mutableStateOf(authManager.isLoggedIn()) }
if (isLoggedIn) {
MainScreen(
onLogout = {
authManager.logout()
isLoggedIn = false
}
)
} else {
LoginScreen(
onLoginClick = {
authManager.startLogin(this)
}
)
}
}
}
}
override fun onDestroy() {
super.onDestroy()
authManager.dispose()
}
}
@Composable
fun MainScreen(onLogout: () -> Unit) {
// Placeholder main screen
// You'll replace this with your actual chat UI
Surface {
Column(
modifier = androidx.compose.ui.Modifier
.fillMaxSize()
.padding(16.dp)
) {
Text("Main App Screen")
Text("TODO: Add chat UI here")
Spacer(modifier = androidx.compose.ui.Modifier.height(16.dp))
Button(onClick = onLogout) {
Text("Logout")
}
}
}
}
Testing the OAuth Flow
1. Build and Install
cd ~/.openclaw/workspace/alfred-mobile
./gradlew assembleDebug
adb install app/build/outputs/apk/debug/app-debug.apk
2. Test Login Flow
- Open the app
- Tap "Sign in with Authentik"
- Browser opens to Authentik login
- Enter your credentials
- Browser redirects back to app
- App should show "Login successful!" and navigate to main screen
3. Check Logs
adb logcat | grep -E "AuthManager|OAuthCallback"
Next Steps
After OAuth is working:
- Implement WebSocket connection (see WEBSOCKET_INTEGRATION.md)
- Build chat UI
- Add voice input
- Implement lists, timers, notes
Troubleshooting
"No browser available"
Install a browser on your device/emulator (Chrome, Firefox, etc.)
"Invalid redirect URI"
Verify in Authentik that alfredmobile://oauth/callback is in the Redirect URIs list.
"Token exchange failed"
Check Client ID matches Authentik exactly:
QeSNaZPqZUz5pPClZMA2bakSsddkStiEhqbE4QZR
OAuth redirect doesn't return to app
Check AndroidManifest.xml has the intent-filter for alfredmobile://oauth/callback