foreground service to scan many items

This commit is contained in:
Thibault Deckers 2021-10-17 16:00:13 +09:00
parent 6921e8dd11
commit 5db804c0e7
47 changed files with 1090 additions and 282 deletions

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8" ?>
<resources>
<string name="app_name">아베스 [Debug]</string>
<string name="app_name">아베스 [Debug]</string>
</resources>

View file

@ -16,6 +16,7 @@
https://developer.android.com/preview/privacy/storage#media-files-raw-paths
-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<!-- request write permission until Q (29) included, because scoped storage is unusable -->
<uses-permission
@ -122,6 +123,10 @@
android:name="android.app.searchable"
android:resource="@xml/searchable" />
</activity>
<service
android:name=".AnalysisService"
android:description="@string/analysis_service_description"
android:exported="false" />
<!-- file provider to share files having a file:// URI -->
<provider

View file

@ -0,0 +1,237 @@
package deckers.thibault.aves
import android.app.Notification
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.*
import android.util.Log
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.MainActivity.Companion.OPEN_FROM_ANALYSIS_SERVICE
import deckers.thibault.aves.channel.calls.DeviceHandler
import deckers.thibault.aves.channel.calls.GeocodingHandler
import deckers.thibault.aves.channel.calls.MediaStoreHandler
import deckers.thibault.aves.channel.calls.MetadataFetchHandler
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler
import deckers.thibault.aves.utils.ContextUtils.runOnUiThread
import deckers.thibault.aves.utils.FlutterUtils
import deckers.thibault.aves.utils.LogUtils
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.runBlocking
import java.util.*
class AnalysisService : MethodChannel.MethodCallHandler, Service() {
private var backgroundFlutterEngine: FlutterEngine? = null
private var backgroundChannel: MethodChannel? = null
private var serviceLooper: Looper? = null
private var serviceHandler: ServiceHandler? = null
private val analysisServiceBinder = AnalysisServiceBinder()
override fun onCreate() {
Log.i(LOG_TAG, "Create analysis service")
val context = this
runBlocking {
FlutterUtils.initFlutterEngine(context, SHARED_PREFERENCES_KEY, CALLBACK_HANDLE_KEY) {
backgroundFlutterEngine = it
}
}
val messenger = backgroundFlutterEngine!!.dartExecutor.binaryMessenger
// channels for analysis
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler())
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> 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<String>("title")
val message = call.argument<String>("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<AnalysisService>()
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<AnalysisServiceListener>()
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<AnalysisServiceBinder>()
}
}
interface AnalysisServiceListener {
fun refreshApp()
fun detachFromActivity()
}

View file

@ -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<String, Any?>
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<MainActivity>()
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<Int, PendingStorageAccessResultHandler>()

View file

@ -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<FieldMap> {
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<Boolean> { 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()
}
}
}

View file

@ -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<Number>("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<AnalysisHandler>()
const val CHANNEL = "deckers.thibault/aves/analysis"
}
}

View file

@ -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<Number>("callbackHandle")?.toLong()
if (callbackHandle == null) {
@ -36,7 +35,6 @@ class GlobalSearchHandler(private val context: Activity) : MethodCallHandler {
}
companion object {
private val LOG_TAG = LogUtils.createTag<GlobalSearchHandler>()
const val CHANNEL = "deckers.thibault/aves/global_search"
}
}

View file

@ -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<String>("path")
val mimeType = call.argument<String>("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 {

View file

@ -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"
}
}

View file

@ -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)
}
}

View file

@ -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<Metadata>()
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)

View file

@ -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<Boolean> { cont ->
Handler(mainLooper).post {
r.run()
cont.resume(true)
}
}
} else {
r.run()
}
}
fun Context.isMyServiceRunning(serviceClass: Class<out Service>): 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 }
}
}

View file

@ -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<FlutterUtils>()
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)
}
}
}

View file

