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