diff --git a/.gitignore b/.gitignore index 76c41faf..5194e827 100644 --- a/.gitignore +++ b/.gitignore @@ -36,7 +36,7 @@ dist/ data/ tmp/ .devcontainer/ -cloudreve +/cloudreve .DS_Store # Playwright MCP snapshots .playwright-mcp/ diff --git a/android/README.md b/android/README.md index da1aea21..e7ca7412 100644 --- a/android/README.md +++ b/android/README.md @@ -2,8 +2,9 @@ Native Android client for Cloudreve. Kotlin + Jetpack Compose, Material 3. -Status: **Phase E — v0 implemented.** Sign in, browse, download, upload, -rename, mkdir, delete. See `../ROADMAP.md` for the remaining scope. +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. ## Build @@ -24,9 +25,11 @@ CI runs `assembleDebug` on every PR. - 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 -- Thumbnails via `/file/thumb`, share-link creation, search +- Search - No iOS. Ever. 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) + } + } +}