Merge pull request #213 from Dvorinka/feat/android-camera-upload

feat(android): camera auto-upload
pull/3589/head
Tomáš Dvořák 2 weeks ago committed by GitHub
commit ce6b33267e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -239,7 +239,7 @@ Goal: Windows + macOS + Linux from the `desktop/` tree in this repo.
Lives in `android/` in this repo. Kotlin + Jetpack Compose, Material 3.
- **API**: `api/v4` REST + OAuth token (entities exist: `oauthclient`, `oauthgrant`) — same surface the desktop `cloudreve-api` crate documents; port its models as the spec
- **Core features**: browse/download/upload files, share links, full-text search (done — query bar + offset pagination + parent-path/snippet rows), camera-upload (auto photo backup), offline-favorite files, local sync folder via SAF/WorkManager
- **Core features**: browse/download/upload files, share links, full-text search (done — query bar + offset pagination + parent-path/snippet rows), camera-upload (done — periodic WorkManager MediaStore sync, Wi-Fi-only constraint, ID dedup, settings dialog), offline-favorite files, local sync folder via SAF/WorkManager
- **System integration** (the "native, complete" ask): share-sheet target (upload to Cloudreve from any app), DocumentsProvider (Cloudreve in Files app), quick-share tile, notifications on share/task events
- **Auth**: webview OAuth flow → token; later passkey if backend exposes
- **WebDAV bridge**: `/dav` works as fallback file access until SDK matures

@ -30,8 +30,12 @@ CI runs `assembleDebug` on every PR.
- Full-text search (`GET /file/search`): query bar in the files screen,
offset pagination, result rows show parent path + content snippet;
folder hits navigate into place
- Camera auto-upload: periodic WorkManager sync of new MediaStore
photos/videos to a configurable remote folder, Wi-Fi-only constraint,
media-permission gated toggle, synced/failed ID dedup, manual
"sync now" — settings dialog in the files top bar
## Planned next
- Camera auto-upload, offline-favorite files, DocumentsProvider
- Offline-favorite files, DocumentsProvider
- No iOS. Ever.

@ -7,6 +7,11 @@
<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" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<application
android:name=".CloudreveApp"

@ -2,6 +2,7 @@ package org.cloudreve.android
import android.app.Application
import org.cloudreve.android.api.ApiClient
import org.cloudreve.android.data.CameraUploadSettings
import org.cloudreve.android.data.FileRepository
import org.cloudreve.android.data.SessionManager
@ -13,11 +14,14 @@ class CloudreveApp : Application() {
private set
lateinit var fileRepository: FileRepository
private set
lateinit var cameraUploadSettings: CameraUploadSettings
private set
override fun onCreate() {
super.onCreate()
sessionManager = SessionManager(this)
apiClient = ApiClient(sessionManager)
fileRepository = FileRepository(apiClient)
cameraUploadSettings = CameraUploadSettings(this)
}
}

