CameraUploadWorker scans MediaStore for photos/videos newer than the last-sync watermark and streams them through the existing upload-session flow — 15-minute periodic work plus manual "sync now", unmetered-only when Wi-Fi is chosen, battery-not-low constrained. Synced and failed MediaStore IDs persist in DataStore so runs are incremental and bad files can't block the queue. Camera backup dialog in the files top bar: enable switch (requests READ_MEDIA_* on 33+ / READ_EXTERNAL_STORAGE below), remote folder, Wi-Fi only, include videos, last-backup timestamp. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>pull/3589/head
parent
7012862c27
commit
67c990eb4c
@ -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
|
||||
}
|
||||
}
|
||||
@ -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…
Reference in new issue