diff --git a/ROADMAP.md b/ROADMAP.md
index 616f7310..61d27b09 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -242,7 +242,7 @@ Lives in `android/` in this repo. Kotlin + Jetpack Compose, Material 3.
- **API**: `api/v4` REST + OAuth token (entities exist: `oauthclient`, `oauthgrant`) — same surface the desktop `cloudreve-api` crate documents; port its models as the spec
- **Core features**: browse/download/upload files, share links, full-text search (done — query bar + offset pagination + parent-path/snippet rows), camera-upload (done — periodic WorkManager MediaStore sync, Wi-Fi-only constraint, ID dedup, settings dialog), offline-favorite files (done — "Keep offline" downloads to filesDir/offline, DataStore registry, star dialog with open/refresh/remove), local sync folder via SAF/WorkManager
- **System integration** (the "native, complete" ask): share-sheet target (done — SEND/SEND_MULTIPLE → UploadWorker), DocumentsProvider (done — Files-app browse/open/thumb/rename/delete/search), quick-share tile, notifications on share/task events (done — periodic /workflow poll → terminal-state notifications)
-- **Auth**: webview OAuth flow → token; later passkey if backend exposes
+- **Auth**: OAuth flow (done — browser consent + PKCE + `cloudreve://mount` deep link); later passkey if backend exposes
- **WebDAV bridge**: `/dav` works as fallback file access until SDK matures
- Non-goals: iOS, tablet-first layouts (works, not optimized)
diff --git a/android/README.md b/android/README.md
index 241374fb..84579152 100644
--- a/android/README.md
+++ b/android/README.md
@@ -44,6 +44,9 @@ CI runs `assembleDebug` on every PR.
- Task notifications: periodic `GET /workflow` poll posts a
notification when a task completes/fails/cancels — toggle in the
settings dialog, runtime POST_NOTIFICATIONS permission gated
+- OAuth sign-in: "Sign in with browser" opens the server's consent
+ page with PKCE; the `/callback/desktop` page deep-links the code
+ back through `cloudreve://mount`, exchanged at `/session/oauth/token`
## Planned next
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 23b3e9ac..2e26dfde 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -36,6 +36,12 @@
+
+
+
+
+
+
(null)
+
lateinit var sessionManager: SessionManager
private set
lateinit var apiClient: ApiClient
diff --git a/android/app/src/main/java/org/cloudreve/android/MainActivity.kt b/android/app/src/main/java/org/cloudreve/android/MainActivity.kt
index 48b54964..e1e156b3 100644
--- a/android/app/src/main/java/org/cloudreve/android/MainActivity.kt
+++ b/android/app/src/main/java/org/cloudreve/android/MainActivity.kt
@@ -42,8 +42,20 @@ class MainActivity : ComponentActivity() {
handleShareIntent(intent)
}
- // Share-sheet target: queue incoming files to the account root.
+ // Share-sheet target + OAuth deep link.
private fun handleShareIntent(intent: Intent?) {
+ if (intent?.action == Intent.ACTION_VIEW) {
+ val data = intent.data
+ if (data?.scheme == "cloudreve" && data.host == "mount") {
+ val code = data.getQueryParameter("code")
+ val state = data.getQueryParameter("state") ?: ""
+ if (!code.isNullOrEmpty()) {
+ (application as CloudreveApp).oauthCallback.value =
+ CloudreveApp.OAuthCallback(code, state)
+ }
+ }
+ return
+ }
val uris = when (intent?.action) {
Intent.ACTION_SEND -> listOfNotNull(
@Suppress("DEPRECATION")
diff --git a/android/app/src/main/java/org/cloudreve/android/api/ApiService.kt b/android/app/src/main/java/org/cloudreve/android/api/ApiService.kt
index 71ffb881..9201928f 100644
--- a/android/app/src/main/java/org/cloudreve/android/api/ApiService.kt
+++ b/android/app/src/main/java/org/cloudreve/android/api/ApiService.kt
@@ -5,7 +5,10 @@ import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
+import retrofit2.http.Field
+import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
+import retrofit2.http.Header
import retrofit2.http.HTTP
import retrofit2.http.POST
import retrofit2.http.PUT
@@ -18,6 +21,22 @@ interface ApiService {
@POST("api/v4/session/token")
suspend fun login(@Body request: PasswordLoginRequest): Response>
+ // OAuth token exchange returns raw OAuth JSON, not the ApiResponse envelope.
+ @FormUrlEncoded
+ @POST("api/v4/session/oauth/token")
+ suspend fun oauthToken(
+ @Field("client_id") clientId: String,
+ @Field("grant_type") grantType: String,
+ @Field("code") code: String,
+ @Field("redirect_uri") redirectUri: String,
+ @Field("code_verifier") codeVerifier: String,
+ ): Response
+
+ @GET("api/v4/session/oauth/userinfo")
+ suspend fun oauthUserInfo(
+ @Header("Authorization") bearer: String,
+ ): Response
+
@POST("api/v4/session/token/refresh")
suspend fun refreshToken(@Body request: RefreshTokenRequest): Response>
diff --git a/android/app/src/main/java/org/cloudreve/android/api/Dto.kt b/android/app/src/main/java/org/cloudreve/android/api/Dto.kt
index 285e39be..07b64642 100644
--- a/android/app/src/main/java/org/cloudreve/android/api/Dto.kt
+++ b/android/app/src/main/java/org/cloudreve/android/api/Dto.kt
@@ -206,3 +206,19 @@ data class TaskItem(
data class TaskListResponse(
val tasks: List = emptyList(),
)
+
+@Serializable
+data class OAuthTokenResponse(
+ @SerialName("access_token") val accessToken: String = "",
+ @SerialName("refresh_token") val refreshToken: String = "",
+ @SerialName("expires_in") val expiresIn: Long = 0,
+ @SerialName("token_type") val tokenType: String = "",
+)
+
+@Serializable
+data class UserInfoResponse(
+ val sub: String = "",
+ val name: String = "",
+ @SerialName("preferred_username") val preferredUsername: String = "",
+ val email: String = "",
+)
diff --git a/android/app/src/main/java/org/cloudreve/android/data/SessionManager.kt b/android/app/src/main/java/org/cloudreve/android/data/SessionManager.kt
index 7e44d379..fec68485 100644
--- a/android/app/src/main/java/org/cloudreve/android/data/SessionManager.kt
+++ b/android/app/src/main/java/org/cloudreve/android/data/SessionManager.kt
@@ -25,6 +25,8 @@ class SessionManager(private val context: Context) {
val accessExpiresAt = longPreferencesKey("access_expires_at")
val userEmail = stringPreferencesKey("user_email")
val userNick = stringPreferencesKey("user_nick")
+ val pendingOauthVerifier = stringPreferencesKey("pending_oauth_verifier")
+ val pendingOauthState = stringPreferencesKey("pending_oauth_state")
}
val serverUrl: Flow = context.sessionStore.data.map { it[Keys.serverUrl] ?: "" }
@@ -46,6 +48,26 @@ class SessionManager(private val context: Context) {
context.sessionStore.edit { it[Keys.serverUrl] = url.trimEnd('/') }
}
+ suspend fun savePendingOAuth(verifier: String, state: String) {
+ context.sessionStore.edit {
+ it[Keys.pendingOauthVerifier] = verifier
+ it[Keys.pendingOauthState] = state
+ }
+ }
+
+ /** Returns the pending verifier+state pair once, then clears it. */
+ suspend fun takePendingOAuth(): Pair? {
+ val prefs = context.sessionStore.data.first()
+ val verifier = prefs[Keys.pendingOauthVerifier]
+ val state = prefs[Keys.pendingOauthState]
+ if (verifier.isNullOrEmpty() || state.isNullOrEmpty()) return null
+ context.sessionStore.edit {
+ it.remove(Keys.pendingOauthVerifier)
+ it.remove(Keys.pendingOauthState)
+ }
+ return verifier to state
+ }
+
suspend fun saveSession(
accessToken: String,
refreshToken: String,
diff --git a/android/app/src/main/java/org/cloudreve/android/ui/login/LoginScreen.kt b/android/app/src/main/java/org/cloudreve/android/ui/login/LoginScreen.kt
index dbd4182a..74011b4d 100644
--- a/android/app/src/main/java/org/cloudreve/android/ui/login/LoginScreen.kt
+++ b/android/app/src/main/java/org/cloudreve/android/ui/login/LoginScreen.kt
@@ -27,11 +27,21 @@ import androidx.compose.ui.unit.dp
@Composable
fun LoginScreen(viewModel: LoginViewModel, onLoggedIn: () -> Unit) {
val state by viewModel.state.collectAsState()
+ val context = androidx.compose.ui.platform.LocalContext.current
+ val app = context.applicationContext as org.cloudreve.android.CloudreveApp
+ val oauth by app.oauthCallback.collectAsState()
LaunchedEffect(state.loggedIn) {
if (state.loggedIn) onLoggedIn()
}
+ LaunchedEffect(oauth) {
+ oauth?.let {
+ app.oauthCallback.value = null
+ viewModel.completeOAuth(it.code, it.state)
+ }
+ }
+
Column(
modifier = Modifier
.fillMaxSize()
@@ -94,5 +104,22 @@ fun LoginScreen(viewModel: LoginViewModel, onLoggedIn: () -> Unit) {
Text("Sign in")
}
}
+ Spacer(Modifier.height(12.dp))
+ androidx.compose.material3.OutlinedButton(
+ onClick = {
+ viewModel.startOAuth()?.let { url ->
+ context.startActivity(
+ android.content.Intent(
+ android.content.Intent.ACTION_VIEW,
+ android.net.Uri.parse(url),
+ )
+ )
+ }
+ },
+ enabled = !state.loading,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Sign in with browser")
+ }
}
}
diff --git a/android/app/src/main/java/org/cloudreve/android/ui/login/LoginViewModel.kt b/android/app/src/main/java/org/cloudreve/android/ui/login/LoginViewModel.kt
index b966e838..8de5ba4b 100644
--- a/android/app/src/main/java/org/cloudreve/android/ui/login/LoginViewModel.kt
+++ b/android/app/src/main/java/org/cloudreve/android/ui/login/LoginViewModel.kt
@@ -8,6 +8,9 @@ import kotlinx.coroutines.launch
import org.cloudreve.android.api.ApiClient
import org.cloudreve.android.api.PasswordLoginRequest
import org.cloudreve.android.data.SessionManager
+import java.net.URLEncoder
+import java.security.MessageDigest
+import java.security.SecureRandom
data class LoginUiState(
val serverUrl: String = "",
@@ -59,4 +62,87 @@ class LoginViewModel(
}
}
}
+
+ /**
+ * Builds the server-side authorize URL for browser sign-in and stashes
+ * the PKCE verifier + state. The consent page bounces the code to the
+ * app through the `cloudreve://mount` deep link. Returns null when the
+ * server URL is missing.
+ */
+ fun startOAuth(): String? {
+ val s = _state.value
+ if (s.serverUrl.isBlank()) {
+ _state.value = s.copy(error = "Enter the server URL first")
+ return null
+ }
+ val verifier = randomBase64Url(48)
+ val challenge = base64Url(MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray()))
+ val state = randomBase64Url(24)
+ viewModelScope.launch {
+ session.saveServerUrl(s.serverUrl)
+ session.savePendingOAuth(verifier, state)
+ }
+ val enc: (String) -> String = { URLEncoder.encode(it, "UTF-8") }
+ return "${s.serverUrl.trimEnd('/')}/session/authorize" +
+ "?response_type=code&client_id=${enc(OAUTH_CLIENT_ID)}" +
+ "&redirect_uri=${enc(OAUTH_REDIRECT)}&state=${enc(state)}" +
+ "&scope=${enc(OAUTH_SCOPES)}&code_challenge=${enc(challenge)}" +
+ "&code_challenge_method=S256"
+ }
+
+ /** Exchanges the deep-link code for tokens. Called by the UI on `cloudreve://mount`. */
+ fun completeOAuth(code: String, state: String) {
+ _state.value = _state.value.copy(loading = true, error = null)
+ viewModelScope.launch {
+ try {
+ val pending = session.takePendingOAuth()
+ ?: throw Exception("No pending sign-in — start again")
+ if (pending.second != state) {
+ throw Exception("OAuth state mismatch")
+ }
+ val resp = api.service().oauthToken(
+ clientId = OAUTH_CLIENT_ID,
+ grantType = "authorization_code",
+ code = code,
+ redirectUri = OAUTH_REDIRECT,
+ codeVerifier = pending.first,
+ )
+ val token = resp.body()
+ if (!resp.isSuccessful || token == null || token.accessToken.isEmpty()) {
+ throw Exception("Token exchange failed (HTTP ${resp.code()})")
+ }
+ // The token response carries no user; fetch profile from userinfo.
+ val info = runCatching {
+ api.service().oauthUserInfo("Bearer ${token.accessToken}").body()
+ }.getOrNull()
+ session.saveSession(
+ accessToken = token.accessToken,
+ refreshToken = token.refreshToken,
+ accessExpiresAt = if (token.expiresIn > 0) {
+ System.currentTimeMillis() + token.expiresIn * 1000
+ } else 0L,
+ email = info?.email ?: "",
+ nick = info?.name?.ifEmpty { info.preferredUsername } ?: "",
+ )
+ _state.value = _state.value.copy(loading = false, loggedIn = true)
+ } catch (e: Exception) {
+ _state.value = _state.value.copy(loading = false, error = e.message ?: "Sign-in failed")
+ }
+ }
+ }
+
+ private fun randomBase64Url(bytes: Int): String =
+ base64Url(ByteArray(bytes).also { SecureRandom().nextBytes(it) })
+
+ private fun base64Url(data: ByteArray): String =
+ android.util.Base64.encodeToString(data, android.util.Base64.URL_SAFE or android.util.Base64.NO_PADDING or android.util.Base64.NO_WRAP)
+ .trim()
+
+ companion object {
+ // Built-in public OAuth client (seeded by the server migration).
+ private const val OAUTH_CLIENT_ID = "393a1839-f52e-498e-9972-e77cc2241eee"
+ private const val OAUTH_REDIRECT = "/callback/desktop"
+ private const val OAUTH_SCOPES =
+ "profile email openid offline_access UserInfo.Write UserSecurityInfo.Write Workflow.Write Files.Write Shares.Write"
+ }
}