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" ?> <?xml version="1.0" encoding="UTF-8" ?>
<resources> <resources>
<string name="app_name">아베스 [Debug]</string> <string name="app_name">아베스 [Debug]</string>
</resources> </resources>

View file

@ -16,6 +16,7 @@
https://developer.android.com/preview/privacy/storage#media-files-raw-paths 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" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<!-- request write permission until Q (29) included, because scoped storage is unusable --> <!-- request write permission until Q (29) included, because scoped storage is unusable -->
<uses-permission <uses-permission
@ -122,6 +123,10 @@
android:name="android.app.searchable" android:name="android.app.searchable"
android:resource="@xml/searchable" /> android:resource="@xml/searchable" />
</activity> </activity>
<service
android:name=".AnalysisService"
android:description="@string/analysis_service_description"
android:exported="false" />
<!-- file provider to share files having a file:// URI --> <!-- file provider to share files having a file:// URI -->
<provider <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 mediaStoreChangeStreamHandler: MediaStoreChangeStreamHandler
private lateinit var settingsChangeStreamHandler: SettingsChangeStreamHandler private lateinit var settingsChangeStreamHandler: SettingsChangeStreamHandler
private lateinit var intentStreamHandler: IntentStreamHandler private lateinit var intentStreamHandler: IntentStreamHandler
private lateinit var analysisStreamHandler: AnalysisStreamHandler
private lateinit var intentDataMap: MutableMap<String, Any?> private lateinit var intentDataMap: MutableMap<String, Any?>
private lateinit var analysisHandler: AnalysisHandler
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
Log.i(LOG_TAG, "onCreate intent=$intent") Log.i(LOG_TAG, "onCreate intent=$intent")
@ -52,24 +54,30 @@ class MainActivity : FlutterActivity() {
val messenger = flutterEngine!!.dartExecutor.binaryMessenger val messenger = flutterEngine!!.dartExecutor.binaryMessenger
// dart -> platform -> dart // 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, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this)) MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler()) MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler())
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this)) MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this)) MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this)) MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this))
MethodChannel(messenger, MediaFileHandler.CHANNEL).setMethodCallHandler(MediaFileHandler(this))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this)) MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
MethodChannel(messenger, MetadataEditHandler.CHANNEL).setMethodCallHandler(MetadataEditHandler(this))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this)) MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(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)) MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(WindowHandler(this))
// result streaming: dart -> platform ->->-> dart // result streaming: dart -> platform ->->-> dart
// - need Context
StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) } 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) } 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) } StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL).setStreamHandlerFactory { args -> StorageAccessStreamHandler(this, args) }
// change monitoring: platform -> dart // 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 // notification: platform -> dart
errorStreamHandler = ErrorStreamHandler().apply { errorStreamHandler = ErrorStreamHandler().apply {
EventChannel(messenger, ErrorStreamHandler.CHANNEL).setStreamHandler(this) 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() { override fun onDestroy() {
Log.i(LOG_TAG, "onDestroy")
mediaStoreChangeStreamHandler.dispose() mediaStoreChangeStreamHandler.dispose()
settingsChangeStreamHandler.dispose() settingsChangeStreamHandler.dispose()
super.onDestroy() super.onDestroy()
@ -252,6 +278,10 @@ class MainActivity : FlutterActivity() {
ShortcutManagerCompat.setDynamicShortcuts(this, listOf(videos, search)) ShortcutManagerCompat.setDynamicShortcuts(this, listOf(videos, search))
} }
private fun onAnalysisCompleted() {
analysisStreamHandler.notifyCompletion()
}
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<MainActivity>() private val LOG_TAG = LogUtils.createTag<MainActivity>()
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer" const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
@ -261,6 +291,7 @@ class MainActivity : FlutterActivity() {
const val CREATE_FILE_REQUEST = 3 const val CREATE_FILE_REQUEST = 3
const val OPEN_FILE_REQUEST = 4 const val OPEN_FILE_REQUEST = 4
const val SELECT_DIRECTORY_REQUEST = 5 const val SELECT_DIRECTORY_REQUEST = 5
const val OPEN_FROM_ANALYSIS_SERVICE = 6
// request code to pending runnable // request code to pending runnable
val pendingStorageAccessResultHandlers = ConcurrentHashMap<Int, PendingStorageAccessResultHandler>() val pendingStorageAccessResultHandlers = ConcurrentHashMap<Int, PendingStorageAccessResultHandler>()

View file

@ -2,24 +2,21 @@ package deckers.thibault.aves
import android.app.SearchManager import android.app.SearchManager
import android.content.ContentProvider import android.content.ContentProvider
import android.content.ContentResolver
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import android.database.MatrixCursor import android.database.MatrixCursor
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Handler
import android.util.Log import android.util.Log
import deckers.thibault.aves.model.FieldMap 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 deckers.thibault.aves.utils.LogUtils
import io.flutter.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine 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.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.view.FlutterCallbackInformation
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -71,7 +68,9 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid
private suspend fun getSuggestions(context: Context, query: String): List<FieldMap> { private suspend fun getSuggestions(context: Context, query: String): List<FieldMap> {
if (backgroundFlutterEngine == null) { if (backgroundFlutterEngine == null) {
initFlutterEngine(context) FlutterUtils.initFlutterEngine(context, SHARED_PREFERENCES_KEY, CALLBACK_HANDLE_KEY) {
backgroundFlutterEngine = it
}
} }
val messenger = backgroundFlutterEngine!!.dartExecutor.binaryMessenger val messenger = backgroundFlutterEngine!!.dartExecutor.binaryMessenger
@ -133,60 +132,5 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid
const val CALLBACK_HANDLE_KEY = "callback_handle" const val CALLBACK_HANDLE_KEY = "callback_handle"
private var backgroundFlutterEngine: FlutterEngine? = null 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 package deckers.thibault.aves.channel.calls
import android.app.Activity import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.util.Log
import deckers.thibault.aves.SearchSuggestionsProvider import deckers.thibault.aves.SearchSuggestionsProvider
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe 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.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
@ -13,7 +11,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch 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) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"registerCallback" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::registerCallback) } "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) { private fun registerCallback(call: MethodCall, result: MethodChannel.Result) {
val callbackHandle = call.argument<Number>("callbackHandle")?.toLong() val callbackHandle = call.argument<Number>("callbackHandle")?.toLong()
if (callbackHandle == null) { if (callbackHandle == null) {
@ -36,7 +35,6 @@ class GlobalSearchHandler(private val context: Activity) : MethodCallHandler {
} }
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<GlobalSearchHandler>()
const val CHANNEL = "deckers.thibault/aves/global_search" const val CHANNEL = "deckers.thibault/aves/global_search"
} }
} }