@ -0,0 +1,26 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="100dp"
android:height="100dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="M3.925,16.034 L60.825,72.933a2.421,2.421 0.001,0 0,3.423 0l10.604,-10.603a6.789,6.789 90.001,0 0,0 -9.601L34.066,11.942A8.264,8.264 22.5,0 0,28.222 9.522H6.623A3.815,3.815 112.5,0 0,3.925 16.034Z"
android:strokeWidth="5"
android:strokeColor="#000000"
android:strokeLineJoin="round" />
<path
android:pathData="m36.36,65.907v28.743a2.557,2.557 22.5,0 0,4.364 1.808L53.817,83.364a6.172,6.172 90,0 0,0 -8.729L42.532,63.35a3.616,3.616 157.5,0 0,-6.172 2.557z"
android:strokeWidth="5"
android:strokeColor="#000000"
android:strokeLineJoin="round" />
<path
android:pathData="M79.653,40.078V11.335A2.557,2.557 22.5,0 0,75.289 9.527L62.195,22.62a6.172,6.172 90,0 0,0 8.729l11.285,11.285a3.616,3.616 157.5,0 0,6.172 -2.557z"
android:strokeWidth="5"
android:strokeColor="#000000"
android:strokeLineJoin="round" />
<path
android:pathData="M96.613,16.867 L89.085,9.339a1.917,1.917 157.5,0 0,-3.273 1.356v6.172a4.629,4.629 45,0 0,4.629 4.629h4.255a2.712,2.712 112.5,0 0,1.917 -4.629z"
android:strokeWidth="5"
android:strokeColor="#000000"
android:strokeLineJoin="round" />
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M16,8v8H8V8h8m2,-2H6v12h12V6z"/>
</vector>

View file

@ -1,6 +1,10 @@
<?xml version="1.0" encoding="UTF-8" ?>
<resources>
<string name="app_name">아베스</string>
<string name="search_shortcut_short_label">검색</string>
<string name="videos_shortcut_short_label">동영상</string>
<string name="app_name">아베스</string>
<string name="search_shortcut_short_label">검색</string>
<string name="videos_shortcut_short_label">동영상</string>
<string name="analysis_channel_name">미디어 분석</string>
<string name="analysis_service_description">사진과 동영상 분석</string>
<string name="analysis_notification_default_title">미디어 분석</string>
<string name="analysis_notification_action_stop">취소</string>
</resources>

View file

@ -3,4 +3,8 @@
<string name="app_name">Aves</string>
<string name="search_shortcut_short_label">Search</string>
<string name="videos_shortcut_short_label">Videos</string>
<string name="analysis_channel_name">Media scan</string>
<string name="analysis_service_description">Scan images &amp; videos</string>
<string name="analysis_notification_default_title">Scanning media</string>
<string name="analysis_notification_action_stop">Stop</string>
</resources>

View file

@ -62,8 +62,10 @@
"@sourceStateLoading": {},
"sourceStateCataloguing": "Cataloguing",
"@sourceStateCataloguing": {},
"sourceStateLocating": "Locating",
"@sourceStateLocating": {},
"sourceStateLocatingCountries": "Locating countries",
"@sourceStateLocatingCountries": {},
"sourceStateLocatingPlaces": "Locating places",
"@sourceStateLocatingPlaces": {},
"chipActionDelete": "Delete",
"@chipActionDelete": {},

View file

@ -27,7 +27,8 @@
"sourceStateLoading": "로딩 중",
"sourceStateCataloguing": "분석 중",
"sourceStateLocating": "장소 찾는 중",
"sourceStateLocatingCountries": "국가 찾는 중",
"sourceStateLocatingPlaces": "장소 찾는 중",
"chipActionDelete": "삭제",
"chipActionGoToAlbumPage": "앨범 페이지에서 보기",

View file