@ -0,0 +1,108 @@
package org.cloudreve.android.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import org.cloudreve.android.util.CrUri
private val Context.cameraUploadStore by preferencesDataStore(name = "camera_upload")
/**
* Camera auto-upload preferences plus the incremental-sync bookkeeping
* (which MediaStore items were already uploaded or permanently failed).
* ID sets are bounded so storage stays small; evicted entries may rarely
* cause a duplicate upload of an old item.
*/
class CameraUploadSettings(private val context: Context) {
private object Keys {
val enabled = booleanPreferencesKey("enabled")
val remoteFolder = stringPreferencesKey("remote_folder")
val wifiOnly = booleanPreferencesKey("wifi_only")
val includeVideos = booleanPreferencesKey("include_videos")
val syncedIds = stringSetPreferencesKey("synced_ids")
val failedIds = stringSetPreferencesKey("failed_ids")
val lastSyncTs = longPreferencesKey("last_sync_ts")
val lastSyncAt = longPreferencesKey("last_sync_at")
}
data class Snapshot(
val enabled: Boolean = false,
val remoteFolder: String = DEFAULT_FOLDER,
val wifiOnly: Boolean = true,
val includeVideos: Boolean = true,
val lastSyncAt: Long = 0L,
)
val snapshot: Flow<Snapshot> = context.cameraUploadStore.data.map {
Snapshot(
enabled = it[Keys.enabled] ?: false,
remoteFolder = it[Keys.remoteFolder] ?: DEFAULT_FOLDER,
wifiOnly = it[Keys.wifiOnly] ?: true,
includeVideos = it[Keys.includeVideos] ?: true,
lastSyncAt = it[Keys.lastSyncAt] ?: 0L,
)
}
suspend fun snapshotNow(): Snapshot = snapshot.first()
suspend fun setEnabled(enabled: Boolean) {
context.cameraUploadStore.edit { it[Keys.enabled] = enabled }
}
suspend fun setRemoteFolder(folder: String) {
context.cameraUploadStore.edit { it[Keys.remoteFolder] = folder.trim() }
}
suspend fun setWifiOnly(wifiOnly: Boolean) {
context.cameraUploadStore.edit { it[Keys.wifiOnly] = wifiOnly }
}
suspend fun setIncludeVideos(include: Boolean) {
context.cameraUploadStore.edit { it[Keys.includeVideos] = include }
}
suspend fun syncedIdsNow(): Set<String> =
context.cameraUploadStore.data.first()[Keys.syncedIds] ?: emptySet()
suspend fun failedIdsNow(): Set<String> =
context.cameraUploadStore.data.first()[Keys.failedIds] ?: emptySet()
suspend fun lastSyncTsNow(): Long =
context.cameraUploadStore.data.first()[Keys.lastSyncTs] ?: 0L
suspend fun markSynced(id: String, dateAddedSec: Long) {
context.cameraUploadStore.edit { prefs ->
val synced = (prefs[Keys.syncedIds] ?: emptySet()).toMutableSet()
synced.add(id)
prefs[Keys.syncedIds] = synced.bounded()
if (dateAddedSec > (prefs[Keys.lastSyncTs] ?: 0L)) {
prefs[Keys.lastSyncTs] = dateAddedSec
}
prefs[Keys.lastSyncAt] = System.currentTimeMillis()
}
}
suspend fun markFailed(id: String) {
context.cameraUploadStore.edit { prefs ->
val failed = (prefs[Keys.failedIds] ?: emptySet()).toMutableSet()
failed.add(id)
prefs[Keys.failedIds] = failed.bounded()
}
}
private fun Set<String>.bounded(): Set<String> =
if (size <= MAX_TRACKED_IDS) this else this.drop(size - MAX_TRACKED_IDS).toSet()
companion object {
const val DEFAULT_FOLDER = "${CrUri.MY_PREFIX}/Photos"
private const val MAX_TRACKED_IDS = 2000
}
}

