diff --git a/app/src/main/java/gallery/memories/MainActivity.kt b/app/src/main/java/gallery/memories/MainActivity.kt index efb7faaa..73d2910b 100644 --- a/app/src/main/java/gallery/memories/MainActivity.kt +++ b/app/src/main/java/gallery/memories/MainActivity.kt @@ -12,6 +12,7 @@ import android.view.WindowInsetsController import android.webkit.* import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Lifecycle import androidx.media3.common.MediaItem import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.Util @@ -35,6 +36,8 @@ import gallery.memories.databinding.ActivityMainBinding private var mediaItemIndex = 0 private var playbackPosition = 0L + private var mNeedRefresh = false + private val memoriesRegex = Regex("/apps/memories/.*$") override fun onCreate(savedInstanceState: Bundle?) { @@ -72,6 +75,9 @@ import gallery.memories.databinding.ActivityMainBinding if (playerUri != null && (Util.SDK_INT <= 23 || player == null)) { initializePlayer(playerUri!!, playerUid!!) } + if (mNeedRefresh) { + refreshTimeline(true) + } } public override fun onPause() { @@ -181,15 +187,28 @@ import gallery.memories.databinding.ActivityMainBinding } fun ensureStoragePermissions() { - val requestPermissionLauncher = - registerForActivityResult( - ActivityResultContracts.RequestPermission() - ) { isGranted: Boolean -> - if (isGranted && !hasMediaPermission()) { - nativex.query.syncFullDb() - } - setHasMediaPermission(isGranted) + val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission()) { isGranted: Boolean -> + if (isGranted) { + val needFullSync = !hasMediaPermission() + + // Run DB operations in separate thread + Thread { + // Full sync if this is the first time permission was granted + if (needFullSync) { + nativex.query.syncFullDb() + } + + // Run delta sync and register hooks + nativex.query.initialize() + }.start() } + + // Persist that we have it now + setHasMediaPermission(isGranted) + } + + // Request media read permission requestPermissionLauncher.launch(android.Manifest.permission.READ_EXTERNAL_STORAGE) } @@ -301,4 +320,19 @@ import gallery.memories.databinding.ActivityMainBinding .putBoolean(getString(R.string.preferences_has_media_permission), v) .apply() } + + fun refreshTimeline(force: Boolean = false) { + runOnUiThread { + // Check webview is loaded + if (binding?.webview?.url == null) return@runOnUiThread + + // Schedule for resume if not active + if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) || force) { + mNeedRefresh = false + binding.webview.evaluateJavascript("window._nc_event_bus?.emit('files:file:created')", null) + } else { + mNeedRefresh = true + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/gallery/memories/NativeX.kt b/app/src/main/java/gallery/memories/NativeX.kt index 8c6f4a57..184262c8 100644 --- a/app/src/main/java/gallery/memories/NativeX.kt +++ b/app/src/main/java/gallery/memories/NativeX.kt @@ -42,11 +42,6 @@ import java.net.URLDecoder init { dlService = DownloadService(mCtx) - - // Synchronize the database if possible - if (mCtx.hasMediaPermission()) { - query.syncDeltaDb() - } } companion object { @@ -55,6 +50,7 @@ import java.net.URLDecoder fun destroy() { dlService = null + query.destroy() } fun handleRequest(request: WebResourceRequest): WebResourceResponse { diff --git a/app/src/main/java/gallery/memories/service/TimelineQuery.kt b/app/src/main/java/gallery/memories/service/TimelineQuery.kt index 78651897..3f3be1e2 100644 --- a/app/src/main/java/gallery/memories/service/TimelineQuery.kt +++ b/app/src/main/java/gallery/memories/service/TimelineQuery.kt @@ -2,9 +2,11 @@ package gallery.memories.service import android.annotation.SuppressLint import android.app.Activity +import android.database.ContentObserver import android.database.sqlite.SQLiteDatabase import android.icu.text.SimpleDateFormat import android.icu.util.TimeZone +import android.net.Uri import android.os.Build import android.provider.MediaStore import android.text.TextUtils @@ -13,9 +15,10 @@ import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.IntentSenderRequest import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity import androidx.collection.ArraySet import androidx.exifinterface.media.ExifInterface +import androidx.media3.common.util.UnstableApi +import gallery.memories.MainActivity import gallery.memories.R import gallery.memories.mapper.Fields import gallery.memories.mapper.SystemImage @@ -26,7 +29,7 @@ import java.io.IOException import java.time.Instant import java.util.concurrent.CountDownLatch -class TimelineQuery(private val mCtx: AppCompatActivity) { +@UnstableApi class TimelineQuery(private val mCtx: MainActivity) { private val mDb: SQLiteDatabase = DbService(mCtx).writableDatabase private val TAG = "TimelineQuery" @@ -38,6 +41,11 @@ class TimelineQuery(private val mCtx: AppCompatActivity) { // Caches var mEnabledBuckets: Set? = null + // Observers + var imageObserver: ContentObserver? = null + var videoObserver: ContentObserver? = null + var refreshPending: Boolean = false + init { // Register intent launcher for callback deleteIntentLauncher = mCtx.registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result: ActivityResult? -> @@ -47,6 +55,57 @@ class TimelineQuery(private val mCtx: AppCompatActivity) { } } + fun initialize() { + if (syncDeltaDb() > 0) { + mCtx.refreshTimeline() + } + registerHooks() + } + + fun destroy() { + if (imageObserver != null) { + mCtx.contentResolver.unregisterContentObserver(imageObserver!!) + } + if (videoObserver != null) { + mCtx.contentResolver.unregisterContentObserver(videoObserver!!) + } + } + + fun registerHooks() { + imageObserver = registerContentObserver(SystemImage.IMAGE_URI) + videoObserver = registerContentObserver(SystemImage.VIDEO_URI) + } + + private fun registerContentObserver(uri: Uri): ContentObserver { + val observer = @UnstableApi object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) { + super.onChange(selfChange) + + // Debounce refreshes + synchronized(this@TimelineQuery) { + if (refreshPending) return + refreshPending = true + } + + // Refresh after 750ms + Thread { + Thread.sleep(750) + synchronized(this@TimelineQuery) { + refreshPending = false + } + + // Check if anything to update + if (syncDeltaDb() == 0 || mCtx.isDestroyed || mCtx.isFinishing) return@Thread + + mCtx.refreshTimeline() + }.start() + } + } + + mCtx.contentResolver.registerContentObserver(uri, true, observer) + return observer + } + @Throws(JSONException::class) fun getByDayId(dayId: Long): JSONArray { // Filter for enabled buckets @@ -230,7 +289,7 @@ class TimelineQuery(private val mCtx: AppCompatActivity) { } } - private fun syncDb(startTime: Long) { + private fun syncDb(startTime: Long): Int { // Date modified is in seconds, not millis val syncTime = Instant.now().toEpochMilli() / 1000; @@ -254,13 +313,16 @@ class TimelineQuery(private val mCtx: AppCompatActivity) { mCtx.getSharedPreferences(mCtx.getString(R.string.preferences_key), 0).edit() .putLong(mCtx.getString(R.string.preferences_last_sync_time), syncTime) .apply() + + // Number of updated files + return files.size } - fun syncDeltaDb() { + fun syncDeltaDb(): Int { // Get last sync time val syncTime = mCtx.getSharedPreferences(mCtx.getString(R.string.preferences_key), 0) .getLong(mCtx.getString(R.string.preferences_last_sync_time), 0L) - syncDb(syncTime) + return syncDb(syncTime) } fun syncFullDb() {