diff --git a/android/app/src/debug/res/values-ko/strings.xml b/android/app/src/debug/res/values-ko/strings.xml index 4fff58c6e..07e7e9a8f 100644 --- a/android/app/src/debug/res/values-ko/strings.xml +++ b/android/app/src/debug/res/values-ko/strings.xml @@ -1,4 +1,4 @@  - 아베스 [Debug] + 아베스 [Debug] \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3cca27c39..0546ae2a0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -16,6 +16,7 @@ https://developer.android.com/preview/privacy/storage#media-files-raw-paths --> + + ImageByteStreamHandler(this, args) } + StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) } + // channel for service management + backgroundChannel = MethodChannel(messenger, BACKGROUND_CHANNEL).apply { + setMethodCallHandler(context) + } + + HandlerThread("Analysis service handler", Process.THREAD_PRIORITY_BACKGROUND).apply { + start() + serviceLooper = looper + serviceHandler = ServiceHandler(looper) + } + } + + override fun onDestroy() { + Log.i(LOG_TAG, "Destroy analysis service") + } + + override fun onBind(intent: Intent) = analysisServiceBinder + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + val channel = NotificationChannelCompat.Builder(CHANNEL_ANALYSIS, NotificationManagerCompat.IMPORTANCE_LOW) + .setName(getText(R.string.analysis_channel_name)) + .setShowBadge(false) + .build() + NotificationManagerCompat.from(this).createNotificationChannel(channel) + startForeground(NOTIFICATION_ID, buildNotification()) + + val msgData = Bundle() + intent.extras?.let { + msgData.putAll(it) + } + serviceHandler?.obtainMessage()?.let { msg -> + msg.arg1 = startId + msg.data = msgData + serviceHandler?.sendMessage(msg) + } + + return START_NOT_STICKY + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "initialized" -> { + Log.d(LOG_TAG, "background channel is ready") + result.success(null) + } + "updateNotification" -> { + val title = call.argument("title") + val message = call.argument("message") + val notification = buildNotification(title, message) + NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, notification) + result.success(null) + } + "refreshApp" -> { + analysisServiceBinder.refreshApp() + result.success(null) + } + "stop" -> { + detachAndStop() + result.success(null) + } + else -> result.notImplemented() + } + } + + private fun detachAndStop() { + analysisServiceBinder.detach() + stopSelf() + } + + private fun buildNotification(title: String? = null, message: String? = null): Notification { + val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + val stopServiceIntent = Intent(this, AnalysisService::class.java).let { + it.putExtra(KEY_COMMAND, COMMAND_STOP) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + PendingIntent.getForegroundService(this, STOP_SERVICE_REQUEST, it, pendingIntentFlags) + } else { + PendingIntent.getService(this, STOP_SERVICE_REQUEST, it, pendingIntentFlags) + } + } + val openAppIntent = Intent(this, MainActivity::class.java).let { + PendingIntent.getActivity(this, OPEN_FROM_ANALYSIS_SERVICE, it, pendingIntentFlags) + } + val stopAction = NotificationCompat.Action.Builder( + R.drawable.ic_outline_stop_24, + getString(R.string.analysis_notification_action_stop), + stopServiceIntent + ).build() + return NotificationCompat.Builder(this, CHANNEL_ANALYSIS) + .setContentTitle(title ?: getText(R.string.analysis_notification_default_title)) + .setContentText(message) + .setBadgeIconType(NotificationCompat.BADGE_ICON_NONE) + .setSmallIcon(R.drawable.ic_notification) + .setContentIntent(openAppIntent) + .setPriority(NotificationCompat.PRIORITY_LOW) + .addAction(stopAction) + .build() + } + + private inner class ServiceHandler(looper: Looper) : Handler(looper) { + override fun handleMessage(msg: Message) { + val context = this@AnalysisService + val data = msg.data + Log.d(LOG_TAG, "handleMessage data=$data") + when (data.getString(KEY_COMMAND)) { + COMMAND_START -> { + runBlocking { + context.runOnUiThread { + backgroundChannel?.invokeMethod("start", null) + } + } + } + COMMAND_STOP -> { + // unconditionally stop the service + runBlocking { + context.runOnUiThread { + backgroundChannel?.invokeMethod("stop", null) + } + } + detachAndStop() + } + else -> { + } + } + } + } + + companion object { + private val LOG_TAG = LogUtils.createTag() + private const val BACKGROUND_CHANNEL = "deckers.thibault/aves/analysis_service_background" + const val SHARED_PREFERENCES_KEY = "analysis_service" + const val CALLBACK_HANDLE_KEY = "callback_handle" + + const val NOTIFICATION_ID = 1 + const val STOP_SERVICE_REQUEST = 1 + const val CHANNEL_ANALYSIS = "analysis" + + const val KEY_COMMAND = "command" + const val COMMAND_START = "start" + const val COMMAND_STOP = "stop" + } +} + +class AnalysisServiceBinder : Binder() { + private val listeners = hashSetOf() + + fun startListening(listener: AnalysisServiceListener) = listeners.add(listener) + + fun stopListening(listener: AnalysisServiceListener) = listeners.remove(listener) + + fun refreshApp() { + val localListeners = listeners.toSet() + for (listener in localListeners) { + try { + listener.refreshApp() + } catch (e: Exception) { + Log.e(LOG_TAG, "failed to notify listener=$listener", e) + } + } + } + + fun detach() { + val localListeners = listeners.toSet() + for (listener in localListeners) { + try { + listener.detachFromActivity() + } catch (e: Exception) { + Log.e(LOG_TAG, "failed to detach listener=$listener", e) + } + } + } + + companion object { + private val LOG_TAG = LogUtils.createTag() + } +} + +interface AnalysisServiceListener { + fun refreshApp() + fun detachFromActivity() +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index 35c72a618..7e4b6003b 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -27,7 +27,9 @@ class MainActivity : FlutterActivity() { private lateinit var mediaStoreChangeStreamHandler: MediaStoreChangeStreamHandler private lateinit var settingsChangeStreamHandler: SettingsChangeStreamHandler private lateinit var intentStreamHandler: IntentStreamHandler + private lateinit var analysisStreamHandler: AnalysisStreamHandler private lateinit var intentDataMap: MutableMap + private lateinit var analysisHandler: AnalysisHandler override fun onCreate(savedInstanceState: Bundle?) { Log.i(LOG_TAG, "onCreate intent=$intent") @@ -52,24 +54,30 @@ class MainActivity : FlutterActivity() { val messenger = flutterEngine!!.dartExecutor.binaryMessenger // dart -> platform -> dart - MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this)) + // - need Context + analysisHandler = AnalysisHandler(this, ::onAnalysisCompleted) + MethodChannel(messenger, AnalysisHandler.CHANNEL).setMethodCallHandler(analysisHandler) MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this)) MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this)) MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler()) MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this)) MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this)) MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this)) - MethodChannel(messenger, MediaFileHandler.CHANNEL).setMethodCallHandler(MediaFileHandler(this)) MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this)) - MethodChannel(messenger, MetadataEditHandler.CHANNEL).setMethodCallHandler(MetadataEditHandler(this)) MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this)) MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this)) + // - need Activity + MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this)) + MethodChannel(messenger, MediaFileHandler.CHANNEL).setMethodCallHandler(MediaFileHandler(this)) + MethodChannel(messenger, MetadataEditHandler.CHANNEL).setMethodCallHandler(MetadataEditHandler(this)) MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(WindowHandler(this)) // result streaming: dart -> platform ->->-> dart + // - need Context StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) } - StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageOpStreamHandler(this, args) } StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) } + // - need Activity + StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageOpStreamHandler(this, args) } StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL).setStreamHandlerFactory { args -> StorageAccessStreamHandler(this, args) } // change monitoring: platform -> dart @@ -97,6 +105,11 @@ class MainActivity : FlutterActivity() { } } + // notification: platform -> dart + analysisStreamHandler = AnalysisStreamHandler().apply { + EventChannel(messenger, AnalysisStreamHandler.CHANNEL).setStreamHandler(this) + } + // notification: platform -> dart errorStreamHandler = ErrorStreamHandler().apply { EventChannel(messenger, ErrorStreamHandler.CHANNEL).setStreamHandler(this) @@ -107,7 +120,20 @@ class MainActivity : FlutterActivity() { } } + override fun onStart() { + Log.i(LOG_TAG, "onStart") + super.onStart() + analysisHandler.attachToActivity() + } + + override fun onStop() { + Log.i(LOG_TAG, "onStop") + analysisHandler.detachFromActivity() + super.onStop() + } + override fun onDestroy() { + Log.i(LOG_TAG, "onDestroy") mediaStoreChangeStreamHandler.dispose() settingsChangeStreamHandler.dispose() super.onDestroy() @@ -252,6 +278,10 @@ class MainActivity : FlutterActivity() { ShortcutManagerCompat.setDynamicShortcuts(this, listOf(videos, search)) } + private fun onAnalysisCompleted() { + analysisStreamHandler.notifyCompletion() + } + companion object { private val LOG_TAG = LogUtils.createTag() const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer" @@ -261,6 +291,7 @@ class MainActivity : FlutterActivity() { const val CREATE_FILE_REQUEST = 3 const val OPEN_FILE_REQUEST = 4 const val SELECT_DIRECTORY_REQUEST = 5 + const val OPEN_FROM_ANALYSIS_SERVICE = 6 // request code to pending runnable val pendingStorageAccessResultHandlers = ConcurrentHashMap() diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt index 4a949e8f8..2323c9009 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt @@ -2,24 +2,21 @@ package deckers.thibault.aves import android.app.SearchManager import android.content.ContentProvider -import android.content.ContentResolver import android.content.ContentValues import android.content.Context import android.database.Cursor import android.database.MatrixCursor import android.net.Uri import android.os.Build -import android.os.Handler import android.util.Log import deckers.thibault.aves.model.FieldMap +import deckers.thibault.aves.utils.ContextUtils.resourceUri +import deckers.thibault.aves.utils.ContextUtils.runOnUiThread +import deckers.thibault.aves.utils.FlutterUtils import deckers.thibault.aves.utils.LogUtils -import io.flutter.FlutterInjector import io.flutter.embedding.engine.FlutterEngine -import io.flutter.embedding.engine.dart.DartExecutor -import io.flutter.embedding.engine.loader.FlutterLoader import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel -import io.flutter.view.FlutterCallbackInformation import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -71,7 +68,9 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid private suspend fun getSuggestions(context: Context, query: String): List { if (backgroundFlutterEngine == null) { - initFlutterEngine(context) + FlutterUtils.initFlutterEngine(context, SHARED_PREFERENCES_KEY, CALLBACK_HANDLE_KEY) { + backgroundFlutterEngine = it + } } val messenger = backgroundFlutterEngine!!.dartExecutor.binaryMessenger @@ -133,60 +132,5 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid const val CALLBACK_HANDLE_KEY = "callback_handle" private var backgroundFlutterEngine: FlutterEngine? = null - - private suspend fun initFlutterEngine(context: Context) { - val callbackHandle = context.getSharedPreferences(SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE).getLong(CALLBACK_HANDLE_KEY, 0) - if (callbackHandle == 0L) { - Log.e(LOG_TAG, "failed to retrieve registered callback handle") - return - } - - lateinit var flutterLoader: FlutterLoader - context.runOnUiThread { - // initialization must happen on the main thread - flutterLoader = FlutterInjector.instance().flutterLoader().apply { - startInitialization(context) - ensureInitializationComplete(context, null) - } - } - - val callbackInfo = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle) - if (callbackInfo == null) { - Log.e(LOG_TAG, "failed to find callback information") - return - } - - val args = DartExecutor.DartCallback( - context.assets, - flutterLoader.findAppBundlePath(), - callbackInfo - ) - context.runOnUiThread { - // initialization must happen on the main thread - backgroundFlutterEngine = FlutterEngine(context).apply { - dartExecutor.executeDartCallback(args) - } - } - } - - // convenience methods - - private suspend fun Context.runOnUiThread(r: Runnable) { - suspendCoroutine { cont -> - Handler(mainLooper).post { - r.run() - cont.resume(true) - } - } - } - - private fun Context.resourceUri(resourceId: Int): Uri = with(resources) { - Uri.Builder() - .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) - .authority(getResourcePackageName(resourceId)) - .appendPath(getResourceTypeName(resourceId)) - .appendPath(getResourceEntryName(resourceId)) - .build() - } } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AnalysisHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AnalysisHandler.kt new file mode 100644 index 000000000..a402b24ca --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AnalysisHandler.kt @@ -0,0 +1,104 @@ +package deckers.thibault.aves.channel.calls + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Build +import android.os.IBinder +import android.util.Log +import deckers.thibault.aves.AnalysisService +import deckers.thibault.aves.AnalysisServiceBinder +import deckers.thibault.aves.AnalysisServiceListener +import deckers.thibault.aves.utils.ContextUtils.isMyServiceRunning +import deckers.thibault.aves.utils.LogUtils +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +class AnalysisHandler(private val activity: Activity, private val onAnalysisCompleted: () -> Unit) : MethodChannel.MethodCallHandler, AnalysisServiceListener { + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "registerCallback" -> GlobalScope.launch(Dispatchers.IO) { Coresult.safe(call, result, ::registerCallback) } + "startService" -> Coresult.safe(call, result, ::startAnalysis) + else -> result.notImplemented() + } + } + + @SuppressLint("CommitPrefEdits") + private fun registerCallback(call: MethodCall, result: MethodChannel.Result) { + val callbackHandle = call.argument("callbackHandle")?.toLong() + if (callbackHandle == null) { + result.error("registerCallback-args", "failed because of missing arguments", null) + return + } + + activity.getSharedPreferences(AnalysisService.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE) + .edit() + .putLong(AnalysisService.CALLBACK_HANDLE_KEY, callbackHandle) + .apply() + result.success(true) + } + + private fun startAnalysis(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + if (!activity.isMyServiceRunning(AnalysisService::class.java)) { + val intent = Intent(activity, AnalysisService::class.java) + intent.putExtra(AnalysisService.KEY_COMMAND, AnalysisService.COMMAND_START) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + activity.startForegroundService(intent) + } else { + activity.startService(intent) + } + } + attachToActivity() + result.success(null) + } + + private var attached = false + + fun attachToActivity() { + if (activity.isMyServiceRunning(AnalysisService::class.java)) { + val intent = Intent(activity, AnalysisService::class.java) + activity.bindService(intent, connection, Context.BIND_AUTO_CREATE) + attached = true + } + } + + override fun detachFromActivity() { + if (attached) { + attached = false + activity.unbindService(connection) + } + } + + override fun refreshApp() { + if (attached) { + onAnalysisCompleted() + } + } + + private val connection = object : ServiceConnection { + var binder: AnalysisServiceBinder? = null + + override fun onServiceConnected(name: ComponentName, service: IBinder) { + Log.i(LOG_TAG, "Analysis service connected") + binder = service as AnalysisServiceBinder + binder?.startListening(this@AnalysisHandler) + } + + override fun onServiceDisconnected(name: ComponentName) { + Log.i(LOG_TAG, "Analysis service disconnected") + binder?.stopListening(this@AnalysisHandler) + binder = null + } + } + + companion object { + private val LOG_TAG = LogUtils.createTag() + const val CHANNEL = "deckers.thibault/aves/analysis" + } +} diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GlobalSearchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GlobalSearchHandler.kt index 770f6beac..48560f9a5 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GlobalSearchHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GlobalSearchHandler.kt @@ -1,11 +1,9 @@ package deckers.thibault.aves.channel.calls -import android.app.Activity +import android.annotation.SuppressLint import android.content.Context -import android.util.Log import deckers.thibault.aves.SearchSuggestionsProvider import deckers.thibault.aves.channel.calls.Coresult.Companion.safe -import deckers.thibault.aves.utils.LogUtils import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler @@ -13,7 +11,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -class GlobalSearchHandler(private val context: Activity) : MethodCallHandler { +class GlobalSearchHandler(private val context: Context) : MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { "registerCallback" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::registerCallback) } @@ -21,6 +19,7 @@ class GlobalSearchHandler(private val context: Activity) : MethodCallHandler { } } + @SuppressLint("CommitPrefEdits") private fun registerCallback(call: MethodCall, result: MethodChannel.Result) { val callbackHandle = call.argument("callbackHandle")?.toLong() if (callbackHandle == null) { @@ -36,7 +35,6 @@ class GlobalSearchHandler(private val context: Activity) : MethodCallHandler { } companion object { - private val LOG_TAG = LogUtils.createTag() const val CHANNEL = "deckers.thibault/aves/global_search" } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaStoreHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaStoreHandler.kt index 8de140b8a..b20b6b802 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaStoreHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaStoreHandler.kt @@ -1,6 +1,6 @@ package deckers.thibault.aves.channel.calls -import android.app.Activity +import android.content.Context import android.media.MediaScannerConnection import android.net.Uri import deckers.thibault.aves.channel.calls.Coresult.Companion.safe @@ -12,7 +12,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -class MediaStoreHandler(private val activity: Activity) : MethodCallHandler { +class MediaStoreHandler(private val context: Context) : MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { "checkObsoleteContentIds" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::checkObsoleteContentIds) } @@ -28,7 +28,7 @@ class MediaStoreHandler(private val activity: Activity) : MethodCallHandler { result.error("checkObsoleteContentIds-args", "failed because of missing arguments", null) return } - result.success(MediaStoreImageProvider().checkObsoleteContentIds(activity, knownContentIds)) + result.success(MediaStoreImageProvider().checkObsoleteContentIds(context, knownContentIds)) } private fun checkObsoletePaths(call: MethodCall, result: MethodChannel.Result) { @@ -37,13 +37,13 @@ class MediaStoreHandler(private val activity: Activity) : MethodCallHandler { result.error("checkObsoletePaths-args", "failed because of missing arguments", null) return } - result.success(MediaStoreImageProvider().checkObsoletePaths(activity, knownPathById)) + result.success(MediaStoreImageProvider().checkObsoletePaths(context, knownPathById)) } private fun scanFile(call: MethodCall, result: MethodChannel.Result) { val path = call.argument("path") val mimeType = call.argument("mimeType") - MediaScannerConnection.scanFile(activity, arrayOf(path), arrayOf(mimeType)) { _, uri: Uri? -> result.success(uri?.toString()) } + MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, uri: Uri? -> result.success(uri?.toString()) } } companion object { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/AnalysisStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/AnalysisStreamHandler.kt new file mode 100644 index 000000000..2e199a30d --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/AnalysisStreamHandler.kt @@ -0,0 +1,25 @@ +package deckers.thibault.aves.channel.streams + +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.EventChannel.EventSink + +class AnalysisStreamHandler : EventChannel.StreamHandler { + // cannot use `lateinit` because we cannot guarantee + // its initialization in `onListen` at the right time + // e.g. when resuming the app after the activity got destroyed + private var eventSink: EventSink? = null + + override fun onListen(arguments: Any?, eventSink: EventSink) { + this.eventSink = eventSink + } + + override fun onCancel(arguments: Any?) {} + + fun notifyCompletion() { + eventSink?.success(true) + } + + companion object { + const val CHANNEL = "deckers.thibault/aves/analysis_events" + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt index 4dfc6e5af..5ff445aaa 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt @@ -1,6 +1,6 @@ package deckers.thibault.aves.channel.streams -import android.app.Activity +import android.content.Context import android.net.Uri import android.os.Handler import android.os.Looper @@ -16,8 +16,8 @@ import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes -import deckers.thibault.aves.utils.MimeTypes.isHeic import deckers.thibault.aves.utils.MimeTypes.canDecodeWithFlutter +import deckers.thibault.aves.utils.MimeTypes.isHeic import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide import deckers.thibault.aves.utils.StorageUtils @@ -26,10 +26,9 @@ import io.flutter.plugin.common.EventChannel.EventSink import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -import java.io.IOException import java.io.InputStream -class ImageByteStreamHandler(private val activity: Activity, private val arguments: Any?) : EventChannel.StreamHandler { +class ImageByteStreamHandler(private val context: Context, private val arguments: Any?) : EventChannel.StreamHandler { private lateinit var eventSink: EventSink private lateinit var handler: Handler @@ -108,7 +107,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen private fun streamImageAsIs(uri: Uri, mimeType: String) { try { - StorageUtils.openInputStream(activity, uri)?.use { input -> streamBytes(input) } + StorageUtils.openInputStream(context, uri)?.use { input -> streamBytes(input) } } catch (e: Exception) { error("streamImage-image-read-exception", "failed to get image for mimeType=$mimeType uri=$uri", e.message) } @@ -116,14 +115,14 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen private suspend fun streamImageByGlide(uri: Uri, pageId: Int?, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) { val model: Any = if (isHeic(mimeType) && pageId != null) { - MultiTrackImage(activity, uri, pageId) + MultiTrackImage(context, uri, pageId) } else if (mimeType == MimeTypes.TIFF) { - TiffImage(activity, uri, pageId) + TiffImage(context, uri, pageId) } else { StorageUtils.getGlideSafeUri(uri, mimeType) } - val target = Glide.with(activity) + val target = Glide.with(context) .asBitmap() .apply(glideOptions) .load(model) @@ -132,7 +131,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen @Suppress("BlockingMethodInNonBlockingContext") var bitmap = target.get() if (needRotationAfterGlide(mimeType)) { - bitmap = applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped) + bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped) } if (bitmap != null) { success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false)) @@ -142,15 +141,15 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen } catch (e: Exception) { error("streamImage-image-decode-exception", "failed to get image for mimeType=$mimeType uri=$uri model=$model", toErrorDetails(e)) } finally { - Glide.with(activity).clear(target) + Glide.with(context).clear(target) } } private suspend fun streamVideoByGlide(uri: Uri, mimeType: String) { - val target = Glide.with(activity) + val target = Glide.with(context) .asBitmap() .apply(glideOptions) - .load(VideoThumbnail(activity, uri)) + .load(VideoThumbnail(context, uri)) .submit() try { @Suppress("BlockingMethodInNonBlockingContext") @@ -163,7 +162,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen } catch (e: Exception) { error("streamImage-video-exception", "failed to get image for mimeType=$mimeType uri=$uri", e.message) } finally { - Glide.with(activity).clear(target) + Glide.with(context).clear(target) } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt index 56e81c0f5..31d6adf4c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt @@ -2,9 +2,7 @@ package deckers.thibault.aves.metadata import android.content.Context import android.net.Uri -import android.util.Log import androidx.exifinterface.media.ExifInterface -import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.StorageUtils import java.io.File @@ -15,8 +13,6 @@ import java.util.* import java.util.regex.Pattern object Metadata { - private val LOG_TAG = LogUtils.createTag() - const val IPTC_MARKER_BYTE: Byte = 0x1c // Pattern to extract latitude & longitude from a video location tag (cf ISO 6709) @@ -138,7 +134,6 @@ object Metadata { } else { // make a preview from the beginning of the file, // hoping the metadata is accessible in the copied chunk - Log.d(LOG_TAG, "use a preview for uri=$uri mimeType=$mimeType size=$sizeBytes") var previewFile = previewFiles[uri] if (previewFile == null) { previewFile = createPreviewFile(context, uri) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt new file mode 100644 index 000000000..318bf234e --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt @@ -0,0 +1,42 @@ +package deckers.thibault.aves.utils + +import android.app.ActivityManager +import android.app.Service +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.os.Handler +import android.os.Looper +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +object ContextUtils { + fun Context.resourceUri(resourceId: Int): Uri = with(resources) { + Uri.Builder() + .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) + .authority(getResourcePackageName(resourceId)) + .appendPath(getResourceTypeName(resourceId)) + .appendPath(getResourceEntryName(resourceId)) + .build() + } + + suspend fun Context.runOnUiThread(r: Runnable) { + if (Looper.myLooper() != mainLooper) { + suspendCoroutine { cont -> + Handler(mainLooper).post { + r.run() + cont.resume(true) + } + } + } else { + r.run() + } + } + + fun Context.isMyServiceRunning(serviceClass: Class): Boolean { + val am = this.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? + am ?: return false + @Suppress("DEPRECATION") + return am.getRunningServices(Integer.MAX_VALUE).any { it.service.className == serviceClass.name } + } +} diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/FlutterUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/FlutterUtils.kt new file mode 100644 index 000000000..98a1dbed6 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/FlutterUtils.kt @@ -0,0 +1,49 @@ +package deckers.thibault.aves.utils + +import android.content.Context +import android.util.Log +import deckers.thibault.aves.utils.ContextUtils.runOnUiThread +import io.flutter.FlutterInjector +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.dart.DartExecutor +import io.flutter.embedding.engine.loader.FlutterLoader +import io.flutter.view.FlutterCallbackInformation + +object FlutterUtils { + private val LOG_TAG = LogUtils.createTag() + + suspend fun initFlutterEngine(context: Context, sharedPreferencesKey: String, callbackHandleKey: String, engineSetter: (engine: FlutterEngine) -> Unit) { + val callbackHandle = context.getSharedPreferences(sharedPreferencesKey, Context.MODE_PRIVATE).getLong(callbackHandleKey, 0) + if (callbackHandle == 0L) { + Log.e(LOG_TAG, "failed to retrieve registered callback handle for sharedPreferencesKey=$sharedPreferencesKey callbackHandleKey=$callbackHandleKey") + return + } + + lateinit var flutterLoader: FlutterLoader + context.runOnUiThread { + // initialization must happen on the main thread + flutterLoader = FlutterInjector.instance().flutterLoader().apply { + startInitialization(context) + ensureInitializationComplete(context, null) + } + } + + val callbackInfo = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle) + if (callbackInfo == null) { + Log.e(LOG_TAG, "failed to find callback information for sharedPreferencesKey=$sharedPreferencesKey callbackHandleKey=$callbackHandleKey") + return + } + + val args = DartExecutor.DartCallback( + context.assets, + flutterLoader.findAppBundlePath(), + callbackInfo + ) + context.runOnUiThread { + val engine = FlutterEngine(context).apply { + dartExecutor.executeDartCallback(args) + } + engineSetter(engine) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/res/drawable-v21/ic_notification.xml b/android/app/src/main/res/drawable-v21/ic_notification.xml new file mode 100644 index 000000000..a9fdcd46f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/ic_notification.xml @@ -0,0 +1,26 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_outline_stop_24.xml b/android/app/src/main/res/drawable/ic_outline_stop_24.xml new file mode 100644 index 000000000..0214f1220 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_outline_stop_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/values-ko/strings.xml b/android/app/src/main/res/values-ko/strings.xml index 2c44e7463..2f2fe17ec 100644 --- a/android/app/src/main/res/values-ko/strings.xml +++ b/android/app/src/main/res/values-ko/strings.xml @@ -1,6 +1,10 @@  - 아베스 - 검색 - 동영상 + 아베스 + 검색 + 동영상 + 미디어 분석 + 사진과 동영상 분석 + 미디어 분석 + 취소 \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index c296a8709..a37cd760c 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -3,4 +3,8 @@ Aves Search Videos + Media scan + Scan images & videos + Scanning media + Stop \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 620738338..472b59cae 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -62,8 +62,10 @@ "@sourceStateLoading": {}, "sourceStateCataloguing": "Cataloguing", "@sourceStateCataloguing": {}, - "sourceStateLocating": "Locating", - "@sourceStateLocating": {}, + "sourceStateLocatingCountries": "Locating countries", + "@sourceStateLocatingCountries": {}, + "sourceStateLocatingPlaces": "Locating places", + "@sourceStateLocatingPlaces": {}, "chipActionDelete": "Delete", "@chipActionDelete": {}, diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 9f91e2867..dcecf8d39 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -27,7 +27,8 @@ "sourceStateLoading": "로딩 중", "sourceStateCataloguing": "분석 중", - "sourceStateLocating": "장소 찾는 중", + "sourceStateLocatingCountries": "국가 찾는 중", + "sourceStateLocatingPlaces": "장소 찾는 중", "chipActionDelete": "삭제", "chipActionGoToAlbumPage": "앨범 페이지에서 보기", diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 2a379205b..3adbd3b08 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -434,17 +434,18 @@ class AvesEntry { addressDetails = null; } - Future catalog({bool background = false, bool persist = true, bool force = false}) async { + Future catalog({required bool background, required bool persist, required bool force}) async { if (isCatalogued && !force) return; if (isSvg) { // vector image sizing is not essential, so we should not spend time for it during loading // but it is useful anyway (for aspect ratios etc.) so we size them during cataloguing final size = await SvgMetadataService.getSize(this); if (size != null) { - await _applyNewFields({ + final fields = { 'width': size.width.ceil(), 'height': size.height.ceil(), - }, persist: persist); + }; + await _applyNewFields(fields, persist: persist); } catalogMetadata = CatalogMetadata(contentId: contentId); } else { @@ -468,17 +469,17 @@ class AvesEntry { addressChangeNotifier.notifyListeners(); } - Future locate({required bool background}) async { + Future locate({required bool background, required bool force}) async { if (!hasGps) return; - await _locateCountry(); + await _locateCountry(force: force); if (await availability.canLocatePlaces) { - await locatePlace(background: background); + await locatePlace(background: background, force: force); } } // quick reverse geocoding to find the country, using an offline asset - Future _locateCountry() async { - if (!hasGps || hasAddress) return; + Future _locateCountry({required bool force}) async { + if (!hasGps || (hasAddress && !force)) return; final countryCode = await countryTopology.countryCode(latLng!); setCountry(countryCode); } @@ -500,8 +501,8 @@ class AvesEntry { } // full reverse geocoding, requiring Play Services and some connectivity - Future locatePlace({required bool background}) async { - if (!hasGps || hasFineAddress) return; + Future locatePlace({required bool background, required bool force}) async { + if (!hasGps || (hasFineAddress && !force)) return; try { Future> call() => GeocodingService.getAddress(latLng!, geocoderLocale); final addresses = await (background @@ -564,6 +565,10 @@ class AvesEntry { }.any((s) => s != null && s.toUpperCase().contains(query)); Future _applyNewFields(Map newFields, {required bool persist}) async { + final oldDateModifiedSecs = this.dateModifiedSecs; + final oldRotationDegrees = this.rotationDegrees; + final oldIsFlipped = this.isFlipped; + final uri = newFields['uri']; if (uri is String) this.uri = uri; final path = newFields['path']; @@ -599,10 +604,11 @@ class AvesEntry { if (catalogMetadata != null) await metadataDb.saveMetadata({catalogMetadata!}); } + await _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); metadataChangeNotifier.notifyListeners(); } - Future refresh({required bool persist}) async { + Future refresh({required bool background, required bool persist, required bool force}) async { _catalogMetadata = null; _addressDetails = null; _bestDate = null; @@ -614,13 +620,9 @@ class AvesEntry { final updated = await mediaFileService.getEntry(uri, mimeType); if (updated != null) { - final oldDateModifiedSecs = dateModifiedSecs; - final oldRotationDegrees = rotationDegrees; - final oldIsFlipped = isFlipped; await _applyNewFields(updated.toMap(), persist: persist); - await catalog(background: false, persist: persist); - await locate(background: false); - await _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); + await catalog(background: background, persist: persist, force: force); + await locate(background: background, force: force); } } @@ -628,11 +630,7 @@ class AvesEntry { final newFields = await metadataEditService.rotate(this, clockwise: clockwise); if (newFields.isEmpty) return false; - final oldDateModifiedSecs = dateModifiedSecs; - final oldRotationDegrees = rotationDegrees; - final oldIsFlipped = isFlipped; await _applyNewFields(newFields, persist: persist); - await _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); return true; } @@ -640,11 +638,7 @@ class AvesEntry { final newFields = await metadataEditService.flip(this); if (newFields.isEmpty) return false; - final oldDateModifiedSecs = dateModifiedSecs; - final oldRotationDegrees = rotationDegrees; - final oldIsFlipped = isFlipped; await _applyNewFields(newFields, persist: persist); - await _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); return true; } diff --git a/lib/model/metadata/address.dart b/lib/model/metadata/address.dart index 86962a736..d7e5f232e 100644 --- a/lib/model/metadata/address.dart +++ b/lib/model/metadata/address.dart @@ -1,13 +1,17 @@ +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @immutable -class AddressDetails { +class AddressDetails extends Equatable { final int? contentId; final String? countryCode, countryName, adminArea, locality; String? get place => locality != null && locality!.isNotEmpty ? locality : adminArea; + @override + List get props => [contentId, countryCode, countryName, adminArea, locality]; + const AddressDetails({ this.contentId, this.countryCode, @@ -45,7 +49,4 @@ class AddressDetails { 'adminArea': adminArea, 'locality': locality, }; - - @override - String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}'; } diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index fb26c0b78..51f95d755 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -170,7 +170,6 @@ class SqfliteMetadataDb implements MetadataDb { Future removeIds(Set contentIds, {required bool metadataOnly}) async { if (contentIds.isEmpty) return; - // final stopwatch = Stopwatch()..start(); final db = await _database; // using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead final batch = db.batch(); @@ -187,7 +186,6 @@ class SqfliteMetadataDb implements MetadataDb { } }); await batch.commit(noResult: true); - // debugPrint('$runtimeType removeIds complete in ${stopwatch.elapsed.inMilliseconds}ms for ${contentIds.length} entries'); } // entries @@ -201,11 +199,9 @@ class SqfliteMetadataDb implements MetadataDb { @override Future> loadEntries() async { - // final stopwatch = Stopwatch()..start(); final db = await _database; final maps = await db.query(entryTable); final entries = maps.map((map) => AvesEntry.fromMap(map)).toSet(); - // debugPrint('$runtimeType loadEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries'); return entries; } diff --git a/lib/model/settings/defaults.dart b/lib/model/settings/defaults.dart index e91319ecd..347d8fd5e 100644 --- a/lib/model/settings/defaults.dart +++ b/lib/model/settings/defaults.dart @@ -13,6 +13,7 @@ import 'package:flutter/material.dart'; class SettingsDefaults { // app static const hasAcceptedTerms = false; + static const canUseAnalysisService = true; static const isErrorReportingEnabled = false; static const mustBackTwiceToExit = true; static const keepScreenOn = KeepScreenOn.viewerOnly; diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 0699d257a..65259cf4f 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -27,9 +27,7 @@ class Settings extends ChangeNotifier { static SharedPreferences? _prefs; - Settings._private() { - _platformSettingsChangeChannel.receiveBroadcastStream().listen((event) => _onPlatformSettingsChange(event as Map?)); - } + Settings._private(); static const Set internalKeys = { hasAcceptedTermsKey, @@ -41,6 +39,7 @@ class Settings extends ChangeNotifier { // app static const hasAcceptedTermsKey = 'has_accepted_terms'; + static const canUseAnalysisServiceKey = 'can_use_analysis_service'; static const isErrorReportingEnabledKey = 'is_crashlytics_enabled'; static const localeKey = 'locale'; static const mustBackTwiceToExitKey = 'must_back_twice_to_exit'; @@ -124,12 +123,16 @@ class Settings extends ChangeNotifier { bool get initialized => _prefs != null; Future init({ + required bool monitorPlatformSettings, bool isRotationLocked = false, bool areAnimationsRemoved = false, }) async { _prefs = await SharedPreferences.getInstance(); _isRotationLocked = isRotationLocked; _areAnimationsRemoved = areAnimationsRemoved; + if (monitorPlatformSettings) { + _platformSettingsChangeChannel.receiveBroadcastStream().listen((event) => _onPlatformSettingsChange(event as Map?)); + } } Future reset({required bool includeInternalKeys}) async { @@ -165,6 +168,10 @@ class Settings extends ChangeNotifier { set hasAcceptedTerms(bool newValue) => setAndNotify(hasAcceptedTermsKey, newValue); + bool get canUseAnalysisService => getBoolOrDefault(canUseAnalysisServiceKey, SettingsDefaults.canUseAnalysisService); + + set canUseAnalysisService(bool newValue) => setAndNotify(canUseAnalysisServiceKey, newValue); + bool get isErrorReportingEnabled => getBoolOrDefault(isErrorReportingEnabledKey, SettingsDefaults.isErrorReportingEnabled); set isErrorReportingEnabled(bool newValue) => setAndNotify(isErrorReportingEnabledKey, newValue); diff --git a/lib/model/source/analysis_controller.dart b/lib/model/source/analysis_controller.dart new file mode 100644 index 000000000..f7a8e97d9 --- /dev/null +++ b/lib/model/source/analysis_controller.dart @@ -0,0 +1,14 @@ +import 'package:flutter/foundation.dart'; + +class AnalysisController { + final bool canStartService, force; + final ValueNotifier stopSignal; + + AnalysisController({ + this.canStartService = true, + this.force = false, + ValueNotifier? stopSignal, + }) : stopSignal = stopSignal ?? ValueNotifier(false); + + bool get isStopping => stopSignal.value; +} diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 9101dd24a..1ffa90da9 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -9,9 +9,11 @@ import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/album.dart'; +import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/tag.dart'; +import 'package:aves/services/analysis_service.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; import 'package:collection/collection.dart'; @@ -29,14 +31,14 @@ mixin SourceBase { ValueNotifier stateNotifier = ValueNotifier(SourceState.ready); - final StreamController _progressStreamController = StreamController.broadcast(); + ValueNotifier progressNotifier = ValueNotifier(const ProgressEvent(done: 0, total: 0)); - Stream get progressStream => _progressStreamController.stream; - - void setProgress({required int done, required int total}) => _progressStreamController.add(ProgressEvent(done: done, total: total)); + void setProgress({required int done, required int total}) => progressNotifier.value = ProgressEvent(done: done, total: total); } abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin { + static const _analysisServiceOpCountThreshold = 400; + final EventBus _eventBus = EventBus(); @override @@ -86,6 +88,15 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM invalidateTagFilterSummary(entries); } + void updateDerivedFilters([Set? entries]) { + _invalidate(entries); + // it is possible for entries hidden by a filter type, to have an impact on other types + // e.g. given a sole entry for country C and tag T, hiding T should make C disappear too + updateDirectories(); + updateLocations(); + updateTags(); + } + void addEntries(Set entries) { if (entries.isEmpty) return; @@ -113,11 +124,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM entries.forEach((v) => _entryById.remove(v.contentId)); _rawEntries.removeAll(entries); - _invalidate(entries); - - cleanEmptyAlbums(entries.map((entry) => entry.directory).toSet()); - updateLocations(); - updateTags(); + updateDerivedFilters(entries); eventBus.fire(EntryRemovedEvent(entries)); } @@ -252,20 +259,43 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM Future init(); - Future refresh(); + Future refresh({AnalysisController? analysisController}); - Future> refreshUris(Set changedUris); + Future> refreshUris(Set changedUris, {AnalysisController? analysisController}); - Future rescan(Set entries); + Future refreshEntry(AvesEntry entry) async { + await entry.refresh(background: false, persist: true, force: true); + updateDerivedFilters({entry}); + eventBus.fire(EntryRefreshedEvent({entry})); + } - Future refreshMetadata(Set entries) async { - await Future.forEach(entries, (entry) => entry.refresh(persist: true)); - - _invalidate(entries); - updateLocations(); - updateTags(); - - eventBus.fire(EntryRefreshedEvent(entries)); + Future analyze(AnalysisController? analysisController, Set candidateEntries) async { + final todoEntries = visibleEntries; + final _analysisController = analysisController ?? AnalysisController(); + if (!_analysisController.isStopping) { + late bool startAnalysisService; + if (_analysisController.canStartService && settings.canUseAnalysisService) { + final force = _analysisController.force; + var opCount = 0; + opCount += (force ? todoEntries : todoEntries.where(TagMixin.catalogEntriesTest)).length; + opCount += (force ? todoEntries.where((entry) => entry.hasGps) : todoEntries.where(LocationMixin.locateCountriesTest)).length; + if (await availability.canLocatePlaces) { + opCount += (force ? todoEntries.where((entry) => entry.hasGps) : todoEntries.where(LocationMixin.locatePlacesTest)).length; + } + startAnalysisService = opCount > _analysisServiceOpCountThreshold; + } else { + startAnalysisService = false; + } + if (startAnalysisService) { + await AnalysisService.startService(); + } else { + await catalogEntries(_analysisController, candidateEntries); + updateDerivedFilters(candidateEntries); + await locateEntries(_analysisController, candidateEntries); + updateDerivedFilters(candidateEntries); + } + } + stateNotifier.value = SourceState.ready; } // monitoring @@ -312,46 +342,45 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM settings.searchHistory = settings.searchHistory..removeWhere(filters.contains); } settings.hiddenFilters = hiddenFilters; - - _invalidate(); - // it is possible for entries hidden by a filter type, to have an impact on other types - // e.g. given a sole entry for country C and tag T, hiding T should make C disappear too - updateDirectories(); - updateLocations(); - updateTags(); - + updateDerivedFilters(); eventBus.fire(FilterVisibilityChangedEvent(filters, visible)); if (visible) { - refreshMetadata(visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet()); + final candidateEntries = visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet(); + analyze(null, candidateEntries); } } } +@immutable class EntryAddedEvent { final Set? entries; const EntryAddedEvent([this.entries]); } +@immutable class EntryRemovedEvent { final Set entries; const EntryRemovedEvent(this.entries); } +@immutable class EntryMovedEvent { final Set entries; const EntryMovedEvent(this.entries); } +@immutable class EntryRefreshedEvent { final Set entries; const EntryRefreshedEvent(this.entries); } +@immutable class FilterVisibilityChangedEvent { final Set filters; final bool visible; @@ -359,6 +388,7 @@ class FilterVisibilityChangedEvent { const FilterVisibilityChangedEvent(this.filters, this.visible); } +@immutable class ProgressEvent { final int done, total; diff --git a/lib/model/source/enums.dart b/lib/model/source/enums.dart index 228310df4..451e27fef 100644 --- a/lib/model/source/enums.dart +++ b/lib/model/source/enums.dart @@ -1,4 +1,4 @@ -enum SourceState { loading, cataloguing, locating, ready } +enum SourceState { loading, cataloguing, locatingCountries, locatingPlaces, ready } enum ChipSortFactor { date, name, count } diff --git a/lib/model/source/location.dart b/lib/model/source/location.dart index e1683125e..cd5f34168 100644 --- a/lib/model/source/location.dart +++ b/lib/model/source/location.dart @@ -4,6 +4,7 @@ import 'package:aves/geo/countries.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/metadata/address.dart'; +import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/services/common/services.dart'; @@ -12,38 +13,43 @@ import 'package:flutter/foundation.dart'; import 'package:tuple/tuple.dart'; mixin LocationMixin on SourceBase { - static const _commitCountThreshold = 50; + static const _commitCountThreshold = 80; + static const _stopCheckCountThreshold = 20; List sortedCountries = List.unmodifiable([]); List sortedPlaces = List.unmodifiable([]); Future loadAddresses() async { - // final stopwatch = Stopwatch()..start(); final saved = await metadataDb.loadAddresses(); final idMap = entryById; saved.forEach((metadata) => idMap[metadata.contentId]?.addressDetails = metadata); - // debugPrint('$runtimeType loadAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries'); onAddressMetadataChanged(); } - Future locateEntries() async { - await _locateCountries(); - await _locatePlaces(); + Future locateEntries(AnalysisController controller, Set candidateEntries) async { + await _locateCountries(controller, candidateEntries); + await _locatePlaces(controller, candidateEntries); } + static bool locateCountriesTest(AvesEntry entry) => entry.hasGps && !entry.hasAddress; + + static bool locatePlacesTest(AvesEntry entry) => entry.hasGps && !entry.hasFineAddress; + // quick reverse geocoding to find the countries, using an offline asset - Future _locateCountries() async { - final todo = visibleEntries.where((entry) => entry.hasGps && !entry.hasAddress).toSet(); + Future _locateCountries(AnalysisController controller, Set candidateEntries) async { + if (controller.isStopping) return; + + final force = controller.force; + final todo = (force ? candidateEntries.where((entry) => entry.hasGps) : candidateEntries.where(locateCountriesTest)).toSet(); if (todo.isEmpty) return; - stateNotifier.value = SourceState.locating; + stateNotifier.value = SourceState.locatingCountries; var progressDone = 0; final progressTotal = todo.length; setProgress(done: progressDone, total: progressTotal); - // final stopwatch = Stopwatch()..start(); final countryCodeMap = await countryTopology.countryCodeMap(todo.map((entry) => entry.latLng!).toSet()); - final newAddresses = []; + final newAddresses = {}; todo.forEach((entry) { final position = entry.latLng; final countryCode = countryCodeMap.entries.firstWhereOrNull((kv) => kv.value.contains(position))?.key; @@ -54,19 +60,18 @@ mixin LocationMixin on SourceBase { setProgress(done: ++progressDone, total: progressTotal); }); if (newAddresses.isNotEmpty) { - await metadataDb.saveAddresses(Set.of(newAddresses)); + await metadataDb.saveAddresses(Set.unmodifiable(newAddresses)); onAddressMetadataChanged(); } - // debugPrint('$runtimeType _locateCountries complete in ${stopwatch.elapsed.inMilliseconds}ms'); } // full reverse geocoding, requiring Play Services and some connectivity - Future _locatePlaces() async { + Future _locatePlaces(AnalysisController controller, Set candidateEntries) async { + if (controller.isStopping) return; if (!(await availability.canLocatePlaces)) return; - // final stopwatch = Stopwatch()..start(); - final byLocated = groupBy(visibleEntries.where((entry) => entry.hasGps), (entry) => entry.hasFineAddress); - final todo = byLocated[false] ?? []; + final force = controller.force; + final todo = (force ? candidateEntries.where((entry) => entry.hasGps) : candidateEntries.where(locatePlacesTest)).toSet(); if (todo.isEmpty) return; // geocoder calls take between 150ms and 250ms @@ -81,28 +86,31 @@ mixin LocationMixin on SourceBase { final latLngFactor = pow(10, 2); Tuple2 approximateLatLng(AvesEntry entry) { // entry has coordinates - final lat = entry.catalogMetadata!.latitude!; - final lng = entry.catalogMetadata!.longitude!; + final catalogMetadata = entry.catalogMetadata!; + final lat = catalogMetadata.latitude!; + final lng = catalogMetadata.longitude!; return Tuple2((lat * latLngFactor).round(), (lng * latLngFactor).round()); } + final located = visibleEntries.where((entry) => entry.hasGps).toSet().difference(todo); final knownLocations = , AddressDetails?>{}; - byLocated[true]?.forEach((entry) { + located.forEach((entry) { knownLocations.putIfAbsent(approximateLatLng(entry), () => entry.addressDetails); }); - stateNotifier.value = SourceState.locating; + stateNotifier.value = SourceState.locatingPlaces; var progressDone = 0; final progressTotal = todo.length; setProgress(done: progressDone, total: progressTotal); - final newAddresses = []; - await Future.forEach(todo, (entry) async { + var stopCheckCount = 0; + final newAddresses = {}; + for (final entry in todo) { final latLng = approximateLatLng(entry); if (knownLocations.containsKey(latLng)) { entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId); } else { - await entry.locatePlace(background: true); + await entry.locatePlace(background: true, force: force); // it is intended to insert `null` if the geocoder failed, // so that we skip geocoding of following entries with the same coordinates knownLocations[latLng] = entry.addressDetails; @@ -110,18 +118,21 @@ mixin LocationMixin on SourceBase { if (entry.hasFineAddress) { newAddresses.add(entry.addressDetails!); if (newAddresses.length >= _commitCountThreshold) { - await metadataDb.saveAddresses(Set.of(newAddresses)); + await metadataDb.saveAddresses(Set.unmodifiable(newAddresses)); onAddressMetadataChanged(); newAddresses.clear(); } + if (++stopCheckCount >= _stopCheckCountThreshold) { + stopCheckCount = 0; + if (controller.isStopping) return; + } } setProgress(done: ++progressDone, total: progressTotal); - }); + } if (newAddresses.isNotEmpty) { - await metadataDb.saveAddresses(Set.of(newAddresses)); + await metadataDb.saveAddresses(Set.unmodifiable(newAddresses)); onAddressMetadataChanged(); } - // debugPrint('$runtimeType _locatePlaces complete in ${stopwatch.elapsed.inSeconds}s'); } void onAddressMetadataChanged() { diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index 775419f4b..f76b6c326 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -5,6 +5,7 @@ import 'package:aves/model/covers.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/services/common/services.dart'; @@ -42,7 +43,7 @@ class MediaStoreSource extends CollectionSource { } @override - Future refresh() async { + Future refresh({AnalysisController? analysisController}) async { assert(_initialized); debugPrint('$runtimeType refresh start'); final stopwatch = Stopwatch()..start(); @@ -59,10 +60,10 @@ class MediaStoreSource extends CollectionSource { // show known entries debugPrint('$runtimeType refresh ${stopwatch.elapsed} add known entries'); addEntries(oldEntries); - debugPrint('$runtimeType refresh ${stopwatch.elapsed} load catalog metadata'); + debugPrint('$runtimeType refresh ${stopwatch.elapsed} load metadata'); await loadCatalogMetadata(); - debugPrint('$runtimeType refresh ${stopwatch.elapsed} load address metadata'); await loadAddresses(); + updateDerivedFilters(); // clean up obsolete entries debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries'); @@ -110,11 +111,7 @@ class MediaStoreSource extends CollectionSource { updateDirectories(); } - debugPrint('$runtimeType refresh ${stopwatch.elapsed} catalog entries'); - await catalogEntries(); - debugPrint('$runtimeType refresh ${stopwatch.elapsed} locate entries'); - await locateEntries(); - stateNotifier.value = SourceState.ready; + await analyze(analysisController, visibleEntries); debugPrint('$runtimeType refresh ${stopwatch.elapsed} done for ${oldEntries.length} known, ${allNewEntries.length} new, ${obsoleteContentIds.length} obsolete'); }, @@ -128,7 +125,7 @@ class MediaStoreSource extends CollectionSource { // For example, when taking a picture with a Galaxy S10e default camera app, querying the Media Store // sometimes yields an entry with its temporary path: `/data/sec/camera/!@#$%^..._temp.jpg` @override - Future> refreshUris(Set changedUris) async { + Future> refreshUris(Set changedUris, {AnalysisController? analysisController}) async { if (!_initialized || !isMonitoring) return changedUris; debugPrint('$runtimeType refreshUris ${changedUris.length} uris'); @@ -181,18 +178,10 @@ class MediaStoreSource extends CollectionSource { addEntries(newEntries); await metadataDb.saveEntries(newEntries); cleanEmptyAlbums(existingDirectories); - await catalogEntries(); - await locateEntries(); - stateNotifier.value = SourceState.ready; + + await analyze(analysisController, newEntries); } return tempUris; } - - @override - Future rescan(Set entries) async { - final contentIds = entries.map((entry) => entry.contentId).whereNotNull().toSet(); - await metadataDb.removeIds(contentIds, metadataOnly: true); - return refresh(); - } } diff --git a/lib/model/source/source_state.dart b/lib/model/source/source_state.dart new file mode 100644 index 000000000..ef18cbfd1 --- /dev/null +++ b/lib/model/source/source_state.dart @@ -0,0 +1,19 @@ +import 'package:aves/model/source/enums.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +extension ExtraSourceState on SourceState { + String? getName(AppLocalizations l10n) { + switch (this) { + case SourceState.loading: + return l10n.sourceStateLoading; + case SourceState.cataloguing: + return l10n.sourceStateCataloguing; + case SourceState.locatingCountries: + return l10n.sourceStateLocatingCountries; + case SourceState.locatingPlaces: + return l10n.sourceStateLocatingPlaces; + case SourceState.ready: + return null; + } + } +} diff --git a/lib/model/source/tag.dart b/lib/model/source/tag.dart index 661c37483..e08913547 100644 --- a/lib/model/source/tag.dart +++ b/lib/model/source/tag.dart @@ -1,6 +1,7 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/metadata/catalog.dart'; +import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/services/common/services.dart'; @@ -8,22 +9,25 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; mixin TagMixin on SourceBase { - static const _commitCountThreshold = 300; + static const _commitCountThreshold = 400; + static const _stopCheckCountThreshold = 100; List sortedTags = List.unmodifiable([]); Future loadCatalogMetadata() async { - // final stopwatch = Stopwatch()..start(); final saved = await metadataDb.loadMetadataEntries(); final idMap = entryById; saved.forEach((metadata) => idMap[metadata.contentId]?.catalogMetadata = metadata); - // debugPrint('$runtimeType loadCatalogMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries'); onCatalogMetadataChanged(); } - Future catalogEntries() async { -// final stopwatch = Stopwatch()..start(); - final todo = visibleEntries.where((entry) => !entry.isCatalogued).toList(); + static bool catalogEntriesTest(AvesEntry entry) => !entry.isCatalogued; + + Future catalogEntries(AnalysisController controller, Set candidateEntries) async { + if (controller.isStopping) return; + + final force = controller.force; + final todo = force ? candidateEntries : candidateEntries.where(catalogEntriesTest).toSet(); if (todo.isEmpty) return; stateNotifier.value = SourceState.cataloguing; @@ -31,22 +35,26 @@ mixin TagMixin on SourceBase { final progressTotal = todo.length; setProgress(done: progressDone, total: progressTotal); - final newMetadata = []; - await Future.forEach(todo, (entry) async { - await entry.catalog(background: true); + var stopCheckCount = 0; + final newMetadata = {}; + for (final entry in todo) { + await entry.catalog(background: true, persist: true, force: force); if (entry.isCatalogued) { newMetadata.add(entry.catalogMetadata!); if (newMetadata.length >= _commitCountThreshold) { - await metadataDb.saveMetadata(Set.of(newMetadata)); + await metadataDb.saveMetadata(Set.unmodifiable(newMetadata)); onCatalogMetadataChanged(); newMetadata.clear(); } + if (++stopCheckCount >= _stopCheckCountThreshold) { + stopCheckCount = 0; + if (controller.isStopping) return; + } } setProgress(done: ++progressDone, total: progressTotal); - }); - await metadataDb.saveMetadata(Set.of(newMetadata)); + } + await metadataDb.saveMetadata(Set.unmodifiable(newMetadata)); onCatalogMetadataChanged(); -// debugPrint('$runtimeType catalogEntries complete in ${stopwatch.elapsed.inSeconds}s'); } void onCatalogMetadataChanged() { diff --git a/lib/services/analysis_service.dart b/lib/services/analysis_service.dart new file mode 100644 index 000000000..8350d66a6 --- /dev/null +++ b/lib/services/analysis_service.dart @@ -0,0 +1,178 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/analysis_controller.dart'; +import 'package:aves/model/source/enums.dart'; +import 'package:aves/model/source/media_store_source.dart'; +import 'package:aves/model/source/source_state.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:fijkplayer/fijkplayer.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class AnalysisService { + static const platform = MethodChannel('deckers.thibault/aves/analysis'); + + static Future registerCallback() async { + try { + await platform.invokeMethod('registerCallback', { + 'callbackHandle': PluginUtilities.getCallbackHandle(_init)?.toRawHandle(), + }); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + } + + static Future startService() async { + try { + await platform.invokeMethod('startService'); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + } +} + +const _channel = MethodChannel('deckers.thibault/aves/analysis_service_background'); + +Future _init() async { + WidgetsFlutterBinding.ensureInitialized(); + initPlatformServices(); + await metadataDb.init(); + await settings.init(monitorPlatformSettings: false); + FijkLog.setLevel(FijkLogLevel.Warn); + await reportService.init(); + + final analyzer = Analyzer(); + _channel.setMethodCallHandler((call) { + switch (call.method) { + case 'start': + analyzer.start(); + return Future.value(true); + case 'stop': + analyzer.stop(); + return Future.value(true); + default: + throw PlatformException(code: 'not-implemented', message: 'failed to handle method=${call.method}'); + } + }); + try { + await _channel.invokeMethod('initialized'); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } +} + +enum AnalyzerState { running, stopping, stopped } + +class Analyzer { + late AppLocalizations _l10n; + final ValueNotifier _serviceStateNotifier = ValueNotifier(AnalyzerState.stopped); + final AnalysisController _controller = AnalysisController(canStartService: false, stopSignal: ValueNotifier(false)); + Timer? _notificationUpdateTimer; + final _source = MediaStoreSource(); + + AnalyzerState get serviceState => _serviceStateNotifier.value; + + bool get isRunning => serviceState == AnalyzerState.running; + + SourceState get sourceState => _source.stateNotifier.value; + + static const notificationUpdateInterval = Duration(seconds: 1); + + Analyzer() { + debugPrint('$runtimeType create'); + _serviceStateNotifier.addListener(_onServiceStateChanged); + _source.stateNotifier.addListener(_onSourceStateChanged); + } + + void dispose() { + debugPrint('$runtimeType dispose'); + _serviceStateNotifier.removeListener(_onServiceStateChanged); + _source.stateNotifier.removeListener(_onSourceStateChanged); + _stopUpdateTimer(); + } + + Future start() async { + debugPrint('$runtimeType start'); + _serviceStateNotifier.value = AnalyzerState.running; + + final preferredLocale = settings.locale; + final appLocale = basicLocaleListResolution(preferredLocale != null ? [preferredLocale] : null, AppLocalizations.supportedLocales); + _l10n = await AppLocalizations.delegate.load(appLocale); + + _controller.stopSignal.value = false; + await _source.init(); + unawaited(_source.refresh(analysisController: _controller)); + + _notificationUpdateTimer = Timer.periodic(notificationUpdateInterval, (_) async { + if (!isRunning) return; + await _updateNotification(); + }); + } + + void stop() { + debugPrint('$runtimeType stop'); + _serviceStateNotifier.value = AnalyzerState.stopped; + } + + void _stopUpdateTimer() => _notificationUpdateTimer?.cancel(); + + Future _onServiceStateChanged() async { + switch (serviceState) { + case AnalyzerState.running: + break; + case AnalyzerState.stopping: + await _stopPlatformService(); + _serviceStateNotifier.value = AnalyzerState.stopped; + break; + case AnalyzerState.stopped: + _controller.stopSignal.value = true; + _stopUpdateTimer(); + break; + } + } + + void _onSourceStateChanged() { + if (sourceState == SourceState.ready) { + _refreshApp(); + _serviceStateNotifier.value = AnalyzerState.stopping; + } + } + + Future _updateNotification() async { + if (!isRunning) return; + + final title = sourceState.getName(_l10n); + if (title == null) return; + + final progress = _source.progressNotifier.value; + final progressive = progress.total != 0 && sourceState != SourceState.locatingCountries; + + try { + await _channel.invokeMethod('updateNotification', { + 'title': title, + 'message': progressive ? '${progress.done}/${progress.total}' : null, + }); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + } + + Future _refreshApp() async { + try { + await _channel.invokeMethod('refreshApp'); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + } + + Future _stopPlatformService() async { + try { + await _channel.invokeMethod('stop'); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + } +} diff --git a/lib/services/geocoding_service.dart b/lib/services/geocoding_service.dart index aed70015a..9a567adbd 100644 --- a/lib/services/geocoding_service.dart +++ b/lib/services/geocoding_service.dart @@ -21,7 +21,9 @@ class GeocodingService { }); return (result as List).cast().map((map) => Address.fromMap(map)).toList(); } on PlatformException catch (e, stack) { - await reportService.recordError(e, stack); + if (e.code != 'getAddress-empty') { + await reportService.recordError(e, stack); + } } return []; } diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index 5665df751..bd774b3c3 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -19,6 +19,7 @@ import 'package:aves/widgets/common/providers/highlight_info_provider.dart'; import 'package:aves/widgets/home_page.dart'; import 'package:aves/widgets/welcome_page.dart'; import 'package:equatable/equatable.dart'; +import 'package:fijkplayer/fijkplayer.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -46,6 +47,7 @@ class _AvesAppState extends State { List _navigatorObservers = []; final EventChannel _mediaStoreChangeChannel = const EventChannel('deckers.thibault/aves/media_store_change'); final EventChannel _newIntentChannel = const EventChannel('deckers.thibault/aves/intent'); + final EventChannel _analysisCompletionChannel = const EventChannel('deckers.thibault/aves/analysis_events'); final EventChannel _errorChannel = const EventChannel('deckers.thibault/aves/error'); final GlobalKey _navigatorKey = GlobalKey(debugLabel: 'app-navigator'); @@ -58,6 +60,7 @@ class _AvesAppState extends State { _appSetup = _setup(); _mediaStoreChangeChannel.receiveBroadcastStream().listen((event) => _onMediaStoreChange(event as String?)); _newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map?)); + _analysisCompletionChannel.receiveBroadcastStream().listen((event) => _onAnalysisCompletion()); _errorChannel.receiveBroadcastStream().listen((event) => _onError(event as String?)); } @@ -144,9 +147,11 @@ class _AvesAppState extends State { Future _setup() async { await settings.init( + monitorPlatformSettings: true, isRotationLocked: await windowService.isRotationLocked(), areAnimationsRemoved: await AccessibilityService.areAnimationsRemoved(), ); + FijkLog.setLevel(FijkLogLevel.Warn); // keep screen on settings.updateStream.where((key) => key == Settings.keepScreenOnKey).listen( @@ -192,6 +197,13 @@ class _AvesAppState extends State { )); } + Future _onAnalysisCompletion() async { + debugPrint('Analysis completed'); + await _mediaStoreSource.loadCatalogMetadata(); + await _mediaStoreSource.loadAddresses(); + _mediaStoreSource.updateDerivedFilters(); + } + void _onMediaStoreChange(String? uri) { if (uri != null) changedUris.add(uri); if (changedUris.isNotEmpty) { diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 10bd549f5..8c3974992 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -7,6 +7,7 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/selection.dart'; +import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/common/image_op_events.dart'; @@ -75,7 +76,9 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware final selection = context.read>(); final selectedItems = _getExpandedSelectedItems(selection); - source.rescan(selectedItems); + final controller = AnalysisController(canStartService: false, force: true); + source.analyze(controller, selectedItems); + selection.browse(); } diff --git a/lib/widgets/common/app_bar_subtitle.dart b/lib/widgets/common/app_bar_subtitle.dart index d65dcdc4f..4362ee50d 100644 --- a/lib/widgets/common/app_bar_subtitle.dart +++ b/lib/widgets/common/app_bar_subtitle.dart @@ -1,5 +1,6 @@ import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; +import 'package:aves/model/source/source_state.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/material.dart'; @@ -56,43 +57,29 @@ class SourceStateSubtitle extends StatelessWidget { @override Widget build(BuildContext context) { - String? subtitle; - switch (source.stateNotifier.value) { - case SourceState.loading: - subtitle = context.l10n.sourceStateLoading; - break; - case SourceState.cataloguing: - subtitle = context.l10n.sourceStateCataloguing; - break; - case SourceState.locating: - subtitle = context.l10n.sourceStateLocating; - break; - case SourceState.ready: - default: - break; - } - final subtitleStyle = Theme.of(context).textTheme.caption; - return subtitle == null - ? const SizedBox.shrink() - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(subtitle, style: subtitleStyle), - StreamBuilder( - stream: source.progressStream, - builder: (context, snapshot) { - if (snapshot.hasError || !snapshot.hasData) return const SizedBox.shrink(); - final progress = snapshot.data!; - return Padding( - padding: const EdgeInsetsDirectional.only(start: 8), - child: Text( - '${progress.done}/${progress.total}', - style: subtitleStyle!.copyWith(color: Colors.white30), - ), - ); - }, + final sourceState = source.stateNotifier.value; + final subtitle = sourceState.getName(context.l10n); + if (subtitle == null) return const SizedBox(); + + final subtitleStyle = Theme.of(context).textTheme.caption!; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(subtitle, style: subtitleStyle), + ValueListenableBuilder( + valueListenable: source.progressNotifier, + builder: (context, progress, snapshot) { + if (progress.total == 0 || sourceState == SourceState.locatingCountries) return const SizedBox(); + return Padding( + padding: const EdgeInsetsDirectional.only(start: 8), + child: Text( + '${progress.done}/${progress.total}', + style: subtitleStyle.copyWith(color: Colors.white30), ), - ], - ); + ); + }, + ), + ], + ); } } diff --git a/lib/widgets/debug/app_debug_page.dart b/lib/widgets/debug/app_debug_page.dart index cf09dc132..f338cbb6e 100644 --- a/lib/widgets/debug/app_debug_page.dart +++ b/lib/widgets/debug/app_debug_page.dart @@ -1,5 +1,6 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/services/analysis_service.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/debug/android_apps.dart'; @@ -103,6 +104,10 @@ class _AppDebugPageState extends State { }, child: const Text('Source full refresh'), ), + const ElevatedButton( + onPressed: AnalysisService.startService, + child: Text('Start analysis service'), + ), const Divider(), Padding( padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8), diff --git a/lib/widgets/debug/settings.dart b/lib/widgets/debug/settings.dart index b21cc3212..3f2231faa 100644 --- a/lib/widgets/debug/settings.dart +++ b/lib/widgets/debug/settings.dart @@ -38,6 +38,11 @@ class DebugSettingsSection extends StatelessWidget { onChanged: (v) => settings.hasAcceptedTerms = v, title: const Text('hasAcceptedTerms'), ), + SwitchListTile( + value: settings.canUseAnalysisService, + onChanged: (v) => settings.canUseAnalysisService = v, + title: const Text('canUseAnalysisService'), + ), SwitchListTile( value: settings.videoShowRawTimedText, onChanged: (v) => settings.videoShowRawTimedText = v, diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index da2ffab34..22fd1363e 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -7,6 +7,7 @@ import 'package:aves/model/settings/home_page.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/services/analysis_service.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/global_search.dart'; import 'package:aves/services/viewer_service.dart'; @@ -110,10 +111,11 @@ class _HomePageState extends State { if (appMode != AppMode.view) { debugPrint('Storage check complete in ${stopwatch.elapsed.inMilliseconds}ms'); + unawaited(GlobalSearch.registerCallback()); + unawaited(AnalysisService.registerCallback()); final source = context.read(); await source.init(); unawaited(source.refresh()); - unawaited(GlobalSearch.registerCallback()); } // `pushReplacement` is not enough in some edge cases @@ -129,7 +131,7 @@ class _HomePageState extends State { final entry = await mediaFileService.getEntry(uri, mimeType); if (entry != null) { // cataloguing is essential for coordinates and video rotation - await entry.catalog(background: false, persist: false); + await entry.catalog(background: false, persist: false, force: false); } return entry; } diff --git a/lib/widgets/viewer/entry_vertical_pager.dart b/lib/widgets/viewer/entry_vertical_pager.dart index ff1b785d2..706076645 100644 --- a/lib/widgets/viewer/entry_vertical_pager.dart +++ b/lib/widgets/viewer/entry_vertical_pager.dart @@ -158,16 +158,18 @@ class _ViewerVerticalPageViewState extends State { } // when the entry changed (e.g. by scrolling through the PageView, or if the entry got deleted) - void _onEntryChanged() { + Future _onEntryChanged() async { _oldEntry?.imageChangeNotifier.removeListener(_onImageChanged); _oldEntry = entry; - if (entry != null) { - entry!.imageChangeNotifier.addListener(_onImageChanged); + final _entry = entry; + if (_entry != null) { + _entry.imageChangeNotifier.addListener(_onImageChanged); // make sure to locate the entry, // so that we can display the address instead of coordinates // even when initial collection locating has not reached this entry yet - entry!.catalog(background: false).then((_) => entry!.locate(background: false)); + await _entry.catalog(background: false, persist: true, force: false); + await _entry.locate(background: false, force: false); } else { Navigator.pop(context); } diff --git a/lib/widgets/viewer/info/entry_info_action_delegate.dart b/lib/widgets/viewer/info/entry_info_action_delegate.dart index b3185bbb8..e26002e90 100644 --- a/lib/widgets/viewer/info/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/info/entry_info_action_delegate.dart @@ -40,9 +40,9 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin { final success = await apply(); if (success) { if (_isMainMode(context) && source != null) { - await source.refreshMetadata({entry}); + await source.refreshEntry(entry); } else { - await entry.refresh(persist: false); + await entry.refresh(background: false, persist: false, force: true); } showFeedback(context, l10n.genericSuccessFeedback); } else { diff --git a/lib/widgets/viewer/video/fijkplayer.dart b/lib/widgets/viewer/video/fijkplayer.dart index e1ff849c3..7fce9b4a7 100644 --- a/lib/widgets/viewer/video/fijkplayer.dart +++ b/lib/widgets/viewer/video/fijkplayer.dart @@ -16,7 +16,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class IjkPlayerAvesVideoController extends AvesVideoController { - static bool _staticInitialized = false; late FijkPlayer _instance; final List _subscriptions = []; final StreamController _valueStreamController = StreamController.broadcast(); @@ -55,10 +54,6 @@ class IjkPlayerAvesVideoController extends AvesVideoController { static const gifLikeBitRateThreshold = 2 << 18; // 512kB/s (4Mb/s) IjkPlayerAvesVideoController(AvesEntry entry) : super(entry) { - if (!_staticInitialized) { - FijkLog.setLevel(FijkLogLevel.Warn); - _staticInitialized = true; - } _instance = FijkPlayer(); _valueStream.map((value) => value.videoRenderStart).firstWhere((v) => v, orElse: () => false).then( (started) => canCaptureFrameNotifier.value = started, diff --git a/lib/widgets/viewer/visual/vector.dart b/lib/widgets/viewer/visual/vector.dart index 0fcda1180..a2ae34d07 100644 --- a/lib/widgets/viewer/visual/vector.dart +++ b/lib/widgets/viewer/visual/vector.dart @@ -101,6 +101,8 @@ class _VectorImageViewState extends State { @override Widget build(BuildContext context) { + if (_displaySize == Size.zero) return widget.errorBuilder(context, 'Not sized', null); + return ValueListenableBuilder( valueListenable: viewStateNotifier, builder: (context, viewState, child) { diff --git a/test/fake/metadata_db.dart b/test/fake/metadata_db.dart index 2506f8813..b7f160fb4 100644 --- a/test/fake/metadata_db.dart +++ b/test/fake/metadata_db.dart @@ -39,6 +39,9 @@ class FakeMetadataDb extends Fake implements MetadataDb { @override Future> loadAddresses() => SynchronousFuture([]); + @override + Future saveAddresses(Set addresses) => SynchronousFuture(null); + @override Future updateAddressId(int oldId, AddressDetails? address) => SynchronousFuture(null); diff --git a/test/fake/metadata_fetch_service.dart b/test/fake/metadata_fetch_service.dart index 990918444..556a75bf1 100644 --- a/test/fake/metadata_fetch_service.dart +++ b/test/fake/metadata_fetch_service.dart @@ -5,6 +5,10 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; class FakeMetadataFetchService extends Fake implements MetadataFetchService { + final Map _metaMap = {}; + + void setUp(AvesEntry entry, CatalogMetadata metadata) => _metaMap[entry] = metadata; + @override - Future getCatalogMetadata(AvesEntry entry, {bool background = false}) => SynchronousFuture(null); + Future getCatalogMetadata(AvesEntry entry, {bool background = false}) => SynchronousFuture(_metaMap[entry]); } diff --git a/test/model/collection_source_test.dart b/test/model/collection_source_test.dart index 72fd17f64..2c8d6c9a7 100644 --- a/test/model/collection_source_test.dart +++ b/test/model/collection_source_test.dart @@ -4,6 +4,9 @@ import 'package:aves/model/availability.dart'; import 'package:aves/model/covers.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/tag.dart'; +import 'package:aves/model/metadata/address.dart'; +import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/enums.dart'; @@ -20,6 +23,7 @@ import 'package:aves/services/window_service.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; import 'package:path/path.dart' as p; import '../fake/android_app_service.dart'; @@ -38,6 +42,13 @@ void main() { const sourceAlbum = '${FakeStorageService.primaryPath}Pictures/source'; const destinationAlbum = '${FakeStorageService.primaryPath}Pictures/destination'; + const aTag = 'sometag'; + final australiaLatLng = LatLng(-26, 141); + const australiaAddress = AddressDetails( + countryCode: 'AU', + countryName: 'AUS', + ); + setUp(() async { // specify Posix style path context for consistent behaviour when running tests on Windows getIt.registerLazySingleton(() => p.Context(style: p.Style.posix)); @@ -53,7 +64,8 @@ void main() { getIt.registerLazySingleton(() => FakeStorageService()); getIt.registerLazySingleton(() => FakeWindowService()); - await settings.init(); + await settings.init(monitorPlatformSettings: false); + settings.canUseAnalysisService = false; }); tearDown(() async { @@ -74,6 +86,57 @@ void main() { return source; } + test('album/country/tag hidden on launch when their items are hidden by entry prop', () async { + settings.hiddenFilters = {const AlbumFilter(testAlbum, 'whatever')}; + + final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1'); + (mediaStoreService as FakeMediaStoreService).entries = { + image1, + }; + (metadataFetchService as FakeMetadataFetchService).setUp( + image1, + CatalogMetadata( + contentId: image1.contentId, + xmpSubjects: aTag, + latitude: australiaLatLng.latitude, + longitude: australiaLatLng.longitude, + ), + ); + + final source = await _initSource(); + expect(source.rawAlbums.length, 0); + expect(source.sortedCountries.length, 0); + expect(source.sortedTags.length, 0); + }); + + test('album/country/tag hidden on launch when their items are hidden by metadata', () async { + settings.hiddenFilters = {TagFilter(aTag)}; + + final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1'); + (mediaStoreService as FakeMediaStoreService).entries = { + image1, + }; + (metadataFetchService as FakeMetadataFetchService).setUp( + image1, + CatalogMetadata( + contentId: image1.contentId, + xmpSubjects: aTag, + latitude: australiaLatLng.latitude, + longitude: australiaLatLng.longitude, + ), + ); + expect(image1.xmpSubjects, []); + + final source = await _initSource(); + expect(image1.xmpSubjects, [aTag]); + expect(image1.addressDetails, australiaAddress.copyWith(contentId: image1.contentId)); + + expect(source.visibleEntries.length, 0); + expect(source.rawAlbums.length, 0); + expect(source.sortedCountries.length, 0); + expect(source.sortedTags.length, 0); + }); + test('add/remove favourite entry', () async { final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1'); (mediaStoreService as FakeMediaStoreService).entries = { diff --git a/test_driver/driver_app.dart b/test_driver/driver_app.dart index 3b2bf575c..735978f0c 100644 --- a/test_driver/driver_app.dart +++ b/test_driver/driver_app.dart @@ -26,7 +26,7 @@ void main() { } Future configureAndLaunch() async { - await settings.init(); + await settings.init(monitorPlatformSettings: false); settings ..keepScreenOn = KeepScreenOn.always ..hasAcceptedTerms = false