commit
4e27cb7dae
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Android Motion Photo (a.k.a. MicroVideo) support.
|
||||
*
|
||||
* Google/Samsung motion photos are a single JPEG with an MP4 appended at the
|
||||
* end. XMP metadata in the JPEG header either carries `GCamera:MicroVideoOffset`
|
||||
* (bytes from EOF to the start of the MP4) or a `Container:Directory` whose
|
||||
* `video/mp4` item declares `Item:Length` (video is the last item).
|
||||
*/
|
||||
|
||||
// XMP packets live in the JPEG APP1 segment near the start of the file;
|
||||
// 128 KiB of head data covers every layout seen in the wild.
|
||||
const HEAD_SCAN_BYTES = 128 * 1024;
|
||||
|
||||
// Motion photos carry a multi-MB video tail; smaller JPEGs cannot be one.
|
||||
const MIN_FILE_SIZE = 512 * 1024;
|
||||
|
||||
const MOTION_PHOTO_EXTENSIONS = new Set(["jpg", "jpeg"]);
|
||||
|
||||
export const isMotionPhotoCandidate = (name: string, size: number): boolean =>
|
||||
MOTION_PHOTO_EXTENSIONS.has(name.toLowerCase().split(".").pop() ?? "") &&
|
||||
size >= MIN_FILE_SIZE;
|
||||
|
||||
const bytesToLatin1 = (buf: ArrayBuffer): string => {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let out = "";
|
||||
for (let i = 0; i < bytes.length; i += 8192) {
|
||||
out += String.fromCharCode.apply(
|
||||
null,
|
||||
bytes.subarray(i, i + 8192) as unknown as number[],
|
||||
);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// Extracts the embedded video's byte length (counted from EOF) from XMP text.
|
||||
const extractVideoLength = (text: string): number | null => {
|
||||
const offset = text.match(/MicroVideoOffset="(\d+)"/);
|
||||
if (offset) {
|
||||
return parseInt(offset[1], 10);
|
||||
}
|
||||
|
||||
if (!/MicroVideo|MotionPhoto/.test(text)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mimeIdx = text.indexOf("video/mp4");
|
||||
if (mimeIdx < 0) {
|
||||
return null;
|
||||
}
|
||||
// The item element serializes Mime before Length; search forward only so a
|
||||
// preceding image item's Length is never picked up.
|
||||
const window = text.slice(mimeIdx, mimeIdx + 512);
|
||||
const length = window.match(/Item:Length="(\d+)"/);
|
||||
return length ? parseInt(length[1], 10) : null;
|
||||
};
|
||||
|
||||
// MP4 files start with an `ftyp` box; guards against bogus XMP offsets.
|
||||
const looksLikeMp4 = (buf: ArrayBuffer): boolean => {
|
||||
if (buf.byteLength < 12) {
|
||||
return false;
|
||||
}
|
||||
const view = new DataView(buf, 4);
|
||||
return view.getUint32(0) === 0x66747970; // "ftyp"
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a blob URL for the embedded MP4 of a motion photo, or null when the
|
||||
* image has no embedded video or the bytes cannot be fetched (CORS, network).
|
||||
* Uses HTTP Range requests so at most the head + video tail are transferred.
|
||||
*/
|
||||
export const fetchMotionPhotoVideoUrl = async (
|
||||
imageUrl: string,
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
const head = await fetch(imageUrl, {
|
||||
headers: { Range: `bytes=0-${HEAD_SCAN_BYTES - 1}` },
|
||||
});
|
||||
if (!head.ok) {
|
||||
return null;
|
||||
}
|
||||
const headBuf = await head.arrayBuffer();
|
||||
|
||||
const videoLength = extractVideoLength(bytesToLatin1(headBuf));
|
||||
if (!videoLength || videoLength <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let videoBuf: ArrayBuffer;
|
||||
if (head.status === 206 && headBuf.byteLength <= HEAD_SCAN_BYTES) {
|
||||
const tail = await fetch(imageUrl, {
|
||||
headers: { Range: `bytes=-${videoLength}` },
|
||||
});
|
||||
if (!tail.ok) {
|
||||
return null;
|
||||
}
|
||||
videoBuf = await tail.arrayBuffer();
|
||||
} else {
|
||||
// Range ignored: the head fetch already holds the whole file.
|
||||
if (headBuf.byteLength <= videoLength) {
|
||||
return null;
|
||||
}
|
||||
videoBuf = headBuf.slice(headBuf.byteLength - videoLength);
|
||||
}
|
||||
|
||||
if (!looksLikeMp4(videoBuf)) {
|
||||
return null;
|
||||
}
|
||||
return URL.createObjectURL(new Blob([videoBuf], { type: "video/mp4" }));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
Loading…
Reference in new issue