- 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
632 lines
18 KiB
Markdown
632 lines
18 KiB
Markdown
# 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`:**
|
|
|
|
```kotlin
|
|
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
|
|
<?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`:**
|
|
|
|
```kotlin
|
|
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`:**
|
|
|
|
```kotlin
|
|
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`:**
|
|
|
|
```kotlin
|
|
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`:**
|
|
|
|
```kotlin
|
|
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`:**
|
|
|
|
```kotlin
|
|
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
|
|
|
|
```bash
|
|
cd ~/.openclaw/workspace/alfred-mobile
|
|
./gradlew assembleDebug
|
|
adb install app/build/outputs/apk/debug/app-debug.apk
|
|
```
|
|
|
|
### 2. Test Login Flow
|
|
|
|
1. Open the app
|
|
2. Tap "Sign in with Authentik"
|
|
3. Browser opens to Authentik login
|
|
4. Enter your credentials
|
|
5. Browser redirects back to app
|
|
6. App should show "Login successful!" and navigate to main screen
|
|
|
|
### 3. Check Logs
|
|
|
|
```bash
|
|
adb logcat | grep -E "AuthManager|OAuthCallback"
|
|
```
|
|
|
|
---
|
|
|
|
## Next Steps
|
|
|
|
After OAuth is working:
|
|
|
|
1. **Implement WebSocket connection** (see WEBSOCKET_INTEGRATION.md)
|
|
2. **Build chat UI**
|
|
3. **Add voice input**
|
|
4. **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`
|