Merge pull request #213 from Dvorinka/feat/android-camera-upload
feat(android): camera auto-uploadpull/3589/head
commit
ce6b33267e
@ -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