Initial commit: Alfred Mobile - AI Assistant Android App
- 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
This commit is contained in:
631
OAUTH_SETUP.md
Normal file
631
OAUTH_SETUP.md
Normal file
@@ -0,0 +1,631 @@
|
||||
# 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`
|
||||
Reference in New Issue
Block a user