@ -434,17 +434,18 @@ class AvesEntry {
addressDetails = null;
}
Future<void> catalog({bool background = false, bool persist = true, bool force = false}) async {
Future<void> 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<void> locate({required bool background}) async {
Future<void> 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<void> _locateCountry() async {
if (!hasGps || hasAddress) return;
Future<void> _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<void> locatePlace({required bool background}) async {
if (!hasGps || hasFineAddress) return;
Future<void> locatePlace({required bool background, required bool force}) async {
if (!hasGps || (hasFineAddress && !force)) return;
try {
Future<List<Address>> call() => GeocodingService.getAddress(latLng!, geocoderLocale);
final addresses = await (background
@ -564,6 +565,10 @@ class AvesEntry {
}.any((s) => s != null && s.toUpperCase().contains(query));
Future<void> _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<void> refresh({required bool persist}) async {
Future<void> 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;
}

View file

@ -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<Object?> 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}';
}

View file

@ -170,7 +170,6 @@ class SqfliteMetadataDb implements MetadataDb {
Future<void> removeIds(Set<int> 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<Set<AvesEntry>> 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;
}

View file

@ -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;

View file

@ -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<String> 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<void> 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<void> 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);

View file

@ -0,0 +1,14 @@
import 'package:flutter/foundation.dart';
class AnalysisController {
final bool canStartService, force;
final ValueNotifier<bool> stopSignal;
AnalysisController({
this.canStartService = true,
this.force = false,
ValueNotifier<bool>? stopSignal,
}) : stopSignal = stopSignal ?? ValueNotifier(false);
bool get isStopping => stopSignal.value;
}

View file

@ -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<SourceState> stateNotifier = ValueNotifier(SourceState.ready);
final StreamController<ProgressEvent> _progressStreamController = StreamController.broadcast();
ValueNotifier<ProgressEvent> progressNotifier = ValueNotifier(const ProgressEvent(done: 0, total: 0));
Stream<ProgressEvent> 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<AvesEntry>? 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<AvesEntry> 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<void> init();
Future<void> refresh();
Future<void> refresh({AnalysisController? analysisController});
Future<Set<String>> refreshUris(Set<String> changedUris);
Future<Set<String>> refreshUris(Set<String> changedUris, {AnalysisController? analysisController});
Future<void> rescan(Set<AvesEntry> entries);
Future<void> refreshEntry(AvesEntry entry) async {
await entry.refresh(background: false, persist: true, force: true);
updateDerivedFilters({entry});
eventBus.fire(EntryRefreshedEvent({entry}));
}
Future<void> refreshMetadata(Set<AvesEntry> entries) async {
await Future.forEach<AvesEntry>(entries, (entry) => entry.refresh(persist: true));
_invalidate(entries);
updateLocations();
updateTags();
eventBus.fire(EntryRefreshedEvent(entries));
Future<void> analyze(AnalysisController? analysisController, Set<AvesEntry> 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<AvesEntry>? entries;
const EntryAddedEvent([this.entries]);
}
@immutable
class EntryRemovedEvent {
final Set<AvesEntry> entries;
const EntryRemovedEvent(this.entries);
}
@immutable
class EntryMovedEvent {
final Set<AvesEntry> entries;
const EntryMovedEvent(this.entries);
}
@immutable
class EntryRefreshedEvent {
final Set<AvesEntry> entries;
const EntryRefreshedEvent(this.entries);
}
@immutable
class FilterVisibilityChangedEvent {
final Set<CollectionFilter> filters;
final bool visible;
@ -359,6 +388,7 @@ class FilterVisibilityChangedEvent {
const FilterVisibilityChangedEvent(this.filters, this.visible);
}
@immutable
class ProgressEvent {
final int done, total;

View file

@ -1,4 +1,4 @@
enum SourceState { loading, cataloguing, locating, ready }
enum SourceState { loading, cataloguing, locatingCountries, locatingPlaces, ready }
enum ChipSortFactor { date, name, count }

View file

@ -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<String> sortedCountries = List.unmodifiable([]);
List<String> sortedPlaces = List.unmodifiable([]);
Future<void> 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<void> locateEntries() async {
await _locateCountries();
await _locatePlaces();
Future<void> locateEntries(AnalysisController controller, Set<AvesEntry> 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<void> _locateCountries() async {
final todo = visibleEntries.where((entry) => entry.hasGps && !entry.hasAddress).toSet();
Future<void> _locateCountries(AnalysisController controller, Set<AvesEntry> 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 = <AddressDetails>[];
final newAddresses = <AddressDetails>{};
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<void> _locatePlaces() async {
Future<void> _locatePlaces(AnalysisController controller, Set<AvesEntry> candidateEntries) async {
if (controller.isStopping) return;
if (!(await availability.canLocatePlaces)) return;
// final stopwatch = Stopwatch()..start();
final byLocated = groupBy<AvesEntry, bool>(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<int, int> 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<int, int>((lat * latLngFactor).round(), (lng * latLngFactor).round());
}
final located = visibleEntries.where((entry) => entry.hasGps).toSet().difference(todo);
final knownLocations = <Tuple2<int, int>, 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 = <AddressDetails>[];
await Future.forEach<AvesEntry>(todo, (entry) async {
var stopCheckCount = 0;
final newAddresses = <AddressDetails>{};
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() {

View file

@ -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<void> refresh() async {
Future<void> 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<Set<String>> refreshUris(Set<String> changedUris) async {
Future<Set<String>> refreshUris(Set<String> 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<void> rescan(Set<AvesEntry> entries) async {
final contentIds = entries.map((entry) => entry.contentId).whereNotNull().toSet();
await metadataDb.removeIds(contentIds, metadataOnly: true);
return refresh();
}
}

View file

@ -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;
}
}
}

View file

@ -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<String> sortedTags = List.unmodifiable([]);
Future<void> 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<void> catalogEntries() async {
// final stopwatch = Stopwatch()..start();
final todo = visibleEntries.where((entry) => !entry.isCatalogued).toList();
static bool catalogEntriesTest(AvesEntry entry) => !entry.isCatalogued;
Future<void> catalogEntries(AnalysisController controller, Set<AvesEntry> 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 = <CatalogMetadata>[];
await Future.forEach<AvesEntry>(todo, (entry) async {
await entry.catalog(background: true);
var stopCheckCount = 0;
final newMetadata = <CatalogMetadata>{};
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() {

View file

@ -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<void> registerCallback() async {
try {
await platform.invokeMethod('registerCallback', <String, dynamic>{
'callbackHandle': PluginUtilities.getCallbackHandle(_init)?.toRawHandle(),
});
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
static Future<void> 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<void> _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<AnalyzerState> _serviceStateNotifier = ValueNotifier<AnalyzerState>(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<void> 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<void> _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<void> _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', <String, dynamic>{
'title': title,
'message': progressive ? '${progress.done}/${progress.total}' : null,
});
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
Future<void> _refreshApp() async {
try {
await _channel.invokeMethod('refreshApp');
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
Future<void> _stopPlatformService() async {
try {
await _channel.invokeMethod('stop');
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
}

View file

@ -21,7 +21,9 @@ class GeocodingService {
});
return (result as List).cast<Map>().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 [];
}

View file

@ -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<AvesApp> {
List<NavigatorObserver> _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<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator');
@ -58,6 +60,7 @@ class _AvesAppState extends State<AvesApp> {
_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<AvesApp> {
Future<void> _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<AvesApp> {
));
}
Future<void> _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) {

View file

@ -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<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection);
source.rescan(selectedItems);
final controller = AnalysisController(canStartService: false, force: true);
source.analyze(controller, selectedItems);
selection.browse();
}

View file

@ -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<ProgressEvent>(
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<ProgressEvent>(
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),
),
],
);
);
},
),
],
);
}
}

View file

@ -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<AppDebugPage> {
},
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),

View file

@ -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,

View file

@ -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<HomePage> {
if (appMode != AppMode.view) {
debugPrint('Storage check complete in ${stopwatch.elapsed.inMilliseconds}ms');
unawaited(GlobalSearch.registerCallback());
unawaited(AnalysisService.registerCallback());
final source = context.read<CollectionSource>();
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<HomePage> {
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;
}

View file

@ -158,16 +158,18 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
}
// when the entry changed (e.g. by scrolling through the PageView, or if the entry got deleted)
void _onEntryChanged() {
Future<void> _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);
}

View file

@ -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 {

View file

@ -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<StreamSubscription> _subscriptions = [];
final StreamController<FijkValue> _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,

View file

@ -101,6 +101,8 @@ class _VectorImageViewState extends State<VectorImageView> {
@override
Widget build(BuildContext context) {
if (_displaySize == Size.zero) return widget.errorBuilder(context, 'Not sized', null);
return ValueListenableBuilder<ViewState>(
valueListenable: viewStateNotifier,
builder: (context, viewState, child) {

View file

@ -39,6 +39,9 @@ class FakeMetadataDb extends Fake implements MetadataDb {
@override
Future<List<AddressDetails>> loadAddresses() => SynchronousFuture([]);
@override
Future<void> saveAddresses(Set<AddressDetails> addresses) => SynchronousFuture(null);
@override
Future<void> updateAddressId(int oldId, AddressDetails? address) => SynchronousFuture(null);

View file

@ -5,6 +5,10 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
class FakeMetadataFetchService extends Fake implements MetadataFetchService {
final Map<AvesEntry, CatalogMetadata> _metaMap = {};
void setUp(AvesEntry entry, CatalogMetadata metadata) => _metaMap[entry] = metadata;
@override
Future<CatalogMetadata?> getCatalogMetadata(AvesEntry entry, {bool background = false}) => SynchronousFuture(null);
Future<CatalogMetadata?> getCatalogMetadata(AvesEntry entry, {bool background = false}) => SynchronousFuture(_metaMap[entry]);
}

View file

@ -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>(() => p.Context(style: p.Style.posix));
@ -53,7 +64,8 @@ void main() {
getIt.registerLazySingleton<StorageService>(() => FakeStorageService());
getIt.registerLazySingleton<WindowService>(() => 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 = {

View file

@ -26,7 +26,7 @@ void main() {
}
Future<void> configureAndLaunch() async {
await settings.init();
await settings.init(monitorPlatformSettings: false);
settings
..keepScreenOn = KeepScreenOn.always
..hasAcceptedTerms = false