@ -32,6 +32,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material3.AlertDialog
@ -49,6 +50,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@ -73,8 +75,11 @@ 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.CloudreveApp
import org.cloudreve.android.api.FileObject
import org.cloudreve.android.data.CameraUploadSettings
import org.cloudreve.android.util.CrUri
import org.cloudreve.android.work.CameraUploadWorker
import org.cloudreve.android.work.UploadWorker
import java.text.DecimalFormat
@ -95,6 +100,7 @@ fun FilesScreen(
var deleteTarget by remember { mutableStateOf<FileObject?>(null) }
var searchOpen by remember { mutableStateOf(false) }
var searchText by remember { mutableStateOf("") }
var cameraSettingsOpen by remember { mutableStateOf(false) }
val searchFocus = remember { FocusRequester() }
val inSearch = state.searchQuery != null
@ -191,6 +197,9 @@ fun FilesScreen(
IconButton(onClick = { searchOpen = true }) {
Icon(Icons.Default.Search, contentDescription = "Search")
}
IconButton(onClick = { cameraSettingsOpen = true }) {
Icon(Icons.Default.Settings, contentDescription = "Camera backup")
}
IconButton(onClick = { mkdirOpen = true }) {
Icon(Icons.Default.CreateNewFolder, contentDescription = "New folder")
}
@ -359,6 +368,10 @@ fun FilesScreen(
},
)
}
if (cameraSettingsOpen) {
CameraUploadDialog(onDismiss = { cameraSettingsOpen = false })
}
}
private fun pathLabel(uri: String): String {
@ -453,6 +466,125 @@ private fun SearchResults(
}
}
@Composable
private fun CameraUploadDialog(onDismiss: () -> Unit) {
val context = LocalContext.current
val app = context.applicationContext as CloudreveApp
val settings = app.cameraUploadSettings
val scope = rememberCoroutineScope()
val snap by settings.snapshot.collectAsState(initial = null)
var folderText by remember(snap?.remoteFolder) {
mutableStateOf(snap?.remoteFolder ?: CameraUploadSettings.DEFAULT_FOLDER)
}
val permLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { grants ->
if (grants.values.all { it }) {
scope.launch {
settings.setRemoteFolder(folderText)
settings.setEnabled(true)
CameraUploadWorker.apply(context, snap?.wifiOnly ?: true)
}
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Camera backup") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Back up new photos & videos", Modifier.weight(1f))
Switch(
checked = snap?.enabled == true,
onCheckedChange = { want ->
if (want) {
permLauncher.launch(mediaPermissions())
} else {
scope.launch {
settings.setEnabled(false)
CameraUploadWorker.cancel(context)
}
}
},
)
}
OutlinedTextField(
value = folderText,
onValueChange = { folderText = it },
label = { Text("Remote folder") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Wi-Fi only", Modifier.weight(1f))
Switch(
checked = snap?.wifiOnly ?: true,
onCheckedChange = { wifiOnly ->
scope.launch {
settings.setWifiOnly(wifiOnly)
if (snap?.enabled == true) {
CameraUploadWorker.apply(context, wifiOnly)
}
}
},
)
}
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Include videos", Modifier.weight(1f))
Switch(
checked = snap?.includeVideos ?: true,
onCheckedChange = { scope.launch { settings.setIncludeVideos(it) } },
)
}
snap?.lastSyncAt?.takeIf { it > 0 }?.let {
Text(
"Last backup: " + java.text.DateFormat.getDateTimeInstance(
java.text.DateFormat.SHORT, java.text.DateFormat.SHORT,
).format(java.util.Date(it)),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
confirmButton = {
TextButton(onClick = {
scope.launch {
settings.setRemoteFolder(folderText)
CameraUploadWorker.syncNow(context, snap?.wifiOnly ?: true)
}
}) { Text("Sync now") }
},
dismissButton = {
TextButton(onClick = {
scope.launch { settings.setRemoteFolder(folderText) }
onDismiss()
}) { Text("Done") }
},
)
}
private fun mediaPermissions(): Array<String> =
if (android.os.Build.VERSION.SDK_INT >= 33) {
arrayOf(
android.Manifest.permission.READ_MEDIA_IMAGES,
android.Manifest.permission.READ_MEDIA_VIDEO,
)
} else {
arrayOf(android.Manifest.permission.READ_EXTERNAL_STORAGE)
}
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 {

@ -0,0 +1,195 @@
package org.cloudreve.android.work
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.ContentUris
import android.content.Context
import android.content.pm.ServiceInfo
import android.os.Build
import android.provider.MediaStore
import androidx.core.app.NotificationCompat
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import org.cloudreve.android.CloudreveApp
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.TimeUnit
/**
* Periodically scans MediaStore for photos/videos captured since the last
* sync and uploads them to the configured Cloudreve folder. A bounded set of
* synced/failed MediaStore IDs prevents re-uploads; the date-added watermark
* bounds each scan. Runs unmetered-only when the user chose Wi-Fi only.
*/
class CameraUploadWorker(context: Context, params: WorkerParameters) :
CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val app = applicationContext as CloudreveApp
val settings = app.cameraUploadSettings
val cfg = settings.snapshotNow()
if (!cfg.enabled) return Result.success()
createChannel()
val synced = settings.syncedIdsNow().toMutableSet()
val failed = settings.failedIdsNow()
val lastTs = settings.lastSyncTsNow()
val items = queryNewMedia(lastTs, cfg.includeVideos)
.filter { it.id !in synced && it.id !in failed }
.take(MAX_PER_RUN)
items.forEachIndexed { i, item ->
setForeground(progressInfo("Backing up ${item.name}", i, items.size))
try {
val tmp = stageToCache(item)
try {
app.fileRepository.uploadFile(tmp, cfg.remoteFolder) { _, _ -> }
settings.markSynced(item.id, item.dateAddedSec)
} finally {
tmp.delete()
}
} catch (_: Exception) {
// Permanently skipped: retried MediaStore rows that keep
// failing would otherwise block every later photo.
settings.markFailed(item.id)
}
}
return Result.success()
}
private data class MediaItem(
val id: String,
val contentUri: android.net.Uri,
val name: String,
val dateAddedSec: Long,
)
private fun queryNewMedia(sinceSec: Long, includeVideos: Boolean): List<MediaItem> {
val out = mutableListOf<MediaItem>()
val collection = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
val projection = arrayOf(
MediaStore.Files.FileColumns._ID,
MediaStore.Files.FileColumns.DISPLAY_NAME,
MediaStore.Files.FileColumns.DATE_ADDED,
MediaStore.Files.FileColumns.MEDIA_TYPE,
)
val mediaTypes = buildList {
add(MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE)
if (includeVideos) add(MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO)
}
val selection = "${MediaStore.Files.FileColumns.DATE_ADDED} > ? AND " +
"${MediaStore.Files.FileColumns.MEDIA_TYPE} IN (${mediaTypes.joinToString(",") { "?" }})"
val args = (listOf(sinceSec) + mediaTypes).map { it.toString() }.toTypedArray()
val order = "${MediaStore.Files.FileColumns.DATE_ADDED} ASC"
applicationContext.contentResolver.query(
collection, projection, selection, args, order,
)?.use { cursor ->
val idIdx = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
val nameIdx = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DISPLAY_NAME)
val dateIdx = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_ADDED)
while (cursor.moveToNext()) {
val id = cursor.getLong(idIdx)
out.add(
MediaItem(
id = id.toString(),
contentUri = ContentUris.withAppendedId(collection, id),
name = cursor.getString(nameIdx) ?: "media_$id",
dateAddedSec = cursor.getLong(dateIdx),
)
)
}
}
return out
}
private fun stageToCache(item: MediaItem): File {
val dir = File(applicationContext.cacheDir, "camera_staging").apply { mkdirs() }
val tmp = File(dir, item.name)
applicationContext.contentResolver.openInputStream(item.contentUri)!!.use { input ->
FileOutputStream(tmp).use { input.copyTo(it) }
}
return tmp
}
private fun progressInfo(text: String, index: Int, total: Int): ForegroundInfo {
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_upload)
.setContentTitle("Cloudreve camera backup")
.setContentText("$text (${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, "Camera backup", NotificationManager.IMPORTANCE_LOW)
)
}
companion object {
private const val WORK_NAME = "camera_upload_periodic"
private const val WORK_ONCE = "camera_upload_once"
private const val CHANNEL_ID = "camera_upload"
private const val NOTIF_ID = 42
private const val MAX_PER_RUN = 100
private fun constraints(wifiOnly: Boolean): Constraints =
Constraints.Builder()
.setRequiredNetworkType(
if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED
)
.setRequiresBatteryNotLow(true)
.build()
/** Schedules (or removes) the periodic sync and kicks an immediate run. */
fun apply(context: Context, wifiOnly: Boolean) {
val wm = WorkManager.getInstance(context)
val periodic = PeriodicWorkRequestBuilder<CameraUploadWorker>(15, TimeUnit.MINUTES)
.setConstraints(constraints(wifiOnly))
.build()
wm.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, periodic)
wm.enqueueUniqueWork(
WORK_ONCE,
ExistingWorkPolicy.REPLACE,
OneTimeWorkRequestBuilder<CameraUploadWorker>()
.setConstraints(constraints(wifiOnly))
.build(),
)
}
fun cancel(context: Context) {
val wm = WorkManager.getInstance(context)
wm.cancelUniqueWork(WORK_NAME)
wm.cancelUniqueWork(WORK_ONCE)
}
/** Manual "sync now" — the periodic schedule is untouched. */
fun syncNow(context: Context, wifiOnly: Boolean) {
WorkManager.getInstance(context).enqueueUniqueWork(
WORK_ONCE,
ExistingWorkPolicy.REPLACE,
OneTimeWorkRequestBuilder<CameraUploadWorker>()
.setConstraints(constraints(wifiOnly))
.build(),
)
}
}
}
Loading…
Cancel
Save