diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 03a20745..561d286f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -57,3 +57,17 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev libgtk-3-dev librsvg2-dev
- run: cargo check --workspace
- run: cargo test --workspace
+
+ android:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: android
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: 17
+ - uses: gradle/actions/setup-gradle@v4
+ - run: ./gradlew :app:assembleDebug --console=plain
diff --git a/.gitignore b/.gitignore
index 76c41faf..ef1431cf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,10 +33,10 @@ conf/conf.ini
.vscode/
dist/
-data/
+/data/
tmp/
.devcontainer/
-cloudreve
+/cloudreve
.DS_Store
# Playwright MCP snapshots
.playwright-mcp/
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 00000000..3550d76f
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,6 @@
+.gradle/
+build/
+*/build/
+local.properties
+*.iml
+.cxx/
diff --git a/android/README.md b/android/README.md
index d0aaa94c..e7ca7412 100644
--- a/android/README.md
+++ b/android/README.md
@@ -2,12 +2,34 @@
Native Android client for Cloudreve. Kotlin + Jetpack Compose, Material 3.
-Status: **Phase E — scaffolded, not yet implemented.** See `../ROADMAP.md`.
+Status: **Phase E — v0.1 implemented.** Sign in, browse, download, upload,
+rename, mkdir, delete, thumbnails, share-link creation. See `../ROADMAP.md`
+for the remaining scope.
-## Planned
+## Build
-- `api/v4` REST + OAuth token auth (same surface as `desktop/crates/cloudreve-api`)
-- Browse / upload / download / share links
-- Camera auto-upload, offline-favorite files, local sync via WorkManager + SAF
-- Share-sheet target, DocumentsProvider, quick-share tile, task notifications
+```bash
+cd android
+ANDROID_HOME=$HOME/Android/Sdk ./gradlew :app:assembleDebug
+# APK: app/build/outputs/apk/debug/app-debug.apk
+```
+
+CI runs `assembleDebug` on every PR.
+
+## What's implemented
+
+- `api/v4` auth: password login → access/refresh tokens, single-flight
+ refresh on 401, server-side revocation on sign-out
+- File browser: folder navigation, cursor pagination, pull-refresh,
+ mkdir / rename / delete / download (cache + FileProvider open)
+- Uploads: SAF document picker or share-sheet target, WorkManager
+ background worker with progress notification, chunked through the
+ upload-session flow (local + presigned remote policies)
+- Thumbnails via `/file/thumb` (Coil, lazily resolved per row)
+- Share-link creation (`PUT /share`), link copied to clipboard
+
+## Planned next
+
+- Camera auto-upload, offline-favorite files, DocumentsProvider
+- Search
- No iOS. Ever.
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 00000000..a32116da
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,77 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.compose")
+ id("org.jetbrains.kotlin.plugin.serialization")
+}
+
+android {
+ namespace = "org.cloudreve.android"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "org.cloudreve.android"
+ minSdk = 28
+ targetSdk = 35
+ versionCode = 1
+ versionName = "0.1.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
+ }
+ }
+
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
+ implementation(composeBom)
+ androidTestImplementation(composeBom)
+
+ implementation("androidx.core:core-ktx:1.15.0")
+ implementation("androidx.activity:activity-compose:1.9.3")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
+ implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
+ implementation("androidx.navigation:navigation-compose:2.8.5")
+ implementation("androidx.datastore:datastore-preferences:1.1.1")
+ implementation("androidx.work:work-runtime-ktx:2.10.0")
+
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.compose.material:material-icons-extended")
+ debugImplementation("androidx.compose.ui:ui-tooling")
+
+ implementation("com.squareup.retrofit2:retrofit:2.11.0")
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
+ implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
+
+ implementation("io.coil-kt:coil-compose:2.7.0")
+}
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
new file mode 100644
index 00000000..ecbc38b8
--- /dev/null
+++ b/android/app/proguard-rules.pro
@@ -0,0 +1,11 @@
+# Keep kotlinx.serialization generated serializers
+-keepclassmembers class org.cloudreve.android.api.** {
+ *** Companion;
+}
+-keepclasseswithmembers class org.cloudreve.android.api.** {
+ kotlinx.serialization.KSerializer serializer*(...);
+}
+-keepattributes *Annotation*, InnerClasses
+-dontnote kotlinx.serialization.**
+-dontwarn okhttp3.**
+-dontwarn okio.**
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..f62ccfc0
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/org/cloudreve/android/CloudreveApp.kt b/android/app/src/main/java/org/cloudreve/android/CloudreveApp.kt
new file mode 100644
index 00000000..e4b48866
--- /dev/null
+++ b/android/app/src/main/java/org/cloudreve/android/CloudreveApp.kt
@@ -0,0 +1,23 @@
+package org.cloudreve.android
+
+import android.app.Application
+import org.cloudreve.android.api.ApiClient
+import org.cloudreve.android.data.FileRepository
+import org.cloudreve.android.data.SessionManager
+
+class CloudreveApp : Application() {
+
+ lateinit var sessionManager: SessionManager
+ private set
+ lateinit var apiClient: ApiClient
+ private set
+ lateinit var fileRepository: FileRepository
+ private set
+
+ override fun onCreate() {
+ super.onCreate()
+ sessionManager = SessionManager(this)
+ apiClient = ApiClient(sessionManager)
+ fileRepository = FileRepository(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
new file mode 100644
index 00000000..063ffcda
--- /dev/null
+++ b/android/app/src/main/java/org/cloudreve/android/MainActivity.kt
@@ -0,0 +1,114 @@
+package org.cloudreve.android
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import kotlinx.coroutines.launch
+import org.cloudreve.android.api.RefreshTokenRequest
+import org.cloudreve.android.ui.files.FilesScreen
+import org.cloudreve.android.ui.files.FilesViewModel
+import org.cloudreve.android.ui.login.LoginScreen
+import org.cloudreve.android.ui.login.LoginViewModel
+import org.cloudreve.android.ui.theme.CloudreveTheme
+import org.cloudreve.android.util.CrUri
+import org.cloudreve.android.work.UploadWorker
+
+class MainActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ handleShareIntent(intent)
+ setContent { App() }
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ handleShareIntent(intent)
+ }
+
+ // Share-sheet target: queue incoming files to the account root.
+ private fun handleShareIntent(intent: Intent?) {
+ val uris = when (intent?.action) {
+ Intent.ACTION_SEND -> listOfNotNull(
+ @Suppress("DEPRECATION")
+ intent.getParcelableExtra(Intent.EXTRA_STREAM)
+ )
+ Intent.ACTION_SEND_MULTIPLE ->
+ @Suppress("DEPRECATION")
+ intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
+ else -> null
+ }
+ if (!uris.isNullOrEmpty()) {
+ UploadWorker.enqueue(this, uris, CrUri.MY_PREFIX)
+ }
+ }
+}
+
+private class AppViewModelFactory(private val app: CloudreveApp) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T = when {
+ modelClass.isAssignableFrom(LoginViewModel::class.java) ->
+ LoginViewModel(app.sessionManager, app.apiClient) as T
+ modelClass.isAssignableFrom(FilesViewModel::class.java) ->
+ FilesViewModel(app.fileRepository) as T
+ else -> throw IllegalArgumentException("Unknown ViewModel ${modelClass.name}")
+ }
+}
+
+@Composable
+fun App() {
+ val context = LocalContext.current
+ val app = context.applicationContext as CloudreveApp
+ val factory = remember { AppViewModelFactory(app) }
+ val loggedIn by app.sessionManager.isLoggedIn.collectAsState(initial = null)
+ val nav = rememberNavController()
+ val scope = rememberCoroutineScope()
+
+ val signOut: () -> Unit = {
+ scope.launch {
+ runCatching {
+ app.apiClient.service()
+ .signOut(RefreshTokenRequest(app.sessionManager.refreshTokenNow()))
+ }
+ app.sessionManager.clear()
+ }
+ }
+
+ CloudreveTheme {
+ when (loggedIn) {
+ null -> Unit // session store still loading; keep frame empty
+ false -> NavHost(navController = nav, startDestination = "login") {
+ composable("login") {
+ LoginScreen(
+ viewModel = viewModel(factory = factory),
+ onLoggedIn = { nav.navigate("files") { popUpTo(0) } },
+ )
+ }
+ }
+ true -> NavHost(navController = nav, startDestination = "files") {
+ composable("files") {
+ FilesScreen(
+ viewModel = viewModel(factory = factory),
+ onSignOut = signOut,
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/org/cloudreve/android/api/ApiClient.kt b/android/app/src/main/java/org/cloudreve/android/api/ApiClient.kt
new file mode 100644
index 00000000..93ce3129
--- /dev/null
+++ b/android/app/src/main/java/org/cloudreve/android/api/ApiClient.kt
@@ -0,0 +1,125 @@
+package org.cloudreve.android.api
+
+import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.serialization.json.Json
+import okhttp3.Interceptor
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import org.cloudreve.android.BuildConfig
+import org.cloudreve.android.data.SessionManager
+import retrofit2.Retrofit
+import java.util.concurrent.TimeUnit
+
+/**
+ * ApiClient owns the OkHttp/Retrofit wiring for one server. Auth requests
+ * carry `Authorization: Bearer `; a 401 triggers a single-flight
+ * refresh and one retry, mirroring the desktop client's behavior.
+ */
+class ApiClient(private val session: SessionManager) {
+
+ private val json = Json {
+ ignoreUnknownKeys = true
+ coerceInputValues = true
+ }
+
+ private val refreshMutex = Mutex()
+
+ @Volatile
+ private var cachedBaseUrl: String? = null
+
+ @Volatile
+ private var cachedService: ApiService? = null
+
+ suspend fun serverBase(): String = session.serverUrlNow()
+
+ suspend fun service(): ApiService {
+ val base = session.serverUrlNow().ifEmpty { "http://localhost" } + "/"
+ cachedService?.let { if (cachedBaseUrl == base) return it }
+ return buildService(base).also {
+ cachedService = it
+ cachedBaseUrl = base
+ }
+ }
+
+ private fun buildService(baseUrl: String): ApiService {
+ val authInterceptor = Interceptor { chain ->
+ val token = runBlocking { session.accessTokenNow() }
+ val request = if (token.isNotEmpty()) {
+ chain.request().newBuilder()
+ .header("Authorization", "Bearer $token")
+ .build()
+ } else {
+ chain.request()
+ }
+ chain.proceed(request)
+ }
+
+ val refreshAuthenticator = okhttp3.Authenticator { _, response ->
+ // Give up after a couple of retries or when there is no session.
+ if (response.request.header("X-Retry-Count")?.toIntOrNull() ?: 0 >= 1) return@Authenticator null
+ val refreshToken = runBlocking { session.refreshTokenNow() }
+ if (refreshToken.isEmpty()) return@Authenticator null
+
+ val newTokens = runBlocking {
+ refreshMutex.withLock {
+ // Another request may already have refreshed while we queued.
+ val current = session.accessTokenNow()
+ if (current.isNotEmpty() && current != response.request.header("Authorization")
+ ?.removePrefix("Bearer ")
+ ) {
+ return@withLock current
+ }
+ val refreshed = kotlin.runCatching {
+ buildService(cachedBaseUrl ?: return@withLock null)
+ .refreshToken(RefreshTokenRequest(refreshToken))
+ }.getOrNull()
+ val body = refreshed?.body()?.data
+ if (refreshed?.isSuccessful == true && body != null) {
+ val expiresAt = parseExpiresAt(body.accessExpires)
+ session.saveTokens(body.accessToken, body.refreshToken, expiresAt)
+ body.accessToken
+ } else {
+ null
+ }
+ }
+ } ?: return@Authenticator null
+
+ response.request.newBuilder()
+ .header("Authorization", "Bearer $newTokens")
+ .header("X-Retry-Count", "1")
+ .build()
+ }
+
+ val logging = HttpLoggingInterceptor().apply {
+ level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC
+ else HttpLoggingInterceptor.Level.NONE
+ }
+
+ val client = OkHttpClient.Builder()
+ .connectTimeout(15, TimeUnit.SECONDS)
+ .readTimeout(60, TimeUnit.SECONDS)
+ .writeTimeout(60, TimeUnit.SECONDS)
+ .addInterceptor(authInterceptor)
+ .addInterceptor(logging)
+ .authenticator(refreshAuthenticator)
+ .build()
+
+ return Retrofit.Builder()
+ .baseUrl(baseUrl)
+ .client(client)
+ .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
+ .build()
+ .create(ApiService::class.java)
+ }
+
+ companion object {
+ // OffsetDateTime parses both "…Z" and "+02:00" offsets; unknown → refresh early.
+ fun parseExpiresAt(iso: String): Long = runCatching {
+ java.time.OffsetDateTime.parse(iso).toInstant().toEpochMilli()
+ }.getOrDefault(0L)
+ }
+}
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
new file mode 100644
index 00000000..38b60623
--- /dev/null
+++ b/android/app/src/main/java/org/cloudreve/android/api/ApiService.kt
@@ -0,0 +1,71 @@
+package org.cloudreve.android.api
+
+import okhttp3.RequestBody
+import okhttp3.ResponseBody
+import retrofit2.Response
+import retrofit2.http.Body
+import retrofit2.http.DELETE
+import retrofit2.http.GET
+import retrofit2.http.HTTP
+import retrofit2.http.POST
+import retrofit2.http.PUT
+import retrofit2.http.Path
+import retrofit2.http.Query
+import retrofit2.http.Url
+
+interface ApiService {
+
+ @POST("api/v4/session/token")
+ suspend fun login(@Body request: PasswordLoginRequest): Response>
+
+ @POST("api/v4/session/token/refresh")
+ suspend fun refreshToken(@Body request: RefreshTokenRequest): Response>
+
+ @DELETE("api/v4/session/token")
+ suspend fun signOut(@Body request: RefreshTokenRequest): Response>
+
+ @GET("api/v4/site/config/login")
+ suspend fun siteConfig(): Response>
+
+ @GET("api/v4/file")
+ suspend fun listFiles(
+ @Query("uri") uri: String,
+ @Query("page_size") pageSize: Int = 50,
+ @Query("next_page_token") nextPageToken: String? = null,
+ ): Response>
+
+ @POST("api/v4/file/url")
+ suspend fun fileUrls(@Body request: FileUrlRequest): Response>
+
+ @POST("api/v4/file/create")
+ suspend fun createFile(@Body request: CreateFileRequest): Response>
+
+ @POST("api/v4/file/rename")
+ suspend fun renameFile(@Body request: RenameFileRequest): Response>
+
+ @HTTP(method = "DELETE", path = "api/v4/file", hasBody = true)
+ suspend fun deleteFiles(@Body request: DeleteFileRequest): Response>
+
+ @PUT("api/v4/file/upload")
+ suspend fun createUploadSession(@Body request: CreateUploadSessionRequest): Response>
+
+ // Chunk targets are fully resolved URLs returned in upload_urls —
+ // same-origin for local policies, presigned for remote policies.
+ @POST
+ suspend fun uploadChunk(@Url url: String, @Body body: RequestBody): Response
+
+ @PUT
+ suspend fun uploadChunkPut(@Url url: String, @Body body: RequestBody): Response
+
+ @POST
+ suspend fun completeUpload(@Url url: String): Response
+
+ @GET
+ suspend fun download(@Url url: String): Response
+
+ @GET("api/v4/file/thumb")
+ suspend fun thumb(@Query("uri") uri: String): Response>
+
+ @PUT("api/v4/share")
+ suspend fun createShare(@Body request: ShareCreateRequest): 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
new file mode 100644
index 00000000..d4af8e5b
--- /dev/null
+++ b/android/app/src/main/java/org/cloudreve/android/api/Dto.kt
@@ -0,0 +1,182 @@
+package org.cloudreve.android.api
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class ApiResponse(
+ val code: Int = 0,
+ val msg: String = "",
+ val data: T? = null,
+)
+
+@Serializable
+data class Token(
+ @SerialName("access_token") val accessToken: String = "",
+ @SerialName("refresh_token") val refreshToken: String = "",
+ @SerialName("access_expires") val accessExpires: String = "",
+ @SerialName("refresh_expires") val refreshExpires: String = "",
+)
+
+@Serializable
+data class User(
+ val id: String = "",
+ val email: String = "",
+ val nickname: String = "",
+ @SerialName("user_name") val userName: String = "",
+ val status: String = "",
+ val avatar: String = "",
+)
+
+@Serializable
+data class LoginResponse(
+ val user: User = User(),
+ val token: Token = Token(),
+)
+
+@Serializable
+data class PasswordLoginRequest(
+ val email: String,
+ val password: String,
+)
+
+@Serializable
+data class RefreshTokenRequest(
+ @SerialName("refresh_token") val refreshToken: String,
+)
+
+@Serializable
+data class RefreshTokenResponse(
+ @SerialName("access_token") val accessToken: String = "",
+ @SerialName("refresh_token") val refreshToken: String = "",
+ @SerialName("access_expires") val accessExpires: String = "",
+ @SerialName("refresh_expires") val refreshExpires: String = "",
+)
+
+@Serializable
+data class FileObject(
+ val type: Int = 0,
+ val id: String = "",
+ val name: String = "",
+ val permission: String? = null,
+ @SerialName("created_at") val createdAt: String = "",
+ @SerialName("updated_at") val updatedAt: String = "",
+ val size: Long = 0,
+ val metadata: Map? = null,
+ val path: String = "",
+ val shared: Boolean? = null,
+ val thumbnail: Boolean? = null,
+) {
+ val isFolder: Boolean get() = type == 1
+}
+
+@Serializable
+data class Pagination(
+ @SerialName("next_token") val nextToken: String? = null,
+ @SerialName("page_size") val pageSize: Int = 0,
+ @SerialName("is_cursor") val isCursor: Boolean = false,
+)
+
+@Serializable
+data class ListFileResponse(
+ val files: List = emptyList(),
+ val pagination: Pagination = Pagination(),
+ @SerialName("storage_policy") val storagePolicy: StoragePolicy? = null,
+ val parent: FileObject? = null,
+)
+
+@Serializable
+data class StoragePolicy(
+ val id: String = "",
+ val name: String = "",
+ val type: String = "",
+)
+
+@Serializable
+data class FileUrlRequest(
+ val uris: List,
+ val download: Boolean = false,
+)
+
+@Serializable
+data class FileUrlResponse(
+ val urls: List = emptyList(),
+)
+
+@Serializable
+data class FileUrl(
+ val url: String = "",
+)
+
+@Serializable
+data class CreateFileRequest(
+ val uri: String,
+ val type: String,
+ @SerialName("err_on_conflict") val errOnConflict: Boolean = false,
+)
+
+@Serializable
+data class RenameFileRequest(
+ val uri: String,
+ @SerialName("new_name") val newName: String,
+)
+
+@Serializable
+data class DeleteFileRequest(
+ val uris: List,
+ val unlink: Boolean = false,
+)
+
+@Serializable
+data class CreateUploadSessionRequest(
+ val uri: String,
+ val size: Long,
+ @SerialName("last_modified") val lastModified: Long = 0,
+ @SerialName("mime_type") val mimeType: String = "",
+ @SerialName("policy_id") val policyId: String = "",
+ @SerialName("entity_type") val entityType: String = "",
+ val previous: String = "",
+)
+
+@Serializable
+data class UploadSessionResponse(
+ @SerialName("session_id") val sessionId: String = "",
+ @SerialName("upload_id") val uploadId: String = "",
+ @SerialName("chunk_size") val chunkSize: Long = 0,
+ val expires: Long = 0,
+ @SerialName("upload_urls") val uploadUrls: List? = null,
+ @SerialName("completeURL") val completeUrl: String = "",
+ val uri: String = "",
+ @SerialName("storage_policy") val storagePolicy: StoragePolicy? = null,
+ @SerialName("callback_secret") val callbackSecret: String = "",
+)
+
+@Serializable
+data class ThumbResponse(
+ val url: String = "",
+ val expires: String? = null,
+)
+
+@Serializable
+data class ShareCreateRequest(
+ val uri: String,
+ val downloads: Int = 0,
+ @SerialName("is_private") val isPrivate: Boolean = false,
+ val password: String = "",
+ val expire: Int = 0,
+ @SerialName("share_view") val shareView: Boolean = false,
+ @SerialName("show_readme") val showReadme: Boolean = false,
+ @SerialName("allow_upload") val allowUpload: Boolean = false,
+ @SerialName("allow_edit") val allowEdit: Boolean = false,
+ @SerialName("preview_only") val previewOnly: Boolean = false,
+ @SerialName("upload_only") val uploadOnly: Boolean = false,
+)
+
+@Serializable
+data class SiteConfig(
+ val authn: Boolean = false,
+ @SerialName("register_enabled") val registerEnabled: Boolean = false,
+ @SerialName("invitation_code") val invitationCode: Boolean = false,
+ @SerialName("tos_url") val tosUrl: String = "",
+ @SerialName("privacy_policy_url") val privacyPolicyUrl: String = "",
+)
diff --git a/android/app/src/main/java/org/cloudreve/android/data/FileRepository.kt b/android/app/src/main/java/org/cloudreve/android/data/FileRepository.kt
new file mode 100644
index 00000000..3e7b25d1
--- /dev/null
+++ b/android/app/src/main/java/org/cloudreve/android/data/FileRepository.kt
@@ -0,0 +1,161 @@
+package org.cloudreve.android.data
+
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.cloudreve.android.api.ApiClient
+import org.cloudreve.android.api.CreateFileRequest
+import org.cloudreve.android.api.CreateUploadSessionRequest
+import org.cloudreve.android.api.DeleteFileRequest
+import org.cloudreve.android.api.FileObject
+import org.cloudreve.android.api.FileUrlRequest
+import org.cloudreve.android.api.ListFileResponse
+import org.cloudreve.android.api.RenameFileRequest
+import org.cloudreve.android.api.ShareCreateRequest
+import org.cloudreve.android.api.UploadSessionResponse
+import org.cloudreve.android.util.CrUri
+import retrofit2.Response
+import java.io.File
+
+class ApiException(val code: Int, message: String) : Exception(message)
+
+class FileRepository(private val api: ApiClient) {
+
+ private fun Response>.unwrap(): T {
+ if (!isSuccessful) throw ApiException(code(), "HTTP ${code()}")
+ val body = body() ?: throw ApiException(-1, "Empty response")
+ if (body.code != 0 || body.data == null) {
+ throw ApiException(body.code, body.msg.ifEmpty { "API error ${body.code}" })
+ }
+ return body.data
+ }
+
+ private fun Response>.unwrapEmpty() {
+ if (!isSuccessful) throw ApiException(code(), "HTTP ${code()}")
+ val body = body() ?: throw ApiException(-1, "Empty response")
+ if (body.code != 0) {
+ throw ApiException(body.code, body.msg.ifEmpty { "API error ${body.code}" })
+ }
+ }
+
+ suspend fun list(uri: String, pageToken: String? = null): ListFileResponse =
+ api.service().listFiles(uri = uri, nextPageToken = pageToken).unwrap()
+
+ suspend fun downloadUrl(uri: String): String =
+ api.service().fileUrls(FileUrlRequest(listOf(uri), download = true))
+ .unwrap().urls.firstOrNull()?.url
+ ?: throw ApiException(-1, "No download URL returned")
+
+ suspend fun download(url: String) = api.service().download(url)
+
+ /** Resolves a thumbnail URL for the file, or null when none exists. */
+ suspend fun thumbUrl(uri: String): String? =
+ runCatching { api.service().thumb(uri).unwrap().url }.getOrNull()
+
+ /** Creates a public share link and returns its absolute URL. */
+ suspend fun createShare(uri: String): String {
+ val id = api.service().createShare(ShareCreateRequest(uri = uri)).unwrap()
+ val base = api.serverBase()
+ return "$base/s/$id"
+ }
+
+ suspend fun mkdir(uri: String) {
+ api.service().createFile(CreateFileRequest(uri = uri, type = "folder")).unwrapEmpty()
+ }
+
+ suspend fun rename(uri: String, newName: String) {
+ api.service().renameFile(RenameFileRequest(uri = uri, newName = newName)).unwrapEmpty()
+ }
+
+ suspend fun delete(uris: List) {
+ api.service().deleteFiles(DeleteFileRequest(uris = uris)).unwrapEmpty()
+ }
+
+ suspend fun createUploadSession(
+ uri: String,
+ size: Long,
+ lastModified: Long,
+ mimeType: String,
+ ): UploadSessionResponse =
+ api.service().createUploadSession(
+ CreateUploadSessionRequest(
+ uri = uri,
+ size = size,
+ lastModified = lastModified,
+ mimeType = mimeType,
+ )
+ ).unwrap()
+
+ /**
+ * Streams a local file through the upload-session flow: create session,
+ * PUT/POST each chunk to its resolved URL, then call completeURL when the
+ * policy requires an explicit finalize step (e.g. S3 multipart).
+ */
+ suspend fun uploadFile(
+ localFile: File,
+ remoteParentUri: String,
+ onProgress: (sent: Long, total: Long) -> Unit = { _, _ -> },
+ ): UploadSessionResponse {
+ val targetUri = CrUri.join(remoteParentUri, localFile.name)
+ val mime = java.net.URLConnection.guessContentTypeFromName(localFile.name)
+ ?: "application/octet-stream"
+ val session = createUploadSession(
+ uri = targetUri,
+ size = localFile.length(),
+ lastModified = localFile.lastModified(),
+ mimeType = mime,
+ )
+
+ val urls = session.uploadUrls
+ if (urls.isNullOrEmpty()) {
+ // Zero-chunk policies (e.g. rapid-upload hit) need nothing further.
+ return session
+ }
+
+ val svc = api.service()
+ val chunkSize = if (session.chunkSize > 0) session.chunkSize else localFile.length().coerceAtLeast(1)
+ var sent = 0L
+ localFile.inputStream().buffered().use { input ->
+ val buffer = ByteArray(chunkSize.coerceAtMost(64L * 1024 * 1024).toInt())
+ var index = 0
+ while (true) {
+ val read = input.readNBytes(buffer, 0, buffer.size)
+ if (read <= 0 || index >= urls.size) break
+ val target = resolveUrl(urls[index])
+ val body = buffer.copyOf(read)
+ .toRequestBody(mime.toMediaTypeOrNull())
+ val resp = if (target.contains("/file/upload/")) {
+ svc.uploadChunk(target, body)
+ } else {
+ // Presigned third-party URLs are conventionally PUT.
+ svc.uploadChunkPut(target, body)
+ }
+ if (!resp.isSuccessful) {
+ throw ApiException(resp.code(), "Chunk $index upload failed: HTTP ${resp.code()}")
+ }
+ resp.body()?.close()
+ sent += read
+ index++
+ onProgress(sent, localFile.length())
+ }
+ }
+
+ if (session.completeUrl.isNotEmpty()) {
+ val resp = svc.completeUpload(resolveUrl(session.completeUrl))
+ if (!resp.isSuccessful) {
+ throw ApiException(resp.code(), "Upload completion failed: HTTP ${resp.code()}")
+ }
+ resp.body()?.close()
+ }
+ return session
+ }
+
+ private suspend fun resolveUrl(url: String): String {
+ if (url.startsWith("http")) return url
+ return session_serverBase() + url
+ }
+
+ private suspend fun session_serverBase(): String {
+ val base = api.serverBase()
+ return if (base.endsWith('/')) base.dropLast(1) else base
+ }
+}
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
new file mode 100644
index 00000000..7e44d379
--- /dev/null
+++ b/android/app/src/main/java/org/cloudreve/android/data/SessionManager.kt
@@ -0,0 +1,76 @@
+package org.cloudreve.android.data
+
+import android.content.Context
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.longPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+
+private val Context.sessionStore by preferencesDataStore(name = "session")
+
+/**
+ * SessionManager persists the server base URL and token pair. It is the
+ * single source of truth for "which account, on which server".
+ */
+class SessionManager(private val context: Context) {
+
+ private object Keys {
+ val serverUrl = stringPreferencesKey("server_url")
+ val accessToken = stringPreferencesKey("access_token")
+ val refreshToken = stringPreferencesKey("refresh_token")
+ // Epoch millis when access_token expires; 0 = unknown.
+ val accessExpiresAt = longPreferencesKey("access_expires_at")
+ val userEmail = stringPreferencesKey("user_email")
+ val userNick = stringPreferencesKey("user_nick")
+ }
+
+ val serverUrl: Flow = context.sessionStore.data.map { it[Keys.serverUrl] ?: "" }
+ val accessToken: Flow = context.sessionStore.data.map { it[Keys.accessToken] ?: "" }
+ val userEmail: Flow = context.sessionStore.data.map { it[Keys.userEmail] ?: "" }
+ val userNick: Flow = context.sessionStore.data.map { it[Keys.userNick] ?: "" }
+ val isLoggedIn: Flow = context.sessionStore.data.map {
+ !it[Keys.refreshToken].isNullOrEmpty() && !it[Keys.serverUrl].isNullOrEmpty()
+ }
+
+ suspend fun serverUrlNow(): String = serverUrl.first()
+ suspend fun accessTokenNow(): String = accessToken.first()
+ suspend fun refreshTokenNow(): String =
+ context.sessionStore.data.first()[Keys.refreshToken] ?: ""
+ suspend fun accessExpiresAtNow(): Long =
+ context.sessionStore.data.first()[Keys.accessExpiresAt] ?: 0L
+
+ suspend fun saveServerUrl(url: String) {
+ context.sessionStore.edit { it[Keys.serverUrl] = url.trimEnd('/') }
+ }
+
+ suspend fun saveSession(
+ accessToken: String,
+ refreshToken: String,
+ accessExpiresAt: Long,
+ email: String,
+ nick: String,
+ ) {
+ context.sessionStore.edit {
+ it[Keys.accessToken] = accessToken
+ it[Keys.refreshToken] = refreshToken
+ it[Keys.accessExpiresAt] = accessExpiresAt
+ it[Keys.userEmail] = email
+ it[Keys.userNick] = nick
+ }
+ }
+
+ suspend fun saveTokens(accessToken: String, refreshToken: String, accessExpiresAt: Long) {
+ context.sessionStore.edit {
+ it[Keys.accessToken] = accessToken
+ it[Keys.refreshToken] = refreshToken
+ it[Keys.accessExpiresAt] = accessExpiresAt
+ }
+ }
+
+ suspend fun clear() {
+ context.sessionStore.edit { it.clear() }
+ }
+}
diff --git a/android/app/src/main/java/org/cloudreve/android/ui/files/FilesScreen.kt b/android/app/src/main/java/org/cloudreve/android/ui/files/FilesScreen.kt
new file mode 100644
index 00000000..21a18228
--- /dev/null
+++ b/android/app/src/main/java/org/cloudreve/android/ui/files/FilesScreen.kt
@@ -0,0 +1,418 @@
+package org.cloudreve.android.ui.files
+
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.ExitToApp
+import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
+import androidx.compose.material.icons.filled.CreateNewFolder
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Download
+import androidx.compose.material.icons.filled.DriveFileRenameOutline
+import androidx.compose.material.icons.filled.Folder
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material.icons.filled.UploadFile
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.core.content.FileProvider
+import kotlinx.coroutines.launch
+import org.cloudreve.android.api.FileObject
+import org.cloudreve.android.util.CrUri
+import org.cloudreve.android.work.UploadWorker
+import java.text.DecimalFormat
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun FilesScreen(
+ viewModel: FilesViewModel,
+ onSignOut: () -> Unit,
+) {
+ val state by viewModel.state.collectAsState()
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val snackbar = remember { SnackbarHostState() }
+ val listState = rememberLazyListState()
+
+ var mkdirOpen by remember { mutableStateOf(false) }
+ var renameTarget by remember { mutableStateOf(null) }
+ var deleteTarget by remember { mutableStateOf(null) }
+
+ val uploadLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.OpenMultipleDocuments()
+ ) { uris ->
+ if (uris.isNotEmpty()) {
+ UploadWorker.enqueue(context, uris, state.currentUri)
+ scope.launch { snackbar.showSnackbar("Upload queued (${uris.size} file${if (uris.size > 1) "s" else ""})") }
+ }
+ }
+
+ LaunchedEffect(Unit) { viewModel.refresh() }
+
+ LaunchedEffect(state.snackbar) {
+ state.snackbar?.let {
+ snackbar.showSnackbar(it)
+ viewModel.consumeSnackbar()
+ }
+ }
+
+ // Infinite scroll — load next page when the end is near.
+ LaunchedEffect(listState) {
+ snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index }
+ .collect { last ->
+ if (last != null && last >= state.files.size - 5) viewModel.loadMore()
+ }
+ }
+
+ BackHandler(enabled = !CrUri.isRoot(state.currentUri)) {
+ viewModel.navigateUp()
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Column {
+ Text("Files")
+ Text(
+ pathLabel(state.currentUri),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ },
+ navigationIcon = {
+ if (!CrUri.isRoot(state.currentUri)) {
+ IconButton(onClick = { viewModel.navigateUp() }) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Up")
+ }
+ }
+ },
+ actions = {
+ IconButton(onClick = { mkdirOpen = true }) {
+ Icon(Icons.Default.CreateNewFolder, contentDescription = "New folder")
+ }
+ IconButton(onClick = { viewModel.refresh() }) {
+ Icon(Icons.Default.Refresh, contentDescription = "Refresh")
+ }
+ IconButton(onClick = onSignOut) {
+ Icon(Icons.AutoMirrored.Filled.ExitToApp, contentDescription = "Sign out")
+ }
+ },
+ )
+ },
+ floatingActionButton = {
+ FloatingActionButton(onClick = { uploadLauncher.launch(arrayOf("*/*")) }) {
+ Icon(Icons.Default.UploadFile, contentDescription = "Upload")
+ }
+ },
+ snackbarHost = { SnackbarHost(snackbar) },
+ ) { padding ->
+ PullToRefreshBox(
+ isRefreshing = state.loading,
+ onRefresh = { viewModel.refresh() },
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ ) {
+ when {
+ state.loading && state.files.isEmpty() -> {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ }
+ state.error != null -> {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(state.error ?: "Error", color = MaterialTheme.colorScheme.error)
+ TextButton(onClick = { viewModel.refresh() }) { Text("Retry") }
+ }
+ }
+ }
+ state.files.isEmpty() -> {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text("Empty folder", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ else -> {
+ LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
+ items(state.files, key = { it.id + it.path }) { file ->
+ FileRow(
+ file = file,
+ thumbUrl = thumbUrlFor(viewModel, file),
+ onClick = {
+ if (file.isFolder) {
+ viewModel.navigateTo(file)
+ } else {
+ scope.launch {
+ runCatching { viewModel.downloadToCache(context, file) }
+ .onSuccess { local -> openFile(context, local) }
+ .onFailure { snackbar.showSnackbar(it.message ?: "Download failed") }
+ }
+ }
+ },
+ onRename = { renameTarget = file },
+ onDelete = { deleteTarget = file },
+ onShare = {
+ scope.launch {
+ runCatching { viewModel.shareLink(file) }
+ .onSuccess { link ->
+ val cm = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE)
+ as android.content.ClipboardManager
+ cm.setPrimaryClip(
+ android.content.ClipData.newPlainText("share", link)
+ )
+ snackbar.showSnackbar("Share link copied")
+ }
+ .onFailure { snackbar.showSnackbar(it.message ?: "Share failed") }
+ }
+ },
+ onDownload = {
+ scope.launch {
+ runCatching { viewModel.downloadToCache(context, file) }
+ .onSuccess { snackbar.showSnackbar("Saved to cache: ${it.name}") }
+ .onFailure { snackbar.showSnackbar(it.message ?: "Download failed") }
+ }
+ },
+ )
+ HorizontalDivider()
+ }
+ if (state.loadingMore) {
+ item {
+ Row(
+ Modifier.fillMaxWidth().padding(12.dp),
+ horizontalArrangement = Arrangement.Center,
+ ) { CircularProgressIndicator() }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (mkdirOpen) {
+ TextFieldDialog(
+ title = "New folder",
+ label = "Folder name",
+ confirm = "Create",
+ onConfirm = {
+ viewModel.mkdir(it)
+ mkdirOpen = false
+ },
+ onDismiss = { mkdirOpen = false },
+ )
+ }
+
+ renameTarget?.let { file ->
+ TextFieldDialog(
+ title = "Rename",
+ label = "New name",
+ initial = file.name,
+ confirm = "Rename",
+ onConfirm = {
+ viewModel.rename(file, it)
+ renameTarget = null
+ },
+ onDismiss = { renameTarget = null },
+ )
+ }
+
+ deleteTarget?.let { file ->
+ AlertDialog(
+ onDismissRequest = { deleteTarget = null },
+ title = { Text("Delete ${file.name}?") },
+ text = { Text("The file will be moved to trash.") },
+ confirmButton = {
+ TextButton(onClick = {
+ viewModel.delete(file)
+ deleteTarget = null
+ }) { Text("Delete", color = MaterialTheme.colorScheme.error) }
+ },
+ dismissButton = {
+ TextButton(onClick = { deleteTarget = null }) { Text("Cancel") }
+ },
+ )
+ }
+}
+
+private fun pathLabel(uri: String): String {
+ val path = uri.removePrefix(CrUri.MY_PREFIX).trim('/')
+ return if (path.isEmpty()) "My files" else "My files / ${java.net.URLDecoder.decode(path, "UTF-8")}"
+}
+
+private fun openFile(context: android.content.Context, local: java.io.File) {
+ val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", local)
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uri, context.contentResolver.getType(uri) ?: "*/*")
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ try {
+ context.startActivity(intent)
+ } catch (_: ActivityNotFoundException) {
+ // No handler — the file is still in cache for manual access.
+ }
+}
+
+@Composable
+private fun thumbUrlFor(viewModel: FilesViewModel, file: FileObject): String? {
+ val url by produceState(initialValue = null, file.id) {
+ value = viewModel.thumbUrl(file)
+ }
+ return url
+}
+
+@Composable
+private fun FileRow(
+ file: FileObject,
+ thumbUrl: String?,
+ onClick: () -> Unit,
+ onRename: () -> Unit,
+ onDelete: () -> Unit,
+ onShare: () -> Unit,
+ onDownload: () -> Unit,
+) {
+ var menuOpen by remember { mutableStateOf(false) }
+ ListItem(
+ modifier = Modifier.clickable(onClick = onClick),
+ headlineContent = { Text(file.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
+ supportingContent = {
+ Text(
+ if (file.isFolder) "Folder" else formatSize(file.size),
+ style = MaterialTheme.typography.labelMedium,
+ )
+ },
+ leadingContent = {
+ if (thumbUrl != null) {
+ coil.compose.AsyncImage(
+ model = thumbUrl,
+ contentDescription = null,
+ modifier = Modifier.size(40.dp),
+ contentScale = androidx.compose.ui.layout.ContentScale.Crop,
+ )
+ } else {
+ Icon(
+ if (file.isFolder) Icons.Default.Folder else Icons.AutoMirrored.Filled.InsertDriveFile,
+ contentDescription = null,
+ tint = if (file.isFolder) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ },
+ trailingContent = {
+ Box {
+ IconButton(onClick = { menuOpen = true }) {
+ Icon(Icons.Default.MoreVert, contentDescription = "More")
+ }
+ DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
+ if (!file.isFolder) {
+ DropdownMenuItem(
+ text = { Text("Download") },
+ leadingIcon = { Icon(Icons.Default.Download, null) },
+ onClick = { menuOpen = false; onDownload() },
+ )
+ }
+ DropdownMenuItem(
+ text = { Text("Share") },
+ leadingIcon = { Icon(Icons.Default.Share, null) },
+ onClick = { menuOpen = false; onShare() },
+ )
+ DropdownMenuItem(
+ text = { Text("Rename") },
+ leadingIcon = { Icon(Icons.Default.DriveFileRenameOutline, null) },
+ onClick = { menuOpen = false; onRename() },
+ )
+ DropdownMenuItem(
+ text = { Text("Delete") },
+ leadingIcon = { Icon(Icons.Default.Delete, null) },
+ onClick = { menuOpen = false; onDelete() },
+ )
+ }
+ }
+ },
+ )
+}
+
+@Composable
+private fun TextFieldDialog(
+ title: String,
+ label: String,
+ confirm: String,
+ initial: String = "",
+ onConfirm: (String) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ var value by remember { mutableStateOf(initial) }
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(title) },
+ text = {
+ OutlinedTextField(
+ value = value,
+ onValueChange = { value = it },
+ label = { Text(label) },
+ singleLine = true,
+ )
+ },
+ confirmButton = { TextButton(onClick = { onConfirm(value) }) { Text(confirm) } },
+ dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
+ )
+}
+
+private fun formatSize(size: Long): String {
+ if (size <= 0) return "0 B"
+ val units = arrayOf("B", "KB", "MB", "GB", "TB")
+ val group = (Math.log10(size.toDouble()) / Math.log10(1024.0)).toInt().coerceAtMost(units.size - 1)
+ return DecimalFormat("#,##0.#").format(size / Math.pow(1024.0, group.toDouble())) + " " + units[group]
+}
diff --git a/android/app/src/main/java/org/cloudreve/android/ui/files/FilesViewModel.kt b/android/app/src/main/java/org/cloudreve/android/ui/files/FilesViewModel.kt
new file mode 100644
index 00000000..ef4f0bb3
--- /dev/null
+++ b/android/app/src/main/java/org/cloudreve/android/ui/files/FilesViewModel.kt
@@ -0,0 +1,131 @@
+package org.cloudreve.android.ui.files
+
+import android.content.Context
+import android.net.Uri
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import org.cloudreve.android.api.FileObject
+import org.cloudreve.android.data.FileRepository
+import org.cloudreve.android.util.CrUri
+import java.io.File
+import java.io.FileOutputStream
+
+data class FilesUiState(
+ val currentUri: String = CrUri.MY_PREFIX,
+ val files: List = emptyList(),
+ val loading: Boolean = false,
+ val loadingMore: Boolean = false,
+ val nextToken: String? = null,
+ val error: String? = null,
+ val snackbar: String? = null,
+)
+
+class FilesViewModel(private val repo: FileRepository) : ViewModel() {
+
+ private val _state = MutableStateFlow(FilesUiState())
+ val state: StateFlow = _state
+
+ fun refresh() {
+ val uri = _state.value.currentUri
+ _state.value = _state.value.copy(loading = true, error = null, files = emptyList(), nextToken = null)
+ viewModelScope.launch {
+ try {
+ val res = repo.list(uri)
+ _state.value = _state.value.copy(
+ loading = false,
+ files = sortFiles(res.files),
+ nextToken = res.pagination.nextToken,
+ )
+ } catch (e: Exception) {
+ _state.value = _state.value.copy(loading = false, error = e.message)
+ }
+ }
+ }
+
+ fun loadMore() {
+ val s = _state.value
+ val token = s.nextToken ?: return
+ if (s.loadingMore) return
+ _state.value = s.copy(loadingMore = true)
+ viewModelScope.launch {
+ try {
+ val res = repo.list(s.currentUri, pageToken = token)
+ _state.value = _state.value.copy(
+ loadingMore = false,
+ files = sortFiles(_state.value.files + res.files),
+ nextToken = res.pagination.nextToken,
+ )
+ } catch (e: Exception) {
+ _state.value = _state.value.copy(loadingMore = false, snackbar = e.message)
+ }
+ }
+ }
+
+ fun navigateTo(file: FileObject) {
+ if (!file.isFolder) return
+ _state.value = _state.value.copy(currentUri = file.path)
+ refresh()
+ }
+
+ fun navigateUp(): Boolean {
+ val cur = _state.value.currentUri
+ if (CrUri.isRoot(cur)) return false
+ _state.value = _state.value.copy(currentUri = CrUri.parent(cur))
+ refresh()
+ return true
+ }
+
+ fun mkdir(name: String) {
+ if (name.isBlank()) return
+ viewModelScope.launch {
+ runCatching { repo.mkdir(CrUri.join(_state.value.currentUri, name)) }
+ .onSuccess { refresh() }
+ .onFailure { _state.value = _state.value.copy(snackbar = it.message) }
+ }
+ }
+
+ fun rename(file: FileObject, newName: String) {
+ if (newName.isBlank() || newName == file.name) return
+ viewModelScope.launch {
+ runCatching { repo.rename(file.path, newName) }
+ .onSuccess { refresh() }
+ .onFailure { _state.value = _state.value.copy(snackbar = it.message) }
+ }
+ }
+
+ fun delete(file: FileObject) {
+ viewModelScope.launch {
+ runCatching { repo.delete(listOf(file.path)) }
+ .onSuccess { refresh() }
+ .onFailure { _state.value = _state.value.copy(snackbar = it.message) }
+ }
+ }
+
+ /** Downloads into app cache and returns the file for the caller to open/share. */
+ suspend fun downloadToCache(context: Context, file: FileObject): File {
+ val url = repo.downloadUrl(file.path)
+ val resp = repo.download(url)
+ if (!resp.isSuccessful) throw Exception("Download failed: HTTP ${resp.code()}")
+ val dir = File(context.cacheDir, "downloads").apply { mkdirs() }
+ val out = File(dir, file.name)
+ resp.body()!!.byteStream().use { input ->
+ FileOutputStream(out).use { input.copyTo(it) }
+ }
+ return out
+ }
+
+ suspend fun shareLink(file: FileObject): String = repo.createShare(file.path)
+
+ suspend fun thumbUrl(file: FileObject): String? =
+ if (file.thumbnail == true) repo.thumbUrl(file.path) else null
+
+ fun consumeSnackbar() {
+ _state.value = _state.value.copy(snackbar = null)
+ }
+
+ private fun sortFiles(files: List): List =
+ files.sortedWith(compareByDescending { it.isFolder }.thenBy { it.name.lowercase() })
+}
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
new file mode 100644
index 00000000..dbd4182a
--- /dev/null
+++ b/android/app/src/main/java/org/cloudreve/android/ui/login/LoginScreen.kt
@@ -0,0 +1,98 @@
+package org.cloudreve.android.ui.login
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun LoginScreen(viewModel: LoginViewModel, onLoggedIn: () -> Unit) {
+ val state by viewModel.state.collectAsState()
+
+ LaunchedEffect(state.loggedIn) {
+ if (state.loggedIn) onLoggedIn()
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text("Cloudreve", style = MaterialTheme.typography.headlineLarge)
+ Spacer(Modifier.height(8.dp))
+ Text(
+ "Sign in to your server",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(32.dp))
+
+ OutlinedTextField(
+ value = state.serverUrl,
+ onValueChange = viewModel::onServerUrlChange,
+ label = { Text("Server URL") },
+ placeholder = { Text("https://drive.example.com") },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(Modifier.height(12.dp))
+ OutlinedTextField(
+ value = state.email,
+ onValueChange = viewModel::onEmailChange,
+ label = { Text("Email") },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(Modifier.height(12.dp))
+ OutlinedTextField(
+ value = state.password,
+ onValueChange = viewModel::onPasswordChange,
+ label = { Text("Password") },
+ singleLine = true,
+ visualTransformation = PasswordVisualTransformation(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ state.error?.let {
+ Spacer(Modifier.height(12.dp))
+ Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
+ }
+
+ Spacer(Modifier.height(24.dp))
+ Button(
+ onClick = viewModel::login,
+ enabled = !state.loading,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ if (state.loading) {
+ CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
+ } else {
+ Text("Sign in")
+ }
+ }
+ }
+}
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
new file mode 100644
index 00000000..b966e838
--- /dev/null
+++ b/android/app/src/main/java/org/cloudreve/android/ui/login/LoginViewModel.kt
@@ -0,0 +1,62 @@
+package org.cloudreve.android.ui.login
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import org.cloudreve.android.api.ApiClient
+import org.cloudreve.android.api.PasswordLoginRequest
+import org.cloudreve.android.data.SessionManager
+
+data class LoginUiState(
+ val serverUrl: String = "",
+ val email: String = "",
+ val password: String = "",
+ val loading: Boolean = false,
+ val error: String? = null,
+ val loggedIn: Boolean = false,
+)
+
+class LoginViewModel(
+ private val session: SessionManager,
+ private val api: ApiClient,
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(LoginUiState())
+ val state: StateFlow = _state
+
+ fun onServerUrlChange(v: String) = _state.value.let { _state.value = it.copy(serverUrl = v, error = null) }
+ fun onEmailChange(v: String) = _state.value.let { _state.value = it.copy(email = v, error = null) }
+ fun onPasswordChange(v: String) = _state.value.let { _state.value = it.copy(password = v, error = null) }
+
+ fun login() {
+ val s = _state.value
+ if (s.serverUrl.isBlank() || s.email.isBlank() || s.password.isBlank()) {
+ _state.value = s.copy(error = "Fill in server, email and password")
+ return
+ }
+ _state.value = s.copy(loading = true, error = null)
+ viewModelScope.launch {
+ try {
+ session.saveServerUrl(s.serverUrl)
+ val resp = api.service().login(PasswordLoginRequest(s.email, s.password))
+ val body = resp.body()
+ if (!resp.isSuccessful || body == null || body.code != 0 || body.data == null) {
+ throw Exception(body?.msg ?: "Login failed (HTTP ${resp.code()})")
+ }
+ val login = body.data
+ session.saveSession(
+ accessToken = login.token.accessToken,
+ refreshToken = login.token.refreshToken,
+ accessExpiresAt = ApiClient.parseExpiresAt(login.token.accessExpires),
+ email = login.user.email,
+ nick = login.user.nickname,
+ )
+ _state.value = _state.value.copy(loading = false, loggedIn = true)
+ } catch (e: Exception) {
+ _state.value = _state.value.copy(loading = false, error = e.message ?: "Login failed")
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/org/cloudreve/android/ui/theme/Theme.kt b/android/app/src/main/java/org/cloudreve/android/ui/theme/Theme.kt
new file mode 100644
index 00000000..d315af03
--- /dev/null
+++ b/android/app/src/main/java/org/cloudreve/android/ui/theme/Theme.kt
@@ -0,0 +1,28 @@
+package org.cloudreve.android.ui.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+
+private val LightColors = lightColorScheme(
+ primary = Color(0xFF4A6FA5),
+ secondary = Color(0xFF6B8BB8),
+ surface = Color(0xFFFAFBFD),
+)
+
+private val DarkColors = darkColorScheme(
+ primary = Color(0xFF9DB8DC),
+ secondary = Color(0xFF8AA4CC),
+ surface = Color(0xFF121417),
+)
+
+@Composable
+fun CloudreveTheme(content: @Composable () -> Unit) {
+ MaterialTheme(
+ colorScheme = if (isSystemInDarkTheme()) DarkColors else LightColors,
+ content = content,
+ )
+}
diff --git a/android/app/src/main/java/org/cloudreve/android/util/CrUri.kt b/android/app/src/main/java/org/cloudreve/android/util/CrUri.kt
new file mode 100644
index 00000000..05001109
--- /dev/null
+++ b/android/app/src/main/java/org/cloudreve/android/util/CrUri.kt
@@ -0,0 +1,33 @@
+package org.cloudreve.android.util
+
+import java.net.URLDecoder
+import java.net.URLEncoder
+
+/**
+ * CrUri builds and manipulates `cloudreve://` URIs the same way the desktop
+ * client does: segments are percent-encoded individually, "/" stays.
+ */
+object CrUri {
+ const val MY_PREFIX = "cloudreve://my"
+
+ fun join(parent: String, name: String): String {
+ val encoded = name.split('/').filter { it.isNotEmpty() }
+ .joinToString("/") { URLEncoder.encode(it, "UTF-8").replace("+", "%20") }
+ return if (parent.endsWith('/')) parent + encoded else "$parent/$encoded"
+ }
+
+ fun child(parent: String, name: String): String = join(parent, name)
+
+ fun parent(uri: String): String {
+ val trimmed = uri.trimEnd('/')
+ val idx = trimmed.lastIndexOf('/')
+ return if (idx <= MY_PREFIX.length) MY_PREFIX else trimmed.substring(0, idx)
+ }
+
+ fun fileName(uri: String): String {
+ val seg = uri.trimEnd('/').substringAfterLast('/')
+ return URLDecoder.decode(seg, "UTF-8")
+ }
+
+ fun isRoot(uri: String): Boolean = uri.trimEnd('/') == MY_PREFIX
+}
diff --git a/android/app/src/main/java/org/cloudreve/android/work/UploadWorker.kt b/android/app/src/main/java/org/cloudreve/android/work/UploadWorker.kt
new file mode 100644
index 00000000..bc0b132d
--- /dev/null
+++ b/android/app/src/main/java/org/cloudreve/android/work/UploadWorker.kt
@@ -0,0 +1,113 @@
+package org.cloudreve.android.work
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.content.pm.ServiceInfo
+import android.net.Uri
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.work.CoroutineWorker
+import androidx.work.Data
+import androidx.work.ForegroundInfo
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import org.cloudreve.android.CloudreveApp
+import org.cloudreve.android.R
+import java.io.File
+import java.io.FileOutputStream
+
+/**
+ * UploadWorker copies SAF/content URIs into cache, then streams them through
+ * the Cloudreve upload-session flow. WorkManager survives process death for
+ * large transfers; the foreground notification shows progress.
+ */
+class UploadWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
+
+ override suspend fun doWork(): Result {
+ val uris = inputData.getStringArray(KEY_URIS)?.toList() ?: return Result.failure()
+ val parentUri = inputData.getString(KEY_PARENT_URI) ?: return Result.failure()
+ val app = applicationContext as CloudreveApp
+
+ createChannel()
+ var failed = 0
+ uris.forEachIndexed { i, uriString ->
+ val uri = Uri.parse(uriString)
+ val name = resolveDisplayName(uri)
+ setForeground(progressInfo("Uploading $name", i, uris.size))
+ try {
+ val tmp = stageToCache(uri, name)
+ try {
+ app.fileRepository.uploadFile(tmp, parentUri) { _, _ -> }
+ } finally {
+ tmp.delete()
+ }
+ } catch (_: Exception) {
+ failed++
+ }
+ }
+ return if (failed == 0) Result.success() else Result.failure(
+ Data.Builder().putInt(KEY_FAILED, failed).build()
+ )
+ }
+
+ private fun resolveDisplayName(uri: Uri): String {
+ val cursor = applicationContext.contentResolver.query(uri, null, null, null, null)
+ cursor?.use {
+ val idx = it.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
+ if (it.moveToFirst() && idx >= 0) return it.getString(idx)
+ }
+ return uri.lastPathSegment?.substringAfterLast('/') ?: "upload.bin"
+ }
+
+ private fun stageToCache(uri: Uri, name: String): File {
+ val dir = File(applicationContext.cacheDir, "upload_staging").apply { mkdirs() }
+ val tmp = File(dir, name)
+ applicationContext.contentResolver.openInputStream(uri)!!.use { input ->
+ FileOutputStream(tmp).use { input.copyTo(it) }
+ }
+ return tmp
+ }
+
+ private fun progressInfo(name: String, index: Int, total: Int): ForegroundInfo {
+ val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
+ .setSmallIcon(android.R.drawable.stat_sys_upload)
+ .setContentTitle("Cloudreve upload")
+ .setContentText("$name (${index + 1}/$total)")
+ .setOngoing(true)
+ .build()
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ ForegroundInfo(NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
+ } else {
+ ForegroundInfo(NOTIF_ID, notification)
+ }
+ }
+
+ private fun createChannel() {
+ val manager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ manager.createNotificationChannel(
+ NotificationChannel(CHANNEL_ID, "Uploads", NotificationManager.IMPORTANCE_LOW)
+ )
+ }
+
+ companion object {
+ const val KEY_URIS = "uris"
+ const val KEY_PARENT_URI = "parent_uri"
+ const val KEY_FAILED = "failed"
+ private const val CHANNEL_ID = "uploads"
+ private const val NOTIF_ID = 41
+
+ fun enqueue(context: Context, uris: List, parentUri: String) {
+ val request = OneTimeWorkRequestBuilder()
+ .setInputData(
+ Data.Builder()
+ .putStringArray(KEY_URIS, uris.map { it.toString() }.toTypedArray())
+ .putString(KEY_PARENT_URI, parentUri)
+ .build()
+ )
+ .build()
+ WorkManager.getInstance(context).enqueue(request)
+ }
+ }
+}
diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 00000000..d02506b4
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 00000000..a8a8fa55
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 00000000..d0f957c0
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #4A6FA5
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 00000000..709b645e
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ Cloudreve
+
diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml
new file mode 100644
index 00000000..d0b78d53
--- /dev/null
+++ b/android/app/src/main/res/values/themes.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 00000000..eb3792ac
--- /dev/null
+++ b/android/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 00000000..70dfb636
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,6 @@
+plugins {
+ id("com.android.application") version "8.7.3" apply false
+ id("org.jetbrains.kotlin.android") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
+}
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 00000000..541cbaff
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,6 @@
+org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
+org.gradle.parallel=true
+org.gradle.caching=true
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+kotlin.code.style=official
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000..a4b76b95
Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..e2847c82
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android/gradlew b/android/gradlew
new file mode 100755
index 00000000..d95bf613
--- /dev/null
+++ b/android/gradlew
@@ -0,0 +1,252 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
+' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/android/gradlew.bat b/android/gradlew.bat
new file mode 100644
index 00000000..640d6868
--- /dev/null
+++ b/android/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 00000000..42d29a4d
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "cloudreve-android"
+include(":app")