Merge pull request #178 from Dvorinka/feat/android-v0

feat(android): native client v0
pull/3587/head
Tomáš Dvořák 2 weeks ago committed by GitHub
commit a1b8b61773
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -57,3 +57,17 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev libgtk-3-dev librsvg2-dev sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev libgtk-3-dev librsvg2-dev
- run: cargo check --workspace - run: cargo check --workspace
- run: cargo test --workspace - run: cargo test --workspace
android:
runs-on: ubuntu-latest
defaults:
run:
working-directory: android
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- uses: gradle/actions/setup-gradle@v4
- run: ./gradlew :app:assembleDebug --console=plain

4
.gitignore vendored

@ -33,10 +33,10 @@ conf/conf.ini
.vscode/ .vscode/
dist/ dist/
data/ /data/
tmp/ tmp/
.devcontainer/ .devcontainer/
cloudreve /cloudreve
.DS_Store .DS_Store
# Playwright MCP snapshots # Playwright MCP snapshots
.playwright-mcp/ .playwright-mcp/

@ -0,0 +1,6 @@
.gradle/
build/
*/build/
local.properties
*.iml
.cxx/

@ -2,12 +2,34 @@
Native Android client for Cloudreve. Kotlin + Jetpack Compose, Material 3. Native Android client for Cloudreve. Kotlin + Jetpack Compose, Material 3.
Status: **Phase E — scaffolded, not yet implemented.** See `../ROADMAP.md`. Status: **Phase E — v0.1 implemented.** Sign in, browse, download, upload,
rename, mkdir, delete, thumbnails, share-link creation. See `../ROADMAP.md`
for the remaining scope.
## Planned ## Build
- `api/v4` REST + OAuth token auth (same surface as `desktop/crates/cloudreve-api`) ```bash
- Browse / upload / download / share links cd android
- Camera auto-upload, offline-favorite files, local sync via WorkManager + SAF ANDROID_HOME=$HOME/Android/Sdk ./gradlew :app:assembleDebug
- Share-sheet target, DocumentsProvider, quick-share tile, task notifications # APK: app/build/outputs/apk/debug/app-debug.apk
```
CI runs `assembleDebug` on every PR.
## What's implemented
- `api/v4` auth: password login → access/refresh tokens, single-flight
refresh on 401, server-side revocation on sign-out
- File browser: folder navigation, cursor pagination, pull-refresh,
mkdir / rename / delete / download (cache + FileProvider open)
- Uploads: SAF document picker or share-sheet target, WorkManager
background worker with progress notification, chunked through the
upload-session flow (local + presigned remote policies)
- Thumbnails via `/file/thumb` (Coil, lazily resolved per row)
- Share-link creation (`PUT /share`), link copied to clipboard
## Planned next
- Camera auto-upload, offline-favorite files, DocumentsProvider
- Search
- No iOS. Ever. - No iOS. Ever.

@ -0,0 +1,77 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
namespace = "org.cloudreve.android"
compileSdk = 35
defaultConfig {
applicationId = "org.cloudreve.android"
minSdk = 28
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.navigation:navigation-compose:2.8.5")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("androidx.work:work-runtime-ktx:2.10.0")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("io.coil-kt:coil-compose:2.7.0")
}

@ -0,0 +1,11 @@
# Keep kotlinx.serialization generated serializers
-keepclassmembers class org.cloudreve.android.api.** {
*** Companion;
}
-keepclasseswithmembers class org.cloudreve.android.api.** {
kotlinx.serialization.KSerializer serializer*(...);
}
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.**
-dontwarn okhttp3.**
-dontwarn okio.**

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application
android:name=".CloudreveApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Cloudreve"
tools:targetApi="35">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|uiMode">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

@ -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)
}
}
}

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- stylized cloud -->
<path
android:fillColor="#FFFFFF"
android:pathData="M79,62c-1.1,0 -2.2,0.1 -3.3,0.3C72,52 62.4,44 51,44c-10,0 -18.5,6.4 -21.7,15.4C22.9,60.6 19,64.7 19,69.7 19,76.6 25.2,82 32.5,82L79,82c6.6,0 12,-4.9 12,-11S85.6,62 79,62z" />
</vector>

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#4A6FA5</color>
</resources>

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Cloudreve</string>
</resources>

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Cloudreve" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="downloads" path="downloads/" />
<files-path name="files" path="." />
</paths>

@ -0,0 +1,6 @@
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
}

@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official

Binary file not shown.

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

252
android/gradlew vendored

@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "cloudreve-android"
include(":app")
Loading…
Cancel
Save