View file

@ -1,6 +1,6 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.app.Activity import android.content.Context
import android.media.MediaScannerConnection import android.media.MediaScannerConnection
import android.net.Uri import android.net.Uri
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
@ -12,7 +12,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch 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) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"checkObsoleteContentIds" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::checkObsoleteContentIds) } "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) result.error("checkObsoleteContentIds-args", "failed because of missing arguments", null)
return return
} }
result.success(MediaStoreImageProvider().checkObsoleteContentIds(activity, knownContentIds)) result.success(MediaStoreImageProvider().checkObsoleteContentIds(context, knownContentIds))
} }
private fun checkObsoletePaths(call: MethodCall, result: MethodChannel.Result) { 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) result.error("checkObsoletePaths-args", "failed because of missing arguments", null)
return return
} }
result.success(MediaStoreImageProvider().checkObsoletePaths(activity, knownPathById)) result.success(MediaStoreImageProvider().checkObsoletePaths(context, knownPathById))
} }
private fun scanFile(call: MethodCall, result: MethodChannel.Result) { private fun scanFile(call: MethodCall, result: MethodChannel.Result) {
val path = call.argument<String>("path") val path = call.argument<String>("path")
val mimeType = call.argument<String>("mimeType") 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 { 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 package deckers.thibault.aves.channel.streams
import android.app.Activity import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper 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.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes 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.canDecodeWithFlutter
import deckers.thibault.aves.utils.MimeTypes.isHeic
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
@ -26,10 +26,9 @@ import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.IOException
import java.io.InputStream 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 eventSink: EventSink
private lateinit var handler: Handler 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) { private fun streamImageAsIs(uri: Uri, mimeType: String) {
try { try {
StorageUtils.openInputStream(activity, uri)?.use { input -> streamBytes(input) } StorageUtils.openInputStream(context, uri)?.use { input -> streamBytes(input) }
} catch (e: Exception) { } catch (e: Exception) {
error("streamImage-image-read-exception", "failed to get image for mimeType=$mimeType uri=$uri", e.message) 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) { private suspend fun streamImageByGlide(uri: Uri, pageId: Int?, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) {
val model: Any = if (isHeic(mimeType) && pageId != null) { val model: Any = if (isHeic(mimeType) && pageId != null) {
MultiTrackImage(activity, uri, pageId) MultiTrackImage(context, uri, pageId)
} else if (mimeType == MimeTypes.TIFF) { } else if (mimeType == MimeTypes.TIFF) {
TiffImage(activity, uri, pageId) TiffImage(context, uri, pageId)
} else { } else {
StorageUtils.getGlideSafeUri(uri, mimeType) StorageUtils.getGlideSafeUri(uri, mimeType)
} }
val target = Glide.with(activity) val target = Glide.with(context)
.asBitmap() .asBitmap()
.apply(glideOptions) .apply(glideOptions)
.load(model) .load(model)
@ -132,7 +131,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
var bitmap = target.get() var bitmap = target.get()
if (needRotationAfterGlide(mimeType)) { if (needRotationAfterGlide(mimeType)) {
bitmap = applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped) bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
} }
if (bitmap != null) { if (bitmap != null) {
success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false)) success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false))
@ -142,15 +141,15 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
} catch (e: Exception) { } catch (e: Exception) {
error("streamImage-image-decode-exception", "failed to get image for mimeType=$mimeType uri=$uri model=$model", toErrorDetails(e)) error("streamImage-image-decode-exception", "failed to get image for mimeType=$mimeType uri=$uri model=$model", toErrorDetails(e))
} finally { } finally {
Glide.with(activity).clear(target) Glide.with(context).clear(target)
} }
} }
private suspend fun streamVideoByGlide(uri: Uri, mimeType: String) { private suspend fun streamVideoByGlide(uri: Uri, mimeType: String) {
val target = Glide.with(activity) val target = Glide.with(context)
.asBitmap() .asBitmap()
.apply(glideOptions) .apply(glideOptions)
.load(VideoThumbnail(activity, uri)) .load(VideoThumbnail(context, uri))
.submit() .submit()
try { try {
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
@ -163,7 +162,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
} catch (e: Exception) { } catch (e: Exception) {
error("streamImage-video-exception", "failed to get image for mimeType=$mimeType uri=$uri", e.message) error("streamImage-video-exception", "failed to get image for mimeType=$mimeType uri=$uri", e.message)
} finally { } 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.content.Context
import android.net.Uri import android.net.Uri
import android.util.Log
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
import java.io.File import java.io.File
@ -15,8 +13,6 @@ import java.util.*
import java.util.regex.Pattern import java.util.regex.Pattern
object Metadata { object Metadata {
private val LOG_TAG = LogUtils.createTag<Metadata>()
const val IPTC_MARKER_BYTE: Byte = 0x1c const val IPTC_MARKER_BYTE: Byte = 0x1c
// Pattern to extract latitude & longitude from a video location tag (cf ISO 6709) // Pattern to extract latitude & longitude from a video location tag (cf ISO 6709)
@ -138,7 +134,6 @@ object Metadata {
} else { } else {
// make a preview from the beginning of the file, // make a preview from the beginning of the file,
// hoping the metadata is accessible in the copied chunk // 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] var previewFile = previewFiles[uri]
if (previewFile == null) { if (previewFile == null) {
previewFile = createPreviewFile(context, uri) 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" ?> <?xml version="1.0" encoding="UTF-8" ?>
<resources> <resources>
<string name="app_name">아베스</string> <string name="app_name">아베스</string>
<string name="search_shortcut_short_label">검색</string> <string name="search_shortcut_short_label">검색</string>
<string name="videos_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> </resources>

View file

@ -3,4 +3,8 @@
<string name="app_name">Aves</string> <string name="app_name">Aves</string>
<string name="search_shortcut_short_label">Search</string> <string name="search_shortcut_short_label">Search</string>
<string name="videos_shortcut_short_label">Videos</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> </resources>

View file

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

View file

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

View file

@ -434,17 +434,18 @@ class AvesEntry {
addressDetails = null; 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 (isCatalogued && !force) return;
if (isSvg) { if (isSvg) {
// vector image sizing is not essential, so we should not spend time for it during loading // 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 // but it is useful anyway (for aspect ratios etc.) so we size them during cataloguing
final size = await SvgMetadataService.getSize(this); final size = await SvgMetadataService.getSize(this);
if (size != null) { if (size != null) {
await _applyNewFields({ final fields = {
'width': size.width.ceil(), 'width': size.width.ceil(),
'height': size.height.ceil(), 'height': size.height.ceil(),
}, persist: persist); };
await _applyNewFields(fields, persist: persist);
} }
catalogMetadata = CatalogMetadata(contentId: contentId); catalogMetadata = CatalogMetadata(contentId: contentId);
} else { } else {
@ -468,17 +469,17 @@ class AvesEntry {
addressChangeNotifier.notifyListeners(); addressChangeNotifier.notifyListeners();
} }
Future<void> locate({required bool background}) async { Future<void> locate({required bool background, required bool force}) async {
if (!hasGps) return; if (!hasGps) return;
await _locateCountry(); await _locateCountry(force: force);
if (await availability.canLocatePlaces) { 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 // quick reverse geocoding to find the country, using an offline asset
Future<void> _locateCountry() async { Future<void> _locateCountry({required bool force}) async {
if (!hasGps || hasAddress) return; if (!hasGps || (hasAddress && !force)) return;
final countryCode = await countryTopology.countryCode(latLng!); final countryCode = await countryTopology.countryCode(latLng!);
setCountry(countryCode); setCountry(countryCode);
} }
@ -500,8 +501,8 @@ class AvesEntry {
} }
// full reverse geocoding, requiring Play Services and some connectivity // full reverse geocoding, requiring Play Services and some connectivity
Future<void> locatePlace({required bool background}) async { Future<void> locatePlace({required bool background, required bool force}) async {
if (!hasGps || hasFineAddress) return; if (!hasGps || (hasFineAddress && !force)) return;
try { try {
Future<List<Address>> call() => GeocodingService.getAddress(latLng!, geocoderLocale); Future<List<Address>> call() => GeocodingService.getAddress(latLng!, geocoderLocale);
final addresses = await (background final addresses = await (background
@ -564,6 +565,10 @@ class AvesEntry {
}.any((s) => s != null && s.toUpperCase().contains(query)); }.any((s) => s != null && s.toUpperCase().contains(query));
Future<void> _applyNewFields(Map newFields, {required bool persist}) async { 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']; final uri = newFields['uri'];
if (uri is String) this.uri = uri; if (uri is String) this.uri = uri;
final path = newFields['path']; final path = newFields['path'];
@ -599,10 +604,11 @@ class AvesEntry {
if (catalogMetadata != null) await metadataDb.saveMetadata({catalogMetadata!}); if (catalogMetadata != null) await metadataDb.saveMetadata({catalogMetadata!});
} }
await _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
metadataChangeNotifier.notifyListeners(); metadataChangeNotifier.notifyListeners();
} }
Future<void> refresh({required bool persist}) async { Future<void> refresh({required bool background, required bool persist, required bool force}) async {
_catalogMetadata = null; _catalogMetadata = null;
_addressDetails = null; _addressDetails = null;
_bestDate = null; _bestDate = null;
@ -614,13 +620,9 @@ class AvesEntry {
final updated = await mediaFileService.getEntry(uri, mimeType); final updated = await mediaFileService.getEntry(uri, mimeType);
if (updated != null) { if (updated != null) {
final oldDateModifiedSecs = dateModifiedSecs;
final oldRotationDegrees = rotationDegrees;
final oldIsFlipped = isFlipped;
await _applyNewFields(updated.toMap(), persist: persist); await _applyNewFields(updated.toMap(), persist: persist);
await catalog(background: false, persist: persist); await catalog(background: background, persist: persist, force: force);
await locate(background: false); await locate(background: background, force: force);
await _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
} }
} }
@ -628,11 +630,7 @@ class AvesEntry {
final newFields = await metadataEditService.rotate(this, clockwise: clockwise); final newFields = await metadataEditService.rotate(this, clockwise: clockwise);
if (newFields.isEmpty) return false; if (newFields.isEmpty) return false;
final oldDateModifiedSecs = dateModifiedSecs;
final oldRotationDegrees = rotationDegrees;
final oldIsFlipped = isFlipped;
await _applyNewFields(newFields, persist: persist); await _applyNewFields(newFields, persist: persist);
await _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
return true; return true;
} }
@ -640,11 +638,7 @@ class AvesEntry {
final newFields = await metadataEditService.flip(this); final newFields = await metadataEditService.flip(this);
if (newFields.isEmpty) return false; if (newFields.isEmpty) return false;
final oldDateModifiedSecs = dateModifiedSecs;
final oldRotationDegrees = rotationDegrees;
final oldIsFlipped = isFlipped;
await _applyNewFields(newFields, persist: persist); await _applyNewFields(newFields, persist: persist);
await _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
return true; return true;
} }

View file

@ -1,13 +1,17 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@immutable @immutable
class AddressDetails { class AddressDetails extends Equatable {
final int? contentId; final int? contentId;
final String? countryCode, countryName, adminArea, locality; final String? countryCode, countryName, adminArea, locality;
String? get place => locality != null && locality!.isNotEmpty ? locality : adminArea; String? get place => locality != null && locality!.isNotEmpty ? locality : adminArea;
@override
List<Object?> get props => [contentId, countryCode, countryName, adminArea, locality];
const AddressDetails({ const AddressDetails({
this.contentId, this.contentId,
this.countryCode, this.countryCode,
@ -45,7 +49,4 @@ class AddressDetails {
'adminArea': adminArea, 'adminArea': adminArea,
'locality': locality, '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 { Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly}) async {
if (contentIds.isEmpty) return; if (contentIds.isEmpty) return;
// final stopwatch = Stopwatch()..start();
final db = await _database; final db = await _database;
// using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead // using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead
final batch = db.batch(); final batch = db.batch();
@ -187,7 +186,6 @@ class SqfliteMetadataDb implements MetadataDb {
} }
}); });
await batch.commit(noResult: true); await batch.commit(noResult: true);
// debugPrint('$runtimeType removeIds complete in ${stopwatch.elapsed.inMilliseconds}ms for ${contentIds.length} entries');
} }
// entries // entries
@ -201,11 +199,9 @@ class SqfliteMetadataDb implements MetadataDb {
@override @override
Future<Set<AvesEntry>> loadEntries() async { Future<Set<AvesEntry>> loadEntries() async {
// final stopwatch = Stopwatch()..start();
final db = await _database; final db = await _database;
final maps = await db.query(entryTable); final maps = await db.query(entryTable);
final entries = maps.map((map) => AvesEntry.fromMap(map)).toSet(); final entries = maps.map((map) => AvesEntry.fromMap(map)).toSet();
// debugPrint('$runtimeType loadEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries');
return entries; return entries;
} }

View file

@ -13,6 +13,7 @@ import 'package:flutter/material.dart';
class SettingsDefaults { class SettingsDefaults {
// app // app
static const hasAcceptedTerms = false; static const hasAcceptedTerms = false;
static const canUseAnalysisService = true;
static const isErrorReportingEnabled = false; static const isErrorReportingEnabled = false;
static const mustBackTwiceToExit = true; static const mustBackTwiceToExit = true;
static const keepScreenOn = KeepScreenOn.viewerOnly; static const keepScreenOn = KeepScreenOn.viewerOnly;

View file

@ -27,9 +27,7 @@ class Settings extends ChangeNotifier {
static SharedPreferences? _prefs; static SharedPreferences? _prefs;
Settings._private() { Settings._private();
_platformSettingsChangeChannel.receiveBroadcastStream().listen((event) => _onPlatformSettingsChange(event as Map?));
}
static const Set<String> internalKeys = { static const Set<String> internalKeys = {
hasAcceptedTermsKey, hasAcceptedTermsKey,
@ -41,6 +39,7 @@ class Settings extends ChangeNotifier {
// app // app
static const hasAcceptedTermsKey = 'has_accepted_terms'; static const hasAcceptedTermsKey = 'has_accepted_terms';
static const canUseAnalysisServiceKey = 'can_use_analysis_service';
static const isErrorReportingEnabledKey = 'is_crashlytics_enabled'; static const isErrorReportingEnabledKey = 'is_crashlytics_enabled';
static const localeKey = 'locale'; static const localeKey = 'locale';
static const mustBackTwiceToExitKey = 'must_back_twice_to_exit'; static const mustBackTwiceToExitKey = 'must_back_twice_to_exit';
@ -124,12 +123,16 @@ class Settings extends ChangeNotifier {
bool get initialized => _prefs != null; bool get initialized => _prefs != null;
Future<void> init({ Future<void> init({
required bool monitorPlatformSettings,
bool isRotationLocked = false, bool isRotationLocked = false,
bool areAnimationsRemoved = false, bool areAnimationsRemoved = false,
}) async { }) async {
_prefs = await SharedPreferences.getInstance(); _prefs = await SharedPreferences.getInstance();
_isRotationLocked = isRotationLocked; _isRotationLocked = isRotationLocked;
_areAnimationsRemoved = areAnimationsRemoved; _areAnimationsRemoved = areAnimationsRemoved;
if (monitorPlatformSettings) {
_platformSettingsChangeChannel.receiveBroadcastStream().listen((event) => _onPlatformSettingsChange(event as Map?));
}
} }
Future<void> reset({required bool includeInternalKeys}) async { Future<void> reset({required bool includeInternalKeys}) async {
@ -165,6 +168,10 @@ class Settings extends ChangeNotifier {
set hasAcceptedTerms(bool newValue) => setAndNotify(hasAcceptedTermsKey, newValue); 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); bool get isErrorReportingEnabled => getBoolOrDefault(isErrorReportingEnabledKey, SettingsDefaults.isErrorReportingEnabled);
set isErrorReportingEnabled(bool newValue) => setAndNotify(isErrorReportingEnabledKey, newValue); 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/filters/tag.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/album.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/enums.dart';
import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.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/image_op_events.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -29,14 +31,14 @@ mixin SourceBase {
ValueNotifier<SourceState> stateNotifier = ValueNotifier(SourceState.ready); 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}) => progressNotifier.value = ProgressEvent(done: done, total: total);
void setProgress({required int done, required int total}) => _progressStreamController.add(ProgressEvent(done: done, total: total));
} }
abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin { abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
static const _analysisServiceOpCountThreshold = 400;
final EventBus _eventBus = EventBus(); final EventBus _eventBus = EventBus();
@override @override
@ -86,6 +88,15 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
invalidateTagFilterSummary(entries); 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) { void addEntries(Set<AvesEntry> entries) {
if (entries.isEmpty) return; if (entries.isEmpty) return;
@ -113,11 +124,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
entries.forEach((v) => _entryById.remove(v.contentId)); entries.forEach((v) => _entryById.remove(v.contentId));
_rawEntries.removeAll(entries); _rawEntries.removeAll(entries);
_invalidate(entries); updateDerivedFilters(entries);
cleanEmptyAlbums(entries.map((entry) => entry.directory).toSet());
updateLocations();
updateTags();
eventBus.fire(EntryRemovedEvent(entries)); eventBus.fire(EntryRemovedEvent(entries));
} }
@ -252,20 +259,43 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
Future<void> init(); 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 { Future<void> analyze(AnalysisController? analysisController, Set<AvesEntry> candidateEntries) async {
await Future.forEach<AvesEntry>(entries, (entry) => entry.refresh(persist: true)); final todoEntries = visibleEntries;
final _analysisController = analysisController ?? AnalysisController();
_invalidate(entries); if (!_analysisController.isStopping) {
updateLocations(); late bool startAnalysisService;
updateTags(); if (_analysisController.canStartService && settings.canUseAnalysisService) {
final force = _analysisController.force;
eventBus.fire(EntryRefreshedEvent(entries)); 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 // monitoring
@ -312,46 +342,45 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
settings.searchHistory = settings.searchHistory..removeWhere(filters.contains); settings.searchHistory = settings.searchHistory..removeWhere(filters.contains);
} }
settings.hiddenFilters = hiddenFilters; settings.hiddenFilters = hiddenFilters;
updateDerivedFilters();
_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();
eventBus.fire(FilterVisibilityChangedEvent(filters, visible)); eventBus.fire(FilterVisibilityChangedEvent(filters, visible));
if (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 { class EntryAddedEvent {
final Set<AvesEntry>? entries; final Set<AvesEntry>? entries;
const EntryAddedEvent([this.entries]); const EntryAddedEvent([this.entries]);
} }
@immutable
class EntryRemovedEvent { class EntryRemovedEvent {
final Set<AvesEntry> entries; final Set<AvesEntry> entries;
const EntryRemovedEvent(this.entries); const EntryRemovedEvent(this.entries);
} }
@immutable
class EntryMovedEvent { class EntryMovedEvent {
final Set<AvesEntry> entries; final Set<AvesEntry> entries;
const EntryMovedEvent(this.entries); const EntryMovedEvent(this.entries);
} }
@immutable
class EntryRefreshedEvent { class EntryRefreshedEvent {
final Set<AvesEntry> entries; final Set<AvesEntry> entries;
const EntryRefreshedEvent(this.entries); const EntryRefreshedEvent(this.entries);
} }
@immutable
class FilterVisibilityChangedEvent { class FilterVisibilityChangedEvent {
final Set<CollectionFilter> filters; final Set<CollectionFilter> filters;
final bool visible; final bool visible;
@ -359,6 +388,7 @@ class FilterVisibilityChangedEvent {
const FilterVisibilityChangedEvent(this.filters, this.visible); const FilterVisibilityChangedEvent(this.filters, this.visible);
} }
@immutable
class ProgressEvent { class ProgressEvent {
final int done, total; 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 } 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/entry.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
import 'package:aves/model/metadata/address.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/collection_source.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
@ -12,38 +13,43 @@ import 'package:flutter/foundation.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
mixin LocationMixin on SourceBase { mixin LocationMixin on SourceBase {
static const _commitCountThreshold = 50; static const _commitCountThreshold = 80;
static const _stopCheckCountThreshold = 20;
List<String> sortedCountries = List.unmodifiable([]); List<String> sortedCountries = List.unmodifiable([]);
List<String> sortedPlaces = List.unmodifiable([]); List<String> sortedPlaces = List.unmodifiable([]);
Future<void> loadAddresses() async { Future<void> loadAddresses() async {
// final stopwatch = Stopwatch()..start();
final saved = await metadataDb.loadAddresses(); final saved = await metadataDb.loadAddresses();
final idMap = entryById; final idMap = entryById;
saved.forEach((metadata) => idMap[metadata.contentId]?.addressDetails = metadata); saved.forEach((metadata) => idMap[metadata.contentId]?.addressDetails = metadata);
// debugPrint('$runtimeType loadAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
onAddressMetadataChanged(); onAddressMetadataChanged();
} }
Future<void> locateEntries() async { Future<void> locateEntries(AnalysisController controller, Set<AvesEntry> candidateEntries) async {
await _locateCountries(); await _locateCountries(controller, candidateEntries);
await _locatePlaces(); 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 // quick reverse geocoding to find the countries, using an offline asset
Future<void> _locateCountries() async { Future<void> _locateCountries(AnalysisController controller, Set<AvesEntry> candidateEntries) async {
final todo = visibleEntries.where((entry) => entry.hasGps && !entry.hasAddress).toSet(); if (controller.isStopping) return;
final force = controller.force;
final todo = (force ? candidateEntries.where((entry) => entry.hasGps) : candidateEntries.where(locateCountriesTest)).toSet();
if (todo.isEmpty) return; if (todo.isEmpty) return;
stateNotifier.value = SourceState.locating; stateNotifier.value = SourceState.locatingCountries;
var progressDone = 0; var progressDone = 0;
final progressTotal = todo.length; final progressTotal = todo.length;
setProgress(done: progressDone, total: progressTotal); setProgress(done: progressDone, total: progressTotal);
// final stopwatch = Stopwatch()..start();
final countryCodeMap = await countryTopology.countryCodeMap(todo.map((entry) => entry.latLng!).toSet()); final countryCodeMap = await countryTopology.countryCodeMap(todo.map((entry) => entry.latLng!).toSet());
final newAddresses = <AddressDetails>[]; final newAddresses = <AddressDetails>{};
todo.forEach((entry) { todo.forEach((entry) {
final position = entry.latLng; final position = entry.latLng;
final countryCode = countryCodeMap.entries.firstWhereOrNull((kv) => kv.value.contains(position))?.key; final countryCode = countryCodeMap.entries.firstWhereOrNull((kv) => kv.value.contains(position))?.key;
@ -54,19 +60,18 @@ mixin LocationMixin on SourceBase {
setProgress(done: ++progressDone, total: progressTotal); setProgress(done: ++progressDone, total: progressTotal);
}); });
if (newAddresses.isNotEmpty) { if (newAddresses.isNotEmpty) {
await metadataDb.saveAddresses(Set.of(newAddresses)); await metadataDb.saveAddresses(Set.unmodifiable(newAddresses));
onAddressMetadataChanged(); onAddressMetadataChanged();
} }
// debugPrint('$runtimeType _locateCountries complete in ${stopwatch.elapsed.inMilliseconds}ms');
} }
// full reverse geocoding, requiring Play Services and some connectivity // 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; if (!(await availability.canLocatePlaces)) return;
// final stopwatch = Stopwatch()..start(); final force = controller.force;
final byLocated = groupBy<AvesEntry, bool>(visibleEntries.where((entry) => entry.hasGps), (entry) => entry.hasFineAddress); final todo = (force ? candidateEntries.where((entry) => entry.hasGps) : candidateEntries.where(locatePlacesTest)).toSet();
final todo = byLocated[false] ?? [];
if (todo.isEmpty) return; if (todo.isEmpty) return;
// geocoder calls take between 150ms and 250ms // geocoder calls take between 150ms and 250ms
@ -81,28 +86,31 @@ mixin LocationMixin on SourceBase {
final latLngFactor = pow(10, 2); final latLngFactor = pow(10, 2);
Tuple2<int, int> approximateLatLng(AvesEntry entry) { Tuple2<int, int> approximateLatLng(AvesEntry entry) {
// entry has coordinates // entry has coordinates
final lat = entry.catalogMetadata!.latitude!; final catalogMetadata = entry.catalogMetadata!;
final lng = entry.catalogMetadata!.longitude!; final lat = catalogMetadata.latitude!;
final lng = catalogMetadata.longitude!;
return Tuple2<int, int>((lat * latLngFactor).round(), (lng * latLngFactor).round()); 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?>{}; final knownLocations = <Tuple2<int, int>, AddressDetails?>{};
byLocated[true]?.forEach((entry) { located.forEach((entry) {
knownLocations.putIfAbsent(approximateLatLng(entry), () => entry.addressDetails); knownLocations.putIfAbsent(approximateLatLng(entry), () => entry.addressDetails);
}); });
stateNotifier.value = SourceState.locating; stateNotifier.value = SourceState.locatingPlaces;
var progressDone = 0; var progressDone = 0;
final progressTotal = todo.length; final progressTotal = todo.length;
setProgress(done: progressDone, total: progressTotal); setProgress(done: progressDone, total: progressTotal);
final newAddresses = <AddressDetails>[]; var stopCheckCount = 0;
await Future.forEach<AvesEntry>(todo, (entry) async { final newAddresses = <AddressDetails>{};
for (final entry in todo) {
final latLng = approximateLatLng(entry); final latLng = approximateLatLng(entry);
if (knownLocations.containsKey(latLng)) { if (knownLocations.containsKey(latLng)) {
entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId); entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId);
} else { } else {
await entry.locatePlace(background: true); await entry.locatePlace(background: true, force: force);
// it is intended to insert `null` if the geocoder failed, // it is intended to insert `null` if the geocoder failed,
// so that we skip geocoding of following entries with the same coordinates // so that we skip geocoding of following entries with the same coordinates
knownLocations[latLng] = entry.addressDetails; knownLocations[latLng] = entry.addressDetails;
@ -110,18 +118,21 @@ mixin LocationMixin on SourceBase {
if (entry.hasFineAddress) { if (entry.hasFineAddress) {
newAddresses.add(entry.addressDetails!); newAddresses.add(entry.addressDetails!);
if (newAddresses.length >= _commitCountThreshold) { if (newAddresses.length >= _commitCountThreshold) {
await metadataDb.saveAddresses(Set.of(newAddresses)); await metadataDb.saveAddresses(Set.unmodifiable(newAddresses));
onAddressMetadataChanged(); onAddressMetadataChanged();
newAddresses.clear(); newAddresses.clear();
} }
if (++stopCheckCount >= _stopCheckCountThreshold) {
stopCheckCount = 0;
if (controller.isStopping) return;
}
} }
setProgress(done: ++progressDone, total: progressTotal); setProgress(done: ++progressDone, total: progressTotal);
}); }
if (newAddresses.isNotEmpty) { if (newAddresses.isNotEmpty) {
await metadataDb.saveAddresses(Set.of(newAddresses)); await metadataDb.saveAddresses(Set.unmodifiable(newAddresses));
onAddressMetadataChanged(); onAddressMetadataChanged();
} }
// debugPrint('$runtimeType _locatePlaces complete in ${stopwatch.elapsed.inSeconds}s');
} }
void onAddressMetadataChanged() { void onAddressMetadataChanged() {

View file

@ -5,6 +5,7 @@ import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/settings/settings.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/collection_source.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
@ -42,7 +43,7 @@ class MediaStoreSource extends CollectionSource {
} }
@override @override
Future<void> refresh() async { Future<void> refresh({AnalysisController? analysisController}) async {
assert(_initialized); assert(_initialized);
debugPrint('$runtimeType refresh start'); debugPrint('$runtimeType refresh start');
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
@ -59,10 +60,10 @@ class MediaStoreSource extends CollectionSource {
// show known entries // show known entries
debugPrint('$runtimeType refresh ${stopwatch.elapsed} add known entries'); debugPrint('$runtimeType refresh ${stopwatch.elapsed} add known entries');
addEntries(oldEntries); addEntries(oldEntries);
debugPrint('$runtimeType refresh ${stopwatch.elapsed} load catalog metadata'); debugPrint('$runtimeType refresh ${stopwatch.elapsed} load metadata');
await loadCatalogMetadata(); await loadCatalogMetadata();
debugPrint('$runtimeType refresh ${stopwatch.elapsed} load address metadata');
await loadAddresses(); await loadAddresses();
updateDerivedFilters();
// clean up obsolete entries // clean up obsolete entries
debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries'); debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries');
@ -110,11 +111,7 @@ class MediaStoreSource extends CollectionSource {
updateDirectories(); updateDirectories();
} }
debugPrint('$runtimeType refresh ${stopwatch.elapsed} catalog entries'); await analyze(analysisController, visibleEntries);
await catalogEntries();
debugPrint('$runtimeType refresh ${stopwatch.elapsed} locate entries');
await locateEntries();
stateNotifier.value = SourceState.ready;
debugPrint('$runtimeType refresh ${stopwatch.elapsed} done for ${oldEntries.length} known, ${allNewEntries.length} new, ${obsoleteContentIds.length} obsolete'); 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 // 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` // sometimes yields an entry with its temporary path: `/data/sec/camera/!@#$%^..._temp.jpg`
@override @override
Future<Set<String>> refreshUris(Set<String> changedUris) async { Future<Set<String>> refreshUris(Set<String> changedUris, {AnalysisController? analysisController}) async {
if (!_initialized || !isMonitoring) return changedUris; if (!_initialized || !isMonitoring) return changedUris;
debugPrint('$runtimeType refreshUris ${changedUris.length} uris'); debugPrint('$runtimeType refreshUris ${changedUris.length} uris');
@ -181,18 +178,10 @@ class MediaStoreSource extends CollectionSource {
addEntries(newEntries); addEntries(newEntries);
await metadataDb.saveEntries(newEntries); await metadataDb.saveEntries(newEntries);
cleanEmptyAlbums(existingDirectories); cleanEmptyAlbums(existingDirectories);
await catalogEntries();
await locateEntries(); await analyze(analysisController, newEntries);
stateNotifier.value = SourceState.ready;
} }
return tempUris; 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/entry.dart';
import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/metadata/catalog.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/collection_source.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
@ -8,22 +9,25 @@ import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
mixin TagMixin on SourceBase { mixin TagMixin on SourceBase {
static const _commitCountThreshold = 300; static const _commitCountThreshold = 400;
static const _stopCheckCountThreshold = 100;
List<String> sortedTags = List.unmodifiable([]); List<String> sortedTags = List.unmodifiable([]);
Future<void> loadCatalogMetadata() async { Future<void> loadCatalogMetadata() async {
// final stopwatch = Stopwatch()..start();
final saved = await metadataDb.loadMetadataEntries(); final saved = await metadataDb.loadMetadataEntries();
final idMap = entryById; final idMap = entryById;
saved.forEach((metadata) => idMap[metadata.contentId]?.catalogMetadata = metadata); saved.forEach((metadata) => idMap[metadata.contentId]?.catalogMetadata = metadata);
// debugPrint('$runtimeType loadCatalogMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
onCatalogMetadataChanged(); onCatalogMetadataChanged();
} }
Future<void> catalogEntries() async { static bool catalogEntriesTest(AvesEntry entry) => !entry.isCatalogued;
// final stopwatch = Stopwatch()..start();
final todo = visibleEntries.where((entry) => !entry.isCatalogued).toList(); 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; if (todo.isEmpty) return;
stateNotifier.value = SourceState.cataloguing; stateNotifier.value = SourceState.cataloguing;
@ -31,22 +35,26 @@ mixin TagMixin on SourceBase {
final progressTotal = todo.length; final progressTotal = todo.length;
setProgress(done: progressDone, total: progressTotal); setProgress(done: progressDone, total: progressTotal);
final newMetadata = <CatalogMetadata>[]; var stopCheckCount = 0;
await Future.forEach<AvesEntry>(todo, (entry) async { final newMetadata = <CatalogMetadata>{};
await entry.catalog(background: true); for (final entry in todo) {
await entry.catalog(background: true, persist: true, force: force);
if (entry.isCatalogued) { if (entry.isCatalogued) {
newMetadata.add(entry.catalogMetadata!); newMetadata.add(entry.catalogMetadata!);
if (newMetadata.length >= _commitCountThreshold) { if (newMetadata.length >= _commitCountThreshold) {
await metadataDb.saveMetadata(Set.of(newMetadata)); await metadataDb.saveMetadata(Set.unmodifiable(newMetadata));
onCatalogMetadataChanged(); onCatalogMetadataChanged();
newMetadata.clear(); newMetadata.clear();
} }
if (++stopCheckCount >= _stopCheckCountThreshold) {
stopCheckCount = 0;
if (controller.isStopping) return;
}
} }
setProgress(done: ++progressDone, total: progressTotal); setProgress(done: ++progressDone, total: progressTotal);
}); }
await metadataDb.saveMetadata(Set.of(newMetadata)); await metadataDb.saveMetadata(Set.unmodifiable(newMetadata));
onCatalogMetadataChanged(); onCatalogMetadataChanged();
// debugPrint('$runtimeType catalogEntries complete in ${stopwatch.elapsed.inSeconds}s');
} }
void onCatalogMetadataChanged() { 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(); return (result as List).cast<Map>().map((map) => Address.fromMap(map)).toList();
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
await reportService.recordError(e, stack); if (e.code != 'getAddress-empty') {
await reportService.recordError(e, stack);
}
} }
return []; 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/home_page.dart';
import 'package:aves/widgets/welcome_page.dart'; import 'package:aves/widgets/welcome_page.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:fijkplayer/fijkplayer.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -46,6 +47,7 @@ class _AvesAppState extends State<AvesApp> {
List<NavigatorObserver> _navigatorObservers = []; List<NavigatorObserver> _navigatorObservers = [];
final EventChannel _mediaStoreChangeChannel = const EventChannel('deckers.thibault/aves/media_store_change'); final EventChannel _mediaStoreChangeChannel = const EventChannel('deckers.thibault/aves/media_store_change');
final EventChannel _newIntentChannel = const EventChannel('deckers.thibault/aves/intent'); 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 EventChannel _errorChannel = const EventChannel('deckers.thibault/aves/error');
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator'); final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator');
@ -58,6 +60,7 @@ class _AvesAppState extends State<AvesApp> {
_appSetup = _setup(); _appSetup = _setup();
_mediaStoreChangeChannel.receiveBroadcastStream().listen((event) => _onMediaStoreChange(event as String?)); _mediaStoreChangeChannel.receiveBroadcastStream().listen((event) => _onMediaStoreChange(event as String?));
_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map?)); _newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map?));
_analysisCompletionChannel.receiveBroadcastStream().listen((event) => _onAnalysisCompletion());
_errorChannel.receiveBroadcastStream().listen((event) => _onError(event as String?)); _errorChannel.receiveBroadcastStream().listen((event) => _onError(event as String?));
} }
@ -144,9 +147,11 @@ class _AvesAppState extends State<AvesApp> {
Future<void> _setup() async { Future<void> _setup() async {
await settings.init( await settings.init(
monitorPlatformSettings: true,
isRotationLocked: await windowService.isRotationLocked(), isRotationLocked: await windowService.isRotationLocked(),
areAnimationsRemoved: await AccessibilityService.areAnimationsRemoved(), areAnimationsRemoved: await AccessibilityService.areAnimationsRemoved(),
); );
FijkLog.setLevel(FijkLogLevel.Warn);
// keep screen on // keep screen on
settings.updateStream.where((key) => key == Settings.keepScreenOnKey).listen( 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) { void _onMediaStoreChange(String? uri) {
if (uri != null) changedUris.add(uri); if (uri != null) changedUris.add(uri);
if (changedUris.isNotEmpty) { 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/filters/album.dart';
import 'package:aves/model/highlight.dart'; import 'package:aves/model/highlight.dart';
import 'package:aves/model/selection.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_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/common/image_op_events.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 selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection); final selectedItems = _getExpandedSelectedItems(selection);
source.rescan(selectedItems); final controller = AnalysisController(canStartService: false, force: true);
source.analyze(controller, selectedItems);
selection.browse(); selection.browse();
} }

View file

@ -1,5 +1,6 @@
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.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/theme/durations.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -56,43 +57,29 @@ class SourceStateSubtitle extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
String? subtitle; final sourceState = source.stateNotifier.value;
switch (source.stateNotifier.value) { final subtitle = sourceState.getName(context.l10n);
case SourceState.loading: if (subtitle == null) return const SizedBox();
subtitle = context.l10n.sourceStateLoading;
break; final subtitleStyle = Theme.of(context).textTheme.caption!;
case SourceState.cataloguing: return Row(
subtitle = context.l10n.sourceStateCataloguing; mainAxisSize: MainAxisSize.min,
break; children: [
case SourceState.locating: Text(subtitle, style: subtitleStyle),
subtitle = context.l10n.sourceStateLocating; ValueListenableBuilder<ProgressEvent>(
break; valueListenable: source.progressNotifier,
case SourceState.ready: builder: (context, progress, snapshot) {
default: if (progress.total == 0 || sourceState == SourceState.locatingCountries) return const SizedBox();
break; return Padding(
} padding: const EdgeInsetsDirectional.only(start: 8),
final subtitleStyle = Theme.of(context).textTheme.caption; child: Text(
return subtitle == null '${progress.done}/${progress.total}',
? const SizedBox.shrink() style: subtitleStyle.copyWith(color: Colors.white30),
: 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),
),
);
},
), ),
], );
); },
),
],
);
} }
} }

View file

@ -1,5 +1,6 @@
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_source.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/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/debug/android_apps.dart'; import 'package:aves/widgets/debug/android_apps.dart';
@ -103,6 +104,10 @@ class _AppDebugPageState extends State<AppDebugPage> {
}, },
child: const Text('Source full refresh'), child: const Text('Source full refresh'),
), ),
const ElevatedButton(
onPressed: AnalysisService.startService,
child: Text('Start analysis service'),
),
const Divider(), const Divider(),
Padding( Padding(
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8), 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, onChanged: (v) => settings.hasAcceptedTerms = v,
title: const Text('hasAcceptedTerms'), title: const Text('hasAcceptedTerms'),
), ),
SwitchListTile(
value: settings.canUseAnalysisService,
onChanged: (v) => settings.canUseAnalysisService = v,
title: const Text('canUseAnalysisService'),
),
SwitchListTile( SwitchListTile(
value: settings.videoShowRawTimedText, value: settings.videoShowRawTimedText,
onChanged: (v) => settings.videoShowRawTimedText = v, 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/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.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/common/services.dart';
import 'package:aves/services/global_search.dart'; import 'package:aves/services/global_search.dart';
import 'package:aves/services/viewer_service.dart'; import 'package:aves/services/viewer_service.dart';
@ -110,10 +111,11 @@ class _HomePageState extends State<HomePage> {
if (appMode != AppMode.view) { if (appMode != AppMode.view) {
debugPrint('Storage check complete in ${stopwatch.elapsed.inMilliseconds}ms'); debugPrint('Storage check complete in ${stopwatch.elapsed.inMilliseconds}ms');
unawaited(GlobalSearch.registerCallback());
unawaited(AnalysisService.registerCallback());
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
await source.init(); await source.init();
unawaited(source.refresh()); unawaited(source.refresh());
unawaited(GlobalSearch.registerCallback());
} }
// `pushReplacement` is not enough in some edge cases // `pushReplacement` is not enough in some edge cases
@ -129,7 +131,7 @@ class _HomePageState extends State<HomePage> {
final entry = await mediaFileService.getEntry(uri, mimeType); final entry = await mediaFileService.getEntry(uri, mimeType);
if (entry != null) { if (entry != null) {
// cataloguing is essential for coordinates and video rotation // 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; 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) // 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?.imageChangeNotifier.removeListener(_onImageChanged);
_oldEntry = entry; _oldEntry = entry;
if (entry != null) { final _entry = entry;
entry!.imageChangeNotifier.addListener(_onImageChanged); if (_entry != null) {
_entry.imageChangeNotifier.addListener(_onImageChanged);
// make sure to locate the entry, // make sure to locate the entry,
// so that we can display the address instead of coordinates // so that we can display the address instead of coordinates
// even when initial collection locating has not reached this entry yet // 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 { } else {
Navigator.pop(context); Navigator.pop(context);
} }

View file

@ -40,9 +40,9 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin {
final success = await apply(); final success = await apply();
if (success) { if (success) {
if (_isMainMode(context) && source != null) { if (_isMainMode(context) && source != null) {
await source.refreshMetadata({entry}); await source.refreshEntry(entry);
} else { } else {
await entry.refresh(persist: false); await entry.refresh(background: false, persist: false, force: true);
} }
showFeedback(context, l10n.genericSuccessFeedback); showFeedback(context, l10n.genericSuccessFeedback);
} else { } else {

View file

@ -16,7 +16,6 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class IjkPlayerAvesVideoController extends AvesVideoController { class IjkPlayerAvesVideoController extends AvesVideoController {
static bool _staticInitialized = false;
late FijkPlayer _instance; late FijkPlayer _instance;
final List<StreamSubscription> _subscriptions = []; final List<StreamSubscription> _subscriptions = [];
final StreamController<FijkValue> _valueStreamController = StreamController.broadcast(); final StreamController<FijkValue> _valueStreamController = StreamController.broadcast();
@ -55,10 +54,6 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
static const gifLikeBitRateThreshold = 2 << 18; // 512kB/s (4Mb/s) static const gifLikeBitRateThreshold = 2 << 18; // 512kB/s (4Mb/s)
IjkPlayerAvesVideoController(AvesEntry entry) : super(entry) { IjkPlayerAvesVideoController(AvesEntry entry) : super(entry) {
if (!_staticInitialized) {
FijkLog.setLevel(FijkLogLevel.Warn);
_staticInitialized = true;
}
_instance = FijkPlayer(); _instance = FijkPlayer();
_valueStream.map((value) => value.videoRenderStart).firstWhere((v) => v, orElse: () => false).then( _valueStream.map((value) => value.videoRenderStart).firstWhere((v) => v, orElse: () => false).then(
(started) => canCaptureFrameNotifier.value = started, (started) => canCaptureFrameNotifier.value = started,

View file

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

View file

@ -39,6 +39,9 @@ class FakeMetadataDb extends Fake implements MetadataDb {
@override @override
Future<List<AddressDetails>> loadAddresses() => SynchronousFuture([]); Future<List<AddressDetails>> loadAddresses() => SynchronousFuture([]);
@override
Future<void> saveAddresses(Set<AddressDetails> addresses) => SynchronousFuture(null);
@override @override
Future<void> updateAddressId(int oldId, AddressDetails? address) => SynchronousFuture(null); 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'; import 'package:flutter_test/flutter_test.dart';
class FakeMetadataFetchService extends Fake implements MetadataFetchService { class FakeMetadataFetchService extends Fake implements MetadataFetchService {
final Map<AvesEntry, CatalogMetadata> _metaMap = {};
void setUp(AvesEntry entry, CatalogMetadata metadata) => _metaMap[entry] = metadata;
@override @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/covers.dart';
import 'package:aves/model/favourites.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/album.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/metadata_db.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/enums.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:aves/utils/android_file_utils.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../fake/android_app_service.dart'; import '../fake/android_app_service.dart';
@ -38,6 +42,13 @@ void main() {
const sourceAlbum = '${FakeStorageService.primaryPath}Pictures/source'; const sourceAlbum = '${FakeStorageService.primaryPath}Pictures/source';
const destinationAlbum = '${FakeStorageService.primaryPath}Pictures/destination'; const destinationAlbum = '${FakeStorageService.primaryPath}Pictures/destination';
const aTag = 'sometag';
final australiaLatLng = LatLng(-26, 141);
const australiaAddress = AddressDetails(
countryCode: 'AU',
countryName: 'AUS',
);
setUp(() async { setUp(() async {
// specify Posix style path context for consistent behaviour when running tests on Windows // specify Posix style path context for consistent behaviour when running tests on Windows
getIt.registerLazySingleton<p.Context>(() => p.Context(style: p.Style.posix)); getIt.registerLazySingleton<p.Context>(() => p.Context(style: p.Style.posix));
@ -53,7 +64,8 @@ void main() {
getIt.registerLazySingleton<StorageService>(() => FakeStorageService()); getIt.registerLazySingleton<StorageService>(() => FakeStorageService());
getIt.registerLazySingleton<WindowService>(() => FakeWindowService()); getIt.registerLazySingleton<WindowService>(() => FakeWindowService());
await settings.init(); await settings.init(monitorPlatformSettings: false);
settings.canUseAnalysisService = false;
}); });
tearDown(() async { tearDown(() async {
@ -74,6 +86,57 @@ void main() {
return source; 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 { test('add/remove favourite entry', () async {
final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1'); final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1');
(mediaStoreService as FakeMediaStoreService).entries = { (mediaStoreService as FakeMediaStoreService).entries = {

View file

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