Unanchored `cloudreve` and `data/` patterns silently ignored the whole Kotlin source tree — PR #178 shipped gradle+manifest only, and CI built an APK with no app code. Anchored both to repo root. Adds the v0 sources plus v0.1: /file/thumb thumbnails (Coil) and share-link creation via PUT /share copied to clipboard. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>pull/3587/head
parent
b2bd36d31c
commit
a1b5989eea
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<Uri>(Intent.EXTRA_STREAM)
|
||||||
|
)
|
||||||
|
Intent.ACTION_SEND_MULTIPLE ->
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
intent.getParcelableArrayListExtra<Uri>(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 <T : ViewModel> create(modelClass: Class<T>): 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 <access>`; 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<ApiResponse<LoginResponse>>
|
||||||
|
|
||||||
|
@POST("api/v4/session/token/refresh")
|
||||||
|
suspend fun refreshToken(@Body request: RefreshTokenRequest): Response<ApiResponse<RefreshTokenResponse>>
|
||||||
|
|
||||||
|
@DELETE("api/v4/session/token")
|
||||||
|
suspend fun signOut(@Body request: RefreshTokenRequest): Response<ApiResponse<Unit>>
|
||||||
|
|
||||||
|
@GET("api/v4/site/config/login")
|
||||||
|
suspend fun siteConfig(): Response<ApiResponse<SiteConfig>>
|
||||||
|
|
||||||
|
@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<ApiResponse<ListFileResponse>>
|
||||||
|
|
||||||
|
@POST("api/v4/file/url")
|
||||||
|
suspend fun fileUrls(@Body request: FileUrlRequest): Response<ApiResponse<FileUrlResponse>>
|
||||||
|
|
||||||
|
@POST("api/v4/file/create")
|
||||||
|
suspend fun createFile(@Body request: CreateFileRequest): Response<ApiResponse<Unit>>
|
||||||
|
|
||||||
|
@POST("api/v4/file/rename")
|
||||||
|
suspend fun renameFile(@Body request: RenameFileRequest): Response<ApiResponse<Unit>>
|
||||||
|
|
||||||
|
@HTTP(method = "DELETE", path = "api/v4/file", hasBody = true)
|
||||||
|
suspend fun deleteFiles(@Body request: DeleteFileRequest): Response<ApiResponse<Unit>>
|
||||||
|
|
||||||
|
@PUT("api/v4/file/upload")
|
||||||
|
suspend fun createUploadSession(@Body request: CreateUploadSessionRequest): Response<ApiResponse<UploadSessionResponse>>
|
||||||
|
|
||||||
|
// 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<ResponseBody>
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
suspend fun uploadChunkPut(@Url url: String, @Body body: RequestBody): Response<ResponseBody>
|
||||||
|
|
||||||
|
@POST
|
||||||
|
suspend fun completeUpload(@Url url: String): Response<ResponseBody>
|
||||||
|
|
||||||
|
@GET
|
||||||
|
suspend fun download(@Url url: String): Response<ResponseBody>
|
||||||
|
|
||||||
|
@GET("api/v4/file/thumb")
|
||||||
|
suspend fun thumb(@Query("uri") uri: String): Response<ApiResponse<ThumbResponse>>
|
||||||
|
|
||||||
|
@PUT("api/v4/share")
|
||||||
|
suspend fun createShare(@Body request: ShareCreateRequest): Response<ApiResponse<String>>
|
||||||
|
}
|
||||||
@ -0,0 +1,182 @@
|
|||||||
|
package org.cloudreve.android.api
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ApiResponse<T>(
|
||||||
|
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<String, String>? = 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<FileObject> = 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<String>,
|
||||||
|
val download: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class FileUrlResponse(
|
||||||
|
val urls: List<FileUrl> = 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<String>,
|
||||||
|
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<String>? = 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 = "",
|
||||||
|
)
|
||||||
@ -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 <T> Response<org.cloudreve.android.api.ApiResponse<T>>.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 <T> Response<org.cloudreve.android.api.ApiResponse<T>>.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<String>) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<String> = context.sessionStore.data.map { it[Keys.serverUrl] ?: "" }
|
||||||
|
val accessToken: Flow<String> = context.sessionStore.data.map { it[Keys.accessToken] ?: "" }
|
||||||
|
val userEmail: Flow<String> = context.sessionStore.data.map { it[Keys.userEmail] ?: "" }
|
||||||
|
val userNick: Flow<String> = context.sessionStore.data.map { it[Keys.userNick] ?: "" }
|
||||||
|
val isLoggedIn: Flow<Boolean> = 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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<FileObject?>(null) }
|
||||||
|
var deleteTarget by remember { mutableStateOf<FileObject?>(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<String?>(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]
|
||||||
|
}
|
||||||
@ -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<FileObject> = 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<FilesUiState> = _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<FileObject>): List<FileObject> =
|
||||||
|
files.sortedWith(compareByDescending<FileObject> { it.isFolder }.thenBy { it.name.lowercase() })
|
||||||
|
}
|
||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<LoginUiState> = _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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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<Uri>, parentUri: String) {
|
||||||
|
val request = OneTimeWorkRequestBuilder<UploadWorker>()
|
||||||
|
.setInputData(
|
||||||
|
Data.Builder()
|
||||||
|
.putStringArray(KEY_URIS, uris.map { it.toString() }.toTypedArray())
|
||||||
|
.putString(KEY_PARENT_URI, parentUri)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(context).enqueue(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in new issue