foreground service to scan many items
This commit is contained in:
parent
6921e8dd11
commit
5db804c0e7
47 changed files with 1090 additions and 282 deletions
|
@ -1,4 +1,4 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<resources>
|
||||
<string name="app_name">아베스 [Debug]</string>
|
||||
<string name="app_name">아베스 [Debug]</string>
|
||||
</resources>
|
|
@ -16,6 +16,7 @@
|
|||
https://developer.android.com/preview/privacy/storage#media-files-raw-paths
|
||||
-->
|
||||
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<!-- request write permission until Q (29) included, because scoped storage is unusable -->
|
||||
<uses-permission
|
||||
|
@ -122,6 +123,10 @@
|
|||
android:name="android.app.searchable"
|
||||
android:resource="@xml/searchable" />
|
||||
</activity>
|
||||
<service
|
||||
android:name=".AnalysisService"
|
||||
android:description="@string/analysis_service_description"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- file provider to share files having a file:// URI -->
|
||||
<provider
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -27,7 +27,9 @@ class MainActivity : FlutterActivity() {
|
|||
private lateinit var mediaStoreChangeStreamHandler: MediaStoreChangeStreamHandler
|
||||
private lateinit var settingsChangeStreamHandler: SettingsChangeStreamHandler
|
||||
private lateinit var intentStreamHandler: IntentStreamHandler
|
||||
private lateinit var analysisStreamHandler: AnalysisStreamHandler
|
||||
private lateinit var intentDataMap: MutableMap<String, Any?>
|
||||
private lateinit var analysisHandler: AnalysisHandler
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Log.i(LOG_TAG, "onCreate intent=$intent")
|
||||
|
@ -52,24 +54,30 @@ class MainActivity : FlutterActivity() {
|
|||
val messenger = flutterEngine!!.dartExecutor.binaryMessenger
|
||||
|
||||
// dart -> platform -> dart
|
||||
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
|
||||
// - need Context
|
||||
analysisHandler = AnalysisHandler(this, ::onAnalysisCompleted)
|
||||
MethodChannel(messenger, AnalysisHandler.CHANNEL).setMethodCallHandler(analysisHandler)
|
||||
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
|
||||
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
|
||||
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler())
|
||||
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
|
||||
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
|
||||
MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this))
|
||||
MethodChannel(messenger, MediaFileHandler.CHANNEL).setMethodCallHandler(MediaFileHandler(this))
|
||||
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
|
||||
MethodChannel(messenger, MetadataEditHandler.CHANNEL).setMethodCallHandler(MetadataEditHandler(this))
|
||||
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
|
||||
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
|
||||
// - need Activity
|
||||
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
|
||||
MethodChannel(messenger, MediaFileHandler.CHANNEL).setMethodCallHandler(MediaFileHandler(this))
|
||||
MethodChannel(messenger, MetadataEditHandler.CHANNEL).setMethodCallHandler(MetadataEditHandler(this))
|
||||
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(WindowHandler(this))
|
||||
|
||||
// result streaming: dart -> platform ->->-> dart
|
||||
// - need Context
|
||||
StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) }
|
||||
StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageOpStreamHandler(this, args) }
|
||||
StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) }
|
||||
// - need Activity
|
||||
StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageOpStreamHandler(this, args) }
|
||||
StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL).setStreamHandlerFactory { args -> StorageAccessStreamHandler(this, args) }
|
||||
|
||||
// change monitoring: platform -> dart
|
||||
|
@ -97,6 +105,11 @@ class MainActivity : FlutterActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
// notification: platform -> dart
|
||||
analysisStreamHandler = AnalysisStreamHandler().apply {
|
||||
EventChannel(messenger, AnalysisStreamHandler.CHANNEL).setStreamHandler(this)
|
||||
}
|
||||
|
||||
// notification: platform -> dart
|
||||
errorStreamHandler = ErrorStreamHandler().apply {
|
||||
EventChannel(messenger, ErrorStreamHandler.CHANNEL).setStreamHandler(this)
|
||||
|
@ -107,7 +120,20 @@ class MainActivity : FlutterActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
Log.i(LOG_TAG, "onStart")
|
||||
super.onStart()
|
||||
analysisHandler.attachToActivity()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
Log.i(LOG_TAG, "onStop")
|
||||
analysisHandler.detachFromActivity()
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.i(LOG_TAG, "onDestroy")
|
||||
mediaStoreChangeStreamHandler.dispose()
|
||||
settingsChangeStreamHandler.dispose()
|
||||
super.onDestroy()
|
||||
|
@ -252,6 +278,10 @@ class MainActivity : FlutterActivity() {
|
|||
ShortcutManagerCompat.setDynamicShortcuts(this, listOf(videos, search))
|
||||
}
|
||||
|
||||
private fun onAnalysisCompleted() {
|
||||
analysisStreamHandler.notifyCompletion()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<MainActivity>()
|
||||
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
|
||||
|
@ -261,6 +291,7 @@ class MainActivity : FlutterActivity() {
|
|||
const val CREATE_FILE_REQUEST = 3
|
||||
const val OPEN_FILE_REQUEST = 4
|
||||
const val SELECT_DIRECTORY_REQUEST = 5
|
||||
const val OPEN_FROM_ANALYSIS_SERVICE = 6
|
||||
|
||||
// request code to pending runnable
|
||||
val pendingStorageAccessResultHandlers = ConcurrentHashMap<Int, PendingStorageAccessResultHandler>()
|
||||
|
|
|
@ -2,24 +2,21 @@ package deckers.thibault.aves
|
|||
|
||||
import android.app.SearchManager
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.util.Log
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.ContextUtils.resourceUri
|
||||
import deckers.thibault.aves.utils.ContextUtils.runOnUiThread
|
||||
import deckers.thibault.aves.utils.FlutterUtils
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import io.flutter.FlutterInjector
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.dart.DartExecutor
|
||||
import io.flutter.embedding.engine.loader.FlutterLoader
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.view.FlutterCallbackInformation
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
@ -71,7 +68,9 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid
|
|||
|
||||
private suspend fun getSuggestions(context: Context, query: String): List<FieldMap> {
|
||||
if (backgroundFlutterEngine == null) {
|
||||
initFlutterEngine(context)
|
||||
FlutterUtils.initFlutterEngine(context, SHARED_PREFERENCES_KEY, CALLBACK_HANDLE_KEY) {
|
||||
backgroundFlutterEngine = it
|
||||
}
|
||||
}
|
||||
|
||||
val messenger = backgroundFlutterEngine!!.dartExecutor.binaryMessenger
|
||||
|
@ -133,60 +132,5 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid
|
|||
const val CALLBACK_HANDLE_KEY = "callback_handle"
|
||||
|
||||
private var backgroundFlutterEngine: FlutterEngine? = null
|
||||
|
||||
private suspend fun initFlutterEngine(context: Context) {
|
||||
val callbackHandle = context.getSharedPreferences(SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE).getLong(CALLBACK_HANDLE_KEY, 0)
|
||||
if (callbackHandle == 0L) {
|
||||
Log.e(LOG_TAG, "failed to retrieve registered callback handle")
|
||||
return
|
||||
}
|
||||
|
||||
lateinit var flutterLoader: FlutterLoader
|
||||
context.runOnUiThread {
|
||||
// initialization must happen on the main thread
|
||||
flutterLoader = FlutterInjector.instance().flutterLoader().apply {
|
||||
startInitialization(context)
|
||||
ensureInitializationComplete(context, null)
|
||||
}
|
||||
}
|
||||
|
||||
val callbackInfo = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle)
|
||||
if (callbackInfo == null) {
|
||||
Log.e(LOG_TAG, "failed to find callback information")
|
||||
return
|
||||
}
|
||||
|
||||
val args = DartExecutor.DartCallback(
|
||||
context.assets,
|
||||
flutterLoader.findAppBundlePath(),
|
||||
callbackInfo
|
||||
)
|
||||
context.runOnUiThread {
|
||||
// initialization must happen on the main thread
|
||||
backgroundFlutterEngine = FlutterEngine(context).apply {
|
||||
dartExecutor.executeDartCallback(args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// convenience methods
|
||||
|
||||
private suspend fun Context.runOnUiThread(r: Runnable) {
|
||||
suspendCoroutine<Boolean> { cont ->
|
||||
Handler(mainLooper).post {
|
||||
r.run()
|
||||
cont.resume(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.resourceUri(resourceId: Int): Uri = with(resources) {
|
||||
Uri.Builder()
|
||||
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
|
||||
.authority(getResourcePackageName(resourceId))
|
||||
.appendPath(getResourceTypeName(resourceId))
|
||||
.appendPath(getResourceEntryName(resourceId))
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -1,11 +1,9 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.app.Activity
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import deckers.thibault.aves.SearchSuggestionsProvider
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
|
@ -13,7 +11,7 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class GlobalSearchHandler(private val context: Activity) : MethodCallHandler {
|
||||
class GlobalSearchHandler(private val context: Context) : MethodCallHandler {
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"registerCallback" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::registerCallback) }
|
||||
|
@ -21,6 +19,7 @@ class GlobalSearchHandler(private val context: Activity) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
@SuppressLint("CommitPrefEdits")
|
||||
private fun registerCallback(call: MethodCall, result: MethodChannel.Result) {
|
||||
val callbackHandle = call.argument<Number>("callbackHandle")?.toLong()
|
||||
if (callbackHandle == null) {
|
||||
|
@ -36,7 +35,6 @@ class GlobalSearchHandler(private val context: Activity) : MethodCallHandler {
|
|||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<GlobalSearchHandler>()
|
||||
const val CHANNEL = "deckers.thibault/aves/global_search"
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.media.MediaScannerConnection
|
||||
import android.net.Uri
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
|
@ -12,7 +12,7 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MediaStoreHandler(private val activity: Activity) : MethodCallHandler {
|
||||
class MediaStoreHandler(private val context: Context) : MethodCallHandler {
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"checkObsoleteContentIds" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::checkObsoleteContentIds) }
|
||||
|
@ -28,7 +28,7 @@ class MediaStoreHandler(private val activity: Activity) : MethodCallHandler {
|
|||
result.error("checkObsoleteContentIds-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
result.success(MediaStoreImageProvider().checkObsoleteContentIds(activity, knownContentIds))
|
||||
result.success(MediaStoreImageProvider().checkObsoleteContentIds(context, knownContentIds))
|
||||
}
|
||||
|
||||
private fun checkObsoletePaths(call: MethodCall, result: MethodChannel.Result) {
|
||||
|
@ -37,13 +37,13 @@ class MediaStoreHandler(private val activity: Activity) : MethodCallHandler {
|
|||
result.error("checkObsoletePaths-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
result.success(MediaStoreImageProvider().checkObsoletePaths(activity, knownPathById))
|
||||
result.success(MediaStoreImageProvider().checkObsoletePaths(context, knownPathById))
|
||||
}
|
||||
|
||||
private fun scanFile(call: MethodCall, result: MethodChannel.Result) {
|
||||
val path = call.argument<String>("path")
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
MediaScannerConnection.scanFile(activity, arrayOf(path), arrayOf(mimeType)) { _, uri: Uri? -> result.success(uri?.toString()) }
|
||||
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, uri: Uri? -> result.success(uri?.toString()) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
package deckers.thibault.aves.channel.streams
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
|
@ -16,8 +16,8 @@ import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
|||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.MimeTypes.isHeic
|
||||
import deckers.thibault.aves.utils.MimeTypes.canDecodeWithFlutter
|
||||
import deckers.thibault.aves.utils.MimeTypes.isHeic
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
|
@ -26,10 +26,9 @@ import io.flutter.plugin.common.EventChannel.EventSink
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
class ImageByteStreamHandler(private val activity: Activity, private val arguments: Any?) : EventChannel.StreamHandler {
|
||||
class ImageByteStreamHandler(private val context: Context, private val arguments: Any?) : EventChannel.StreamHandler {
|
||||
private lateinit var eventSink: EventSink
|
||||
private lateinit var handler: Handler
|
||||
|
||||
|
@ -108,7 +107,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
|
||||
private fun streamImageAsIs(uri: Uri, mimeType: String) {
|
||||
try {
|
||||
StorageUtils.openInputStream(activity, uri)?.use { input -> streamBytes(input) }
|
||||
StorageUtils.openInputStream(context, uri)?.use { input -> streamBytes(input) }
|
||||
} catch (e: Exception) {
|
||||
error("streamImage-image-read-exception", "failed to get image for mimeType=$mimeType uri=$uri", e.message)
|
||||
}
|
||||
|
@ -116,14 +115,14 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
|
||||
private suspend fun streamImageByGlide(uri: Uri, pageId: Int?, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) {
|
||||
val model: Any = if (isHeic(mimeType) && pageId != null) {
|
||||
MultiTrackImage(activity, uri, pageId)
|
||||
MultiTrackImage(context, uri, pageId)
|
||||
} else if (mimeType == MimeTypes.TIFF) {
|
||||
TiffImage(activity, uri, pageId)
|
||||
TiffImage(context, uri, pageId)
|
||||
} else {
|
||||
StorageUtils.getGlideSafeUri(uri, mimeType)
|
||||
}
|
||||
|
||||
val target = Glide.with(activity)
|
||||
val target = Glide.with(context)
|
||||
.asBitmap()
|
||||
.apply(glideOptions)
|
||||
.load(model)
|
||||
|
@ -132,7 +131,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
var bitmap = target.get()
|
||||
if (needRotationAfterGlide(mimeType)) {
|
||||
bitmap = applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped)
|
||||
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
|
||||
}
|
||||
if (bitmap != null) {
|
||||
success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false))
|
||||
|
@ -142,15 +141,15 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
} catch (e: Exception) {
|
||||
error("streamImage-image-decode-exception", "failed to get image for mimeType=$mimeType uri=$uri model=$model", toErrorDetails(e))
|
||||
} finally {
|
||||
Glide.with(activity).clear(target)
|
||||
Glide.with(context).clear(target)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun streamVideoByGlide(uri: Uri, mimeType: String) {
|
||||
val target = Glide.with(activity)
|
||||
val target = Glide.with(context)
|
||||
.asBitmap()
|
||||
.apply(glideOptions)
|
||||
.load(VideoThumbnail(activity, uri))
|
||||
.load(VideoThumbnail(context, uri))
|
||||
.submit()
|
||||
try {
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
|
@ -163,7 +162,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
} catch (e: Exception) {
|
||||
error("streamImage-video-exception", "failed to get image for mimeType=$mimeType uri=$uri", e.message)
|
||||
} finally {
|
||||
Glide.with(activity).clear(target)
|
||||
Glide.with(context).clear(target)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,9 +2,7 @@ package deckers.thibault.aves.metadata
|
|||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import java.io.File
|
||||
|
@ -15,8 +13,6 @@ import java.util.*
|
|||
import java.util.regex.Pattern
|
||||
|
||||
object Metadata {
|
||||
private val LOG_TAG = LogUtils.createTag<Metadata>()
|
||||
|
||||
const val IPTC_MARKER_BYTE: Byte = 0x1c
|
||||
|
||||
// Pattern to extract latitude & longitude from a video location tag (cf ISO 6709)
|
||||
|
@ -138,7 +134,6 @@ object Metadata {
|
|||
} else {
|
||||
// make a preview from the beginning of the file,
|
||||
// hoping the metadata is accessible in the copied chunk
|
||||
Log.d(LOG_TAG, "use a preview for uri=$uri mimeType=$mimeType size=$sizeBytes")
|
||||
var previewFile = previewFiles[uri]
|
||||
if (previewFile == null) {
|
||||
previewFile = createPreviewFile(context, uri)
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
26
android/app/src/main/res/drawable-v21/ic_notification.xml
Normal file
26
android/app/src/main/res/drawable-v21/ic_notification.xml
Normal 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>
|
9
android/app/src/main/res/drawable/ic_outline_stop_24.xml
Normal file
9
android/app/src/main/res/drawable/ic_outline_stop_24.xml
Normal 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>
|
|
@ -1,6 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<resources>
|
||||
<string name="app_name">아베스</string>
|
||||
<string name="search_shortcut_short_label">검색</string>
|
||||
<string name="videos_shortcut_short_label">동영상</string>
|
||||
<string name="app_name">아베스</string>
|
||||
<string name="search_shortcut_short_label">검색</string>
|
||||
<string name="videos_shortcut_short_label">동영상</string>
|
||||
<string name="analysis_channel_name">미디어 분석</string>
|
||||
<string name="analysis_service_description">사진과 동영상 분석</string>
|
||||
<string name="analysis_notification_default_title">미디어 분석</string>
|
||||
<string name="analysis_notification_action_stop">취소</string>
|
||||
</resources>
|
|
@ -3,4 +3,8 @@
|
|||
<string name="app_name">Aves</string>
|
||||
<string name="search_shortcut_short_label">Search</string>
|
||||
<string name="videos_shortcut_short_label">Videos</string>
|
||||
<string name="analysis_channel_name">Media scan</string>
|
||||
<string name="analysis_service_description">Scan images & videos</string>
|
||||
<string name="analysis_notification_default_title">Scanning media</string>
|
||||
<string name="analysis_notification_action_stop">Stop</string>
|
||||
</resources>
|
|
@ -62,8 +62,10 @@
|
|||
"@sourceStateLoading": {},
|
||||
"sourceStateCataloguing": "Cataloguing",
|
||||
"@sourceStateCataloguing": {},
|
||||
"sourceStateLocating": "Locating",
|
||||
"@sourceStateLocating": {},
|
||||
"sourceStateLocatingCountries": "Locating countries",
|
||||
"@sourceStateLocatingCountries": {},
|
||||
"sourceStateLocatingPlaces": "Locating places",
|
||||
"@sourceStateLocatingPlaces": {},
|
||||
|
||||
"chipActionDelete": "Delete",
|
||||
"@chipActionDelete": {},
|
||||
|
|
|
@ -27,7 +27,8 @@
|
|||
|
||||
"sourceStateLoading": "로딩 중",
|
||||
"sourceStateCataloguing": "분석 중",
|
||||
"sourceStateLocating": "장소 찾는 중",
|
||||
"sourceStateLocatingCountries": "국가 찾는 중",
|
||||
"sourceStateLocatingPlaces": "장소 찾는 중",
|
||||
|
||||
"chipActionDelete": "삭제",
|
||||
"chipActionGoToAlbumPage": "앨범 페이지에서 보기",
|
||||
|
|
|
@ -434,17 +434,18 @@ class AvesEntry {
|
|||
addressDetails = null;
|
||||
}
|
||||
|
||||
Future<void> catalog({bool background = false, bool persist = true, bool force = false}) async {
|
||||
Future<void> catalog({required bool background, required bool persist, required bool force}) async {
|
||||
if (isCatalogued && !force) return;
|
||||
if (isSvg) {
|
||||
// vector image sizing is not essential, so we should not spend time for it during loading
|
||||
// but it is useful anyway (for aspect ratios etc.) so we size them during cataloguing
|
||||
final size = await SvgMetadataService.getSize(this);
|
||||
if (size != null) {
|
||||
await _applyNewFields({
|
||||
final fields = {
|
||||
'width': size.width.ceil(),
|
||||
'height': size.height.ceil(),
|
||||
}, persist: persist);
|
||||
};
|
||||
await _applyNewFields(fields, persist: persist);
|
||||
}
|
||||
catalogMetadata = CatalogMetadata(contentId: contentId);
|
||||
} else {
|
||||
|
@ -468,17 +469,17 @@ class AvesEntry {
|
|||
addressChangeNotifier.notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> locate({required bool background}) async {
|
||||
Future<void> locate({required bool background, required bool force}) async {
|
||||
if (!hasGps) return;
|
||||
await _locateCountry();
|
||||
await _locateCountry(force: force);
|
||||
if (await availability.canLocatePlaces) {
|
||||
await locatePlace(background: background);
|
||||
await locatePlace(background: background, force: force);
|
||||
}
|
||||
}
|
||||
|
||||
// quick reverse geocoding to find the country, using an offline asset
|
||||
Future<void> _locateCountry() async {
|
||||
if (!hasGps || hasAddress) return;
|
||||
Future<void> _locateCountry({required bool force}) async {
|
||||
if (!hasGps || (hasAddress && !force)) return;
|
||||
final countryCode = await countryTopology.countryCode(latLng!);
|
||||
setCountry(countryCode);
|
||||
}
|
||||
|
@ -500,8 +501,8 @@ class AvesEntry {
|
|||
}
|
||||
|
||||
// full reverse geocoding, requiring Play Services and some connectivity
|
||||
Future<void> locatePlace({required bool background}) async {
|
||||
if (!hasGps || hasFineAddress) return;
|
||||
Future<void> locatePlace({required bool background, required bool force}) async {
|
||||
if (!hasGps || (hasFineAddress && !force)) return;
|
||||
try {
|
||||
Future<List<Address>> call() => GeocodingService.getAddress(latLng!, geocoderLocale);
|
||||
final addresses = await (background
|
||||
|
@ -564,6 +565,10 @@ class AvesEntry {
|
|||
}.any((s) => s != null && s.toUpperCase().contains(query));
|
||||
|
||||
Future<void> _applyNewFields(Map newFields, {required bool persist}) async {
|
||||
final oldDateModifiedSecs = this.dateModifiedSecs;
|
||||
final oldRotationDegrees = this.rotationDegrees;
|
||||
final oldIsFlipped = this.isFlipped;
|
||||
|
||||
final uri = newFields['uri'];
|
||||
if (uri is String) this.uri = uri;
|
||||
final path = newFields['path'];
|
||||
|
@ -599,10 +604,11 @@ class AvesEntry {
|
|||
if (catalogMetadata != null) await metadataDb.saveMetadata({catalogMetadata!});
|
||||
}
|
||||
|
||||
await _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||||
metadataChangeNotifier.notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> refresh({required bool persist}) async {
|
||||
Future<void> refresh({required bool background, required bool persist, required bool force}) async {
|
||||
_catalogMetadata = null;
|
||||
_addressDetails = null;
|
||||
_bestDate = null;
|
||||
|
@ -614,13 +620,9 @@ class AvesEntry {
|
|||
|
||||
final updated = await mediaFileService.getEntry(uri, mimeType);
|
||||
if (updated != null) {
|
||||
final oldDateModifiedSecs = dateModifiedSecs;
|
||||
final oldRotationDegrees = rotationDegrees;
|
||||
final oldIsFlipped = isFlipped;
|
||||
await _applyNewFields(updated.toMap(), persist: persist);
|
||||
await catalog(background: false, persist: persist);
|
||||
await locate(background: false);
|
||||
await _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||||
await catalog(background: background, persist: persist, force: force);
|
||||
await locate(background: background, force: force);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -628,11 +630,7 @@ class AvesEntry {
|
|||
final newFields = await metadataEditService.rotate(this, clockwise: clockwise);
|
||||
if (newFields.isEmpty) return false;
|
||||
|
||||
final oldDateModifiedSecs = dateModifiedSecs;
|
||||
final oldRotationDegrees = rotationDegrees;
|
||||
final oldIsFlipped = isFlipped;
|
||||
await _applyNewFields(newFields, persist: persist);
|
||||
await _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -640,11 +638,7 @@ class AvesEntry {
|
|||
final newFields = await metadataEditService.flip(this);
|
||||
if (newFields.isEmpty) return false;
|
||||
|
||||
final oldDateModifiedSecs = dateModifiedSecs;
|
||||
final oldRotationDegrees = rotationDegrees;
|
||||
final oldIsFlipped = isFlipped;
|
||||
await _applyNewFields(newFields, persist: persist);
|
||||
await _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
@immutable
|
||||
class AddressDetails {
|
||||
class AddressDetails extends Equatable {
|
||||
final int? contentId;
|
||||
final String? countryCode, countryName, adminArea, locality;
|
||||
|
||||
String? get place => locality != null && locality!.isNotEmpty ? locality : adminArea;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [contentId, countryCode, countryName, adminArea, locality];
|
||||
|
||||
const AddressDetails({
|
||||
this.contentId,
|
||||
this.countryCode,
|
||||
|
@ -45,7 +49,4 @@ class AddressDetails {
|
|||
'adminArea': adminArea,
|
||||
'locality': locality,
|
||||
};
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}';
|
||||
}
|
||||
|
|
|
@ -170,7 +170,6 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly}) async {
|
||||
if (contentIds.isEmpty) return;
|
||||
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final db = await _database;
|
||||
// using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead
|
||||
final batch = db.batch();
|
||||
|
@ -187,7 +186,6 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
}
|
||||
});
|
||||
await batch.commit(noResult: true);
|
||||
// debugPrint('$runtimeType removeIds complete in ${stopwatch.elapsed.inMilliseconds}ms for ${contentIds.length} entries');
|
||||
}
|
||||
|
||||
// entries
|
||||
|
@ -201,11 +199,9 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
|
||||
@override
|
||||
Future<Set<AvesEntry>> loadEntries() async {
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final db = await _database;
|
||||
final maps = await db.query(entryTable);
|
||||
final entries = maps.map((map) => AvesEntry.fromMap(map)).toSet();
|
||||
// debugPrint('$runtimeType loadEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries');
|
||||
return entries;
|
||||
}
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ import 'package:flutter/material.dart';
|
|||
class SettingsDefaults {
|
||||
// app
|
||||
static const hasAcceptedTerms = false;
|
||||
static const canUseAnalysisService = true;
|
||||
static const isErrorReportingEnabled = false;
|
||||
static const mustBackTwiceToExit = true;
|
||||
static const keepScreenOn = KeepScreenOn.viewerOnly;
|
||||
|
|
|
@ -27,9 +27,7 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
static SharedPreferences? _prefs;
|
||||
|
||||
Settings._private() {
|
||||
_platformSettingsChangeChannel.receiveBroadcastStream().listen((event) => _onPlatformSettingsChange(event as Map?));
|
||||
}
|
||||
Settings._private();
|
||||
|
||||
static const Set<String> internalKeys = {
|
||||
hasAcceptedTermsKey,
|
||||
|
@ -41,6 +39,7 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
// app
|
||||
static const hasAcceptedTermsKey = 'has_accepted_terms';
|
||||
static const canUseAnalysisServiceKey = 'can_use_analysis_service';
|
||||
static const isErrorReportingEnabledKey = 'is_crashlytics_enabled';
|
||||
static const localeKey = 'locale';
|
||||
static const mustBackTwiceToExitKey = 'must_back_twice_to_exit';
|
||||
|
@ -124,12 +123,16 @@ class Settings extends ChangeNotifier {
|
|||
bool get initialized => _prefs != null;
|
||||
|
||||
Future<void> init({
|
||||
required bool monitorPlatformSettings,
|
||||
bool isRotationLocked = false,
|
||||
bool areAnimationsRemoved = false,
|
||||
}) async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
_isRotationLocked = isRotationLocked;
|
||||
_areAnimationsRemoved = areAnimationsRemoved;
|
||||
if (monitorPlatformSettings) {
|
||||
_platformSettingsChangeChannel.receiveBroadcastStream().listen((event) => _onPlatformSettingsChange(event as Map?));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reset({required bool includeInternalKeys}) async {
|
||||
|
@ -165,6 +168,10 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set hasAcceptedTerms(bool newValue) => setAndNotify(hasAcceptedTermsKey, newValue);
|
||||
|
||||
bool get canUseAnalysisService => getBoolOrDefault(canUseAnalysisServiceKey, SettingsDefaults.canUseAnalysisService);
|
||||
|
||||
set canUseAnalysisService(bool newValue) => setAndNotify(canUseAnalysisServiceKey, newValue);
|
||||
|
||||
bool get isErrorReportingEnabled => getBoolOrDefault(isErrorReportingEnabledKey, SettingsDefaults.isErrorReportingEnabled);
|
||||
|
||||
set isErrorReportingEnabled(bool newValue) => setAndNotify(isErrorReportingEnabledKey, newValue);
|
||||
|
|
14
lib/model/source/analysis_controller.dart
Normal file
14
lib/model/source/analysis_controller.dart
Normal 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;
|
||||
}
|
|
@ -9,9 +9,11 @@ import 'package:aves/model/filters/location.dart';
|
|||
import 'package:aves/model/filters/tag.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/album.dart';
|
||||
import 'package:aves/model/source/analysis_controller.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/model/source/location.dart';
|
||||
import 'package:aves/model/source/tag.dart';
|
||||
import 'package:aves/services/analysis_service.dart';
|
||||
import 'package:aves/services/common/image_op_events.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
@ -29,14 +31,14 @@ mixin SourceBase {
|
|||
|
||||
ValueNotifier<SourceState> stateNotifier = ValueNotifier(SourceState.ready);
|
||||
|
||||
final StreamController<ProgressEvent> _progressStreamController = StreamController.broadcast();
|
||||
ValueNotifier<ProgressEvent> progressNotifier = ValueNotifier(const ProgressEvent(done: 0, total: 0));
|
||||
|
||||
Stream<ProgressEvent> get progressStream => _progressStreamController.stream;
|
||||
|
||||
void setProgress({required int done, required int total}) => _progressStreamController.add(ProgressEvent(done: done, total: total));
|
||||
void setProgress({required int done, required int total}) => progressNotifier.value = ProgressEvent(done: done, total: total);
|
||||
}
|
||||
|
||||
abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
|
||||
static const _analysisServiceOpCountThreshold = 400;
|
||||
|
||||
final EventBus _eventBus = EventBus();
|
||||
|
||||
@override
|
||||
|
@ -86,6 +88,15 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
invalidateTagFilterSummary(entries);
|
||||
}
|
||||
|
||||
void updateDerivedFilters([Set<AvesEntry>? entries]) {
|
||||
_invalidate(entries);
|
||||
// it is possible for entries hidden by a filter type, to have an impact on other types
|
||||
// e.g. given a sole entry for country C and tag T, hiding T should make C disappear too
|
||||
updateDirectories();
|
||||
updateLocations();
|
||||
updateTags();
|
||||
}
|
||||
|
||||
void addEntries(Set<AvesEntry> entries) {
|
||||
if (entries.isEmpty) return;
|
||||
|
||||
|
@ -113,11 +124,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
|
||||
entries.forEach((v) => _entryById.remove(v.contentId));
|
||||
_rawEntries.removeAll(entries);
|
||||
_invalidate(entries);
|
||||
|
||||
cleanEmptyAlbums(entries.map((entry) => entry.directory).toSet());
|
||||
updateLocations();
|
||||
updateTags();
|
||||
updateDerivedFilters(entries);
|
||||
eventBus.fire(EntryRemovedEvent(entries));
|
||||
}
|
||||
|
||||
|
@ -252,20 +259,43 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
|
||||
Future<void> init();
|
||||
|
||||
Future<void> refresh();
|
||||
Future<void> refresh({AnalysisController? analysisController});
|
||||
|
||||
Future<Set<String>> refreshUris(Set<String> changedUris);
|
||||
Future<Set<String>> refreshUris(Set<String> changedUris, {AnalysisController? analysisController});
|
||||
|
||||
Future<void> rescan(Set<AvesEntry> entries);
|
||||
Future<void> refreshEntry(AvesEntry entry) async {
|
||||
await entry.refresh(background: false, persist: true, force: true);
|
||||
updateDerivedFilters({entry});
|
||||
eventBus.fire(EntryRefreshedEvent({entry}));
|
||||
}
|
||||
|
||||
Future<void> refreshMetadata(Set<AvesEntry> entries) async {
|
||||
await Future.forEach<AvesEntry>(entries, (entry) => entry.refresh(persist: true));
|
||||
|
||||
_invalidate(entries);
|
||||
updateLocations();
|
||||
updateTags();
|
||||
|
||||
eventBus.fire(EntryRefreshedEvent(entries));
|
||||
Future<void> analyze(AnalysisController? analysisController, Set<AvesEntry> candidateEntries) async {
|
||||
final todoEntries = visibleEntries;
|
||||
final _analysisController = analysisController ?? AnalysisController();
|
||||
if (!_analysisController.isStopping) {
|
||||
late bool startAnalysisService;
|
||||
if (_analysisController.canStartService && settings.canUseAnalysisService) {
|
||||
final force = _analysisController.force;
|
||||
var opCount = 0;
|
||||
opCount += (force ? todoEntries : todoEntries.where(TagMixin.catalogEntriesTest)).length;
|
||||
opCount += (force ? todoEntries.where((entry) => entry.hasGps) : todoEntries.where(LocationMixin.locateCountriesTest)).length;
|
||||
if (await availability.canLocatePlaces) {
|
||||
opCount += (force ? todoEntries.where((entry) => entry.hasGps) : todoEntries.where(LocationMixin.locatePlacesTest)).length;
|
||||
}
|
||||
startAnalysisService = opCount > _analysisServiceOpCountThreshold;
|
||||
} else {
|
||||
startAnalysisService = false;
|
||||
}
|
||||
if (startAnalysisService) {
|
||||
await AnalysisService.startService();
|
||||
} else {
|
||||
await catalogEntries(_analysisController, candidateEntries);
|
||||
updateDerivedFilters(candidateEntries);
|
||||
await locateEntries(_analysisController, candidateEntries);
|
||||
updateDerivedFilters(candidateEntries);
|
||||
}
|
||||
}
|
||||
stateNotifier.value = SourceState.ready;
|
||||
}
|
||||
|
||||
// monitoring
|
||||
|
@ -312,46 +342,45 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
settings.searchHistory = settings.searchHistory..removeWhere(filters.contains);
|
||||
}
|
||||
settings.hiddenFilters = hiddenFilters;
|
||||
|
||||
_invalidate();
|
||||
// it is possible for entries hidden by a filter type, to have an impact on other types
|
||||
// e.g. given a sole entry for country C and tag T, hiding T should make C disappear too
|
||||
updateDirectories();
|
||||
updateLocations();
|
||||
updateTags();
|
||||
|
||||
updateDerivedFilters();
|
||||
eventBus.fire(FilterVisibilityChangedEvent(filters, visible));
|
||||
|
||||
if (visible) {
|
||||
refreshMetadata(visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet());
|
||||
final candidateEntries = visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet();
|
||||
analyze(null, candidateEntries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class EntryAddedEvent {
|
||||
final Set<AvesEntry>? entries;
|
||||
|
||||
const EntryAddedEvent([this.entries]);
|
||||
}
|
||||
|
||||
@immutable
|
||||
class EntryRemovedEvent {
|
||||
final Set<AvesEntry> entries;
|
||||
|
||||
const EntryRemovedEvent(this.entries);
|
||||
}
|
||||
|
||||
@immutable
|
||||
class EntryMovedEvent {
|
||||
final Set<AvesEntry> entries;
|
||||
|
||||
const EntryMovedEvent(this.entries);
|
||||
}
|
||||
|
||||
@immutable
|
||||
class EntryRefreshedEvent {
|
||||
final Set<AvesEntry> entries;
|
||||
|
||||
const EntryRefreshedEvent(this.entries);
|
||||
}
|
||||
|
||||
@immutable
|
||||
class FilterVisibilityChangedEvent {
|
||||
final Set<CollectionFilter> filters;
|
||||
final bool visible;
|
||||
|
@ -359,6 +388,7 @@ class FilterVisibilityChangedEvent {
|
|||
const FilterVisibilityChangedEvent(this.filters, this.visible);
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ProgressEvent {
|
||||
final int done, total;
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
enum SourceState { loading, cataloguing, locating, ready }
|
||||
enum SourceState { loading, cataloguing, locatingCountries, locatingPlaces, ready }
|
||||
|
||||
enum ChipSortFactor { date, name, count }
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import 'package:aves/geo/countries.dart';
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/location.dart';
|
||||
import 'package:aves/model/metadata/address.dart';
|
||||
import 'package:aves/model/source/analysis_controller.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
|
@ -12,38 +13,43 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:tuple/tuple.dart';
|
||||
|
||||
mixin LocationMixin on SourceBase {
|
||||
static const _commitCountThreshold = 50;
|
||||
static const _commitCountThreshold = 80;
|
||||
static const _stopCheckCountThreshold = 20;
|
||||
|
||||
List<String> sortedCountries = List.unmodifiable([]);
|
||||
List<String> sortedPlaces = List.unmodifiable([]);
|
||||
|
||||
Future<void> loadAddresses() async {
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final saved = await metadataDb.loadAddresses();
|
||||
final idMap = entryById;
|
||||
saved.forEach((metadata) => idMap[metadata.contentId]?.addressDetails = metadata);
|
||||
// debugPrint('$runtimeType loadAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
|
||||
onAddressMetadataChanged();
|
||||
}
|
||||
|
||||
Future<void> locateEntries() async {
|
||||
await _locateCountries();
|
||||
await _locatePlaces();
|
||||
Future<void> locateEntries(AnalysisController controller, Set<AvesEntry> candidateEntries) async {
|
||||
await _locateCountries(controller, candidateEntries);
|
||||
await _locatePlaces(controller, candidateEntries);
|
||||
}
|
||||
|
||||
static bool locateCountriesTest(AvesEntry entry) => entry.hasGps && !entry.hasAddress;
|
||||
|
||||
static bool locatePlacesTest(AvesEntry entry) => entry.hasGps && !entry.hasFineAddress;
|
||||
|
||||
// quick reverse geocoding to find the countries, using an offline asset
|
||||
Future<void> _locateCountries() async {
|
||||
final todo = visibleEntries.where((entry) => entry.hasGps && !entry.hasAddress).toSet();
|
||||
Future<void> _locateCountries(AnalysisController controller, Set<AvesEntry> candidateEntries) async {
|
||||
if (controller.isStopping) return;
|
||||
|
||||
final force = controller.force;
|
||||
final todo = (force ? candidateEntries.where((entry) => entry.hasGps) : candidateEntries.where(locateCountriesTest)).toSet();
|
||||
if (todo.isEmpty) return;
|
||||
|
||||
stateNotifier.value = SourceState.locating;
|
||||
stateNotifier.value = SourceState.locatingCountries;
|
||||
var progressDone = 0;
|
||||
final progressTotal = todo.length;
|
||||
setProgress(done: progressDone, total: progressTotal);
|
||||
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final countryCodeMap = await countryTopology.countryCodeMap(todo.map((entry) => entry.latLng!).toSet());
|
||||
final newAddresses = <AddressDetails>[];
|
||||
final newAddresses = <AddressDetails>{};
|
||||
todo.forEach((entry) {
|
||||
final position = entry.latLng;
|
||||
final countryCode = countryCodeMap.entries.firstWhereOrNull((kv) => kv.value.contains(position))?.key;
|
||||
|
@ -54,19 +60,18 @@ mixin LocationMixin on SourceBase {
|
|||
setProgress(done: ++progressDone, total: progressTotal);
|
||||
});
|
||||
if (newAddresses.isNotEmpty) {
|
||||
await metadataDb.saveAddresses(Set.of(newAddresses));
|
||||
await metadataDb.saveAddresses(Set.unmodifiable(newAddresses));
|
||||
onAddressMetadataChanged();
|
||||
}
|
||||
// debugPrint('$runtimeType _locateCountries complete in ${stopwatch.elapsed.inMilliseconds}ms');
|
||||
}
|
||||
|
||||
// full reverse geocoding, requiring Play Services and some connectivity
|
||||
Future<void> _locatePlaces() async {
|
||||
Future<void> _locatePlaces(AnalysisController controller, Set<AvesEntry> candidateEntries) async {
|
||||
if (controller.isStopping) return;
|
||||
if (!(await availability.canLocatePlaces)) return;
|
||||
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final byLocated = groupBy<AvesEntry, bool>(visibleEntries.where((entry) => entry.hasGps), (entry) => entry.hasFineAddress);
|
||||
final todo = byLocated[false] ?? [];
|
||||
final force = controller.force;
|
||||
final todo = (force ? candidateEntries.where((entry) => entry.hasGps) : candidateEntries.where(locatePlacesTest)).toSet();
|
||||
if (todo.isEmpty) return;
|
||||
|
||||
// geocoder calls take between 150ms and 250ms
|
||||
|
@ -81,28 +86,31 @@ mixin LocationMixin on SourceBase {
|
|||
final latLngFactor = pow(10, 2);
|
||||
Tuple2<int, int> approximateLatLng(AvesEntry entry) {
|
||||
// entry has coordinates
|
||||
final lat = entry.catalogMetadata!.latitude!;
|
||||
final lng = entry.catalogMetadata!.longitude!;
|
||||
final catalogMetadata = entry.catalogMetadata!;
|
||||
final lat = catalogMetadata.latitude!;
|
||||
final lng = catalogMetadata.longitude!;
|
||||
return Tuple2<int, int>((lat * latLngFactor).round(), (lng * latLngFactor).round());
|
||||
}
|
||||
|
||||
final located = visibleEntries.where((entry) => entry.hasGps).toSet().difference(todo);
|
||||
final knownLocations = <Tuple2<int, int>, AddressDetails?>{};
|
||||
byLocated[true]?.forEach((entry) {
|
||||
located.forEach((entry) {
|
||||
knownLocations.putIfAbsent(approximateLatLng(entry), () => entry.addressDetails);
|
||||
});
|
||||
|
||||
stateNotifier.value = SourceState.locating;
|
||||
stateNotifier.value = SourceState.locatingPlaces;
|
||||
var progressDone = 0;
|
||||
final progressTotal = todo.length;
|
||||
setProgress(done: progressDone, total: progressTotal);
|
||||
|
||||
final newAddresses = <AddressDetails>[];
|
||||
await Future.forEach<AvesEntry>(todo, (entry) async {
|
||||
var stopCheckCount = 0;
|
||||
final newAddresses = <AddressDetails>{};
|
||||
for (final entry in todo) {
|
||||
final latLng = approximateLatLng(entry);
|
||||
if (knownLocations.containsKey(latLng)) {
|
||||
entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId);
|
||||
} else {
|
||||
await entry.locatePlace(background: true);
|
||||
await entry.locatePlace(background: true, force: force);
|
||||
// it is intended to insert `null` if the geocoder failed,
|
||||
// so that we skip geocoding of following entries with the same coordinates
|
||||
knownLocations[latLng] = entry.addressDetails;
|
||||
|
@ -110,18 +118,21 @@ mixin LocationMixin on SourceBase {
|
|||
if (entry.hasFineAddress) {
|
||||
newAddresses.add(entry.addressDetails!);
|
||||
if (newAddresses.length >= _commitCountThreshold) {
|
||||
await metadataDb.saveAddresses(Set.of(newAddresses));
|
||||
await metadataDb.saveAddresses(Set.unmodifiable(newAddresses));
|
||||
onAddressMetadataChanged();
|
||||
newAddresses.clear();
|
||||
}
|
||||
if (++stopCheckCount >= _stopCheckCountThreshold) {
|
||||
stopCheckCount = 0;
|
||||
if (controller.isStopping) return;
|
||||
}
|
||||
}
|
||||
setProgress(done: ++progressDone, total: progressTotal);
|
||||
});
|
||||
}
|
||||
if (newAddresses.isNotEmpty) {
|
||||
await metadataDb.saveAddresses(Set.of(newAddresses));
|
||||
await metadataDb.saveAddresses(Set.unmodifiable(newAddresses));
|
||||
onAddressMetadataChanged();
|
||||
}
|
||||
// debugPrint('$runtimeType _locatePlaces complete in ${stopwatch.elapsed.inSeconds}s');
|
||||
}
|
||||
|
||||
void onAddressMetadataChanged() {
|
||||
|
|
|
@ -5,6 +5,7 @@ import 'package:aves/model/covers.dart';
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/favourites.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/analysis_controller.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
|
@ -42,7 +43,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<void> refresh() async {
|
||||
Future<void> refresh({AnalysisController? analysisController}) async {
|
||||
assert(_initialized);
|
||||
debugPrint('$runtimeType refresh start');
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
@ -59,10 +60,10 @@ class MediaStoreSource extends CollectionSource {
|
|||
// show known entries
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} add known entries');
|
||||
addEntries(oldEntries);
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} load catalog metadata');
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} load metadata');
|
||||
await loadCatalogMetadata();
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} load address metadata');
|
||||
await loadAddresses();
|
||||
updateDerivedFilters();
|
||||
|
||||
// clean up obsolete entries
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries');
|
||||
|
@ -110,11 +111,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
updateDirectories();
|
||||
}
|
||||
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} catalog entries');
|
||||
await catalogEntries();
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} locate entries');
|
||||
await locateEntries();
|
||||
stateNotifier.value = SourceState.ready;
|
||||
await analyze(analysisController, visibleEntries);
|
||||
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} done for ${oldEntries.length} known, ${allNewEntries.length} new, ${obsoleteContentIds.length} obsolete');
|
||||
},
|
||||
|
@ -128,7 +125,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
// For example, when taking a picture with a Galaxy S10e default camera app, querying the Media Store
|
||||
// sometimes yields an entry with its temporary path: `/data/sec/camera/!@#$%^..._temp.jpg`
|
||||
@override
|
||||
Future<Set<String>> refreshUris(Set<String> changedUris) async {
|
||||
Future<Set<String>> refreshUris(Set<String> changedUris, {AnalysisController? analysisController}) async {
|
||||
if (!_initialized || !isMonitoring) return changedUris;
|
||||
|
||||
debugPrint('$runtimeType refreshUris ${changedUris.length} uris');
|
||||
|
@ -181,18 +178,10 @@ class MediaStoreSource extends CollectionSource {
|
|||
addEntries(newEntries);
|
||||
await metadataDb.saveEntries(newEntries);
|
||||
cleanEmptyAlbums(existingDirectories);
|
||||
await catalogEntries();
|
||||
await locateEntries();
|
||||
stateNotifier.value = SourceState.ready;
|
||||
|
||||
await analyze(analysisController, newEntries);
|
||||
}
|
||||
|
||||
return tempUris;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> rescan(Set<AvesEntry> entries) async {
|
||||
final contentIds = entries.map((entry) => entry.contentId).whereNotNull().toSet();
|
||||
await metadataDb.removeIds(contentIds, metadataOnly: true);
|
||||
return refresh();
|
||||
}
|
||||
}
|
||||
|
|
19
lib/model/source/source_state.dart
Normal file
19
lib/model/source/source_state.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/tag.dart';
|
||||
import 'package:aves/model/metadata/catalog.dart';
|
||||
import 'package:aves/model/source/analysis_controller.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
|
@ -8,22 +9,25 @@ import 'package:collection/collection.dart';
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
mixin TagMixin on SourceBase {
|
||||
static const _commitCountThreshold = 300;
|
||||
static const _commitCountThreshold = 400;
|
||||
static const _stopCheckCountThreshold = 100;
|
||||
|
||||
List<String> sortedTags = List.unmodifiable([]);
|
||||
|
||||
Future<void> loadCatalogMetadata() async {
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final saved = await metadataDb.loadMetadataEntries();
|
||||
final idMap = entryById;
|
||||
saved.forEach((metadata) => idMap[metadata.contentId]?.catalogMetadata = metadata);
|
||||
// debugPrint('$runtimeType loadCatalogMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
|
||||
onCatalogMetadataChanged();
|
||||
}
|
||||
|
||||
Future<void> catalogEntries() async {
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final todo = visibleEntries.where((entry) => !entry.isCatalogued).toList();
|
||||
static bool catalogEntriesTest(AvesEntry entry) => !entry.isCatalogued;
|
||||
|
||||
Future<void> catalogEntries(AnalysisController controller, Set<AvesEntry> candidateEntries) async {
|
||||
if (controller.isStopping) return;
|
||||
|
||||
final force = controller.force;
|
||||
final todo = force ? candidateEntries : candidateEntries.where(catalogEntriesTest).toSet();
|
||||
if (todo.isEmpty) return;
|
||||
|
||||
stateNotifier.value = SourceState.cataloguing;
|
||||
|
@ -31,22 +35,26 @@ mixin TagMixin on SourceBase {
|
|||
final progressTotal = todo.length;
|
||||
setProgress(done: progressDone, total: progressTotal);
|
||||
|
||||
final newMetadata = <CatalogMetadata>[];
|
||||
await Future.forEach<AvesEntry>(todo, (entry) async {
|
||||
await entry.catalog(background: true);
|
||||
var stopCheckCount = 0;
|
||||
final newMetadata = <CatalogMetadata>{};
|
||||
for (final entry in todo) {
|
||||
await entry.catalog(background: true, persist: true, force: force);
|
||||
if (entry.isCatalogued) {
|
||||
newMetadata.add(entry.catalogMetadata!);
|
||||
if (newMetadata.length >= _commitCountThreshold) {
|
||||
await metadataDb.saveMetadata(Set.of(newMetadata));
|
||||
await metadataDb.saveMetadata(Set.unmodifiable(newMetadata));
|
||||
onCatalogMetadataChanged();
|
||||
newMetadata.clear();
|
||||
}
|
||||
if (++stopCheckCount >= _stopCheckCountThreshold) {
|
||||
stopCheckCount = 0;
|
||||
if (controller.isStopping) return;
|
||||
}
|
||||
}
|
||||
setProgress(done: ++progressDone, total: progressTotal);
|
||||
});
|
||||
await metadataDb.saveMetadata(Set.of(newMetadata));
|
||||
}
|
||||
await metadataDb.saveMetadata(Set.unmodifiable(newMetadata));
|
||||
onCatalogMetadataChanged();
|
||||
// debugPrint('$runtimeType catalogEntries complete in ${stopwatch.elapsed.inSeconds}s');
|
||||
}
|
||||
|
||||
void onCatalogMetadataChanged() {
|
||||
|
|
178
lib/services/analysis_service.dart
Normal file
178
lib/services/analysis_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -21,7 +21,9 @@ class GeocodingService {
|
|||
});
|
||||
return (result as List).cast<Map>().map((map) => Address.fromMap(map)).toList();
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
if (e.code != 'getAddress-empty') {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ import 'package:aves/widgets/common/providers/highlight_info_provider.dart';
|
|||
import 'package:aves/widgets/home_page.dart';
|
||||
import 'package:aves/widgets/welcome_page.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:fijkplayer/fijkplayer.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
@ -46,6 +47,7 @@ class _AvesAppState extends State<AvesApp> {
|
|||
List<NavigatorObserver> _navigatorObservers = [];
|
||||
final EventChannel _mediaStoreChangeChannel = const EventChannel('deckers.thibault/aves/media_store_change');
|
||||
final EventChannel _newIntentChannel = const EventChannel('deckers.thibault/aves/intent');
|
||||
final EventChannel _analysisCompletionChannel = const EventChannel('deckers.thibault/aves/analysis_events');
|
||||
final EventChannel _errorChannel = const EventChannel('deckers.thibault/aves/error');
|
||||
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator');
|
||||
|
||||
|
@ -58,6 +60,7 @@ class _AvesAppState extends State<AvesApp> {
|
|||
_appSetup = _setup();
|
||||
_mediaStoreChangeChannel.receiveBroadcastStream().listen((event) => _onMediaStoreChange(event as String?));
|
||||
_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map?));
|
||||
_analysisCompletionChannel.receiveBroadcastStream().listen((event) => _onAnalysisCompletion());
|
||||
_errorChannel.receiveBroadcastStream().listen((event) => _onError(event as String?));
|
||||
}
|
||||
|
||||
|
@ -144,9 +147,11 @@ class _AvesAppState extends State<AvesApp> {
|
|||
|
||||
Future<void> _setup() async {
|
||||
await settings.init(
|
||||
monitorPlatformSettings: true,
|
||||
isRotationLocked: await windowService.isRotationLocked(),
|
||||
areAnimationsRemoved: await AccessibilityService.areAnimationsRemoved(),
|
||||
);
|
||||
FijkLog.setLevel(FijkLogLevel.Warn);
|
||||
|
||||
// keep screen on
|
||||
settings.updateStream.where((key) => key == Settings.keepScreenOnKey).listen(
|
||||
|
@ -192,6 +197,13 @@ class _AvesAppState extends State<AvesApp> {
|
|||
));
|
||||
}
|
||||
|
||||
Future<void> _onAnalysisCompletion() async {
|
||||
debugPrint('Analysis completed');
|
||||
await _mediaStoreSource.loadCatalogMetadata();
|
||||
await _mediaStoreSource.loadAddresses();
|
||||
_mediaStoreSource.updateDerivedFilters();
|
||||
}
|
||||
|
||||
void _onMediaStoreChange(String? uri) {
|
||||
if (uri != null) changedUris.add(uri);
|
||||
if (changedUris.isNotEmpty) {
|
||||
|
|
|
@ -7,6 +7,7 @@ import 'package:aves/model/entry.dart';
|
|||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/highlight.dart';
|
||||
import 'package:aves/model/selection.dart';
|
||||
import 'package:aves/model/source/analysis_controller.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/services/common/image_op_events.dart';
|
||||
|
@ -75,7 +76,9 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
final selection = context.read<Selection<AvesEntry>>();
|
||||
final selectedItems = _getExpandedSelectedItems(selection);
|
||||
|
||||
source.rescan(selectedItems);
|
||||
final controller = AnalysisController(canStartService: false, force: true);
|
||||
source.analyze(controller, selectedItems);
|
||||
|
||||
selection.browse();
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/model/source/source_state.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -56,43 +57,29 @@ class SourceStateSubtitle extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String? subtitle;
|
||||
switch (source.stateNotifier.value) {
|
||||
case SourceState.loading:
|
||||
subtitle = context.l10n.sourceStateLoading;
|
||||
break;
|
||||
case SourceState.cataloguing:
|
||||
subtitle = context.l10n.sourceStateCataloguing;
|
||||
break;
|
||||
case SourceState.locating:
|
||||
subtitle = context.l10n.sourceStateLocating;
|
||||
break;
|
||||
case SourceState.ready:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
final subtitleStyle = Theme.of(context).textTheme.caption;
|
||||
return subtitle == null
|
||||
? const SizedBox.shrink()
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(subtitle, style: subtitleStyle),
|
||||
StreamBuilder<ProgressEvent>(
|
||||
stream: source.progressStream,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError || !snapshot.hasData) return const SizedBox.shrink();
|
||||
final progress = snapshot.data!;
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: 8),
|
||||
child: Text(
|
||||
'${progress.done}/${progress.total}',
|
||||
style: subtitleStyle!.copyWith(color: Colors.white30),
|
||||
),
|
||||
);
|
||||
},
|
||||
final sourceState = source.stateNotifier.value;
|
||||
final subtitle = sourceState.getName(context.l10n);
|
||||
if (subtitle == null) return const SizedBox();
|
||||
|
||||
final subtitleStyle = Theme.of(context).textTheme.caption!;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(subtitle, style: subtitleStyle),
|
||||
ValueListenableBuilder<ProgressEvent>(
|
||||
valueListenable: source.progressNotifier,
|
||||
builder: (context, progress, snapshot) {
|
||||
if (progress.total == 0 || sourceState == SourceState.locatingCountries) return const SizedBox();
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: 8),
|
||||
child: Text(
|
||||
'${progress.done}/${progress.total}',
|
||||
style: subtitleStyle.copyWith(color: Colors.white30),
|
||||
),
|
||||
],
|
||||
);
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/services/analysis_service.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/debug/android_apps.dart';
|
||||
|
@ -103,6 +104,10 @@ class _AppDebugPageState extends State<AppDebugPage> {
|
|||
},
|
||||
child: const Text('Source full refresh'),
|
||||
),
|
||||
const ElevatedButton(
|
||||
onPressed: AnalysisService.startService,
|
||||
child: Text('Start analysis service'),
|
||||
),
|
||||
const Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||
|
|
|
@ -38,6 +38,11 @@ class DebugSettingsSection extends StatelessWidget {
|
|||
onChanged: (v) => settings.hasAcceptedTerms = v,
|
||||
title: const Text('hasAcceptedTerms'),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.canUseAnalysisService,
|
||||
onChanged: (v) => settings.canUseAnalysisService = v,
|
||||
title: const Text('canUseAnalysisService'),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.videoShowRawTimedText,
|
||||
onChanged: (v) => settings.videoShowRawTimedText = v,
|
||||
|
|
|
@ -7,6 +7,7 @@ import 'package:aves/model/settings/home_page.dart';
|
|||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/services/analysis_service.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/services/global_search.dart';
|
||||
import 'package:aves/services/viewer_service.dart';
|
||||
|
@ -110,10 +111,11 @@ class _HomePageState extends State<HomePage> {
|
|||
|
||||
if (appMode != AppMode.view) {
|
||||
debugPrint('Storage check complete in ${stopwatch.elapsed.inMilliseconds}ms');
|
||||
unawaited(GlobalSearch.registerCallback());
|
||||
unawaited(AnalysisService.registerCallback());
|
||||
final source = context.read<CollectionSource>();
|
||||
await source.init();
|
||||
unawaited(source.refresh());
|
||||
unawaited(GlobalSearch.registerCallback());
|
||||
}
|
||||
|
||||
// `pushReplacement` is not enough in some edge cases
|
||||
|
@ -129,7 +131,7 @@ class _HomePageState extends State<HomePage> {
|
|||
final entry = await mediaFileService.getEntry(uri, mimeType);
|
||||
if (entry != null) {
|
||||
// cataloguing is essential for coordinates and video rotation
|
||||
await entry.catalog(background: false, persist: false);
|
||||
await entry.catalog(background: false, persist: false, force: false);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
|
|
@ -158,16 +158,18 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
|
|||
}
|
||||
|
||||
// when the entry changed (e.g. by scrolling through the PageView, or if the entry got deleted)
|
||||
void _onEntryChanged() {
|
||||
Future<void> _onEntryChanged() async {
|
||||
_oldEntry?.imageChangeNotifier.removeListener(_onImageChanged);
|
||||
_oldEntry = entry;
|
||||
|
||||
if (entry != null) {
|
||||
entry!.imageChangeNotifier.addListener(_onImageChanged);
|
||||
final _entry = entry;
|
||||
if (_entry != null) {
|
||||
_entry.imageChangeNotifier.addListener(_onImageChanged);
|
||||
// make sure to locate the entry,
|
||||
// so that we can display the address instead of coordinates
|
||||
// even when initial collection locating has not reached this entry yet
|
||||
entry!.catalog(background: false).then((_) => entry!.locate(background: false));
|
||||
await _entry.catalog(background: false, persist: true, force: false);
|
||||
await _entry.locate(background: false, force: false);
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
|
|
@ -40,9 +40,9 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
|||
final success = await apply();
|
||||
if (success) {
|
||||
if (_isMainMode(context) && source != null) {
|
||||
await source.refreshMetadata({entry});
|
||||
await source.refreshEntry(entry);
|
||||
} else {
|
||||
await entry.refresh(persist: false);
|
||||
await entry.refresh(background: false, persist: false, force: true);
|
||||
}
|
||||
showFeedback(context, l10n.genericSuccessFeedback);
|
||||
} else {
|
||||
|
|
|
@ -16,7 +16,6 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class IjkPlayerAvesVideoController extends AvesVideoController {
|
||||
static bool _staticInitialized = false;
|
||||
late FijkPlayer _instance;
|
||||
final List<StreamSubscription> _subscriptions = [];
|
||||
final StreamController<FijkValue> _valueStreamController = StreamController.broadcast();
|
||||
|
@ -55,10 +54,6 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
|
|||
static const gifLikeBitRateThreshold = 2 << 18; // 512kB/s (4Mb/s)
|
||||
|
||||
IjkPlayerAvesVideoController(AvesEntry entry) : super(entry) {
|
||||
if (!_staticInitialized) {
|
||||
FijkLog.setLevel(FijkLogLevel.Warn);
|
||||
_staticInitialized = true;
|
||||
}
|
||||
_instance = FijkPlayer();
|
||||
_valueStream.map((value) => value.videoRenderStart).firstWhere((v) => v, orElse: () => false).then(
|
||||
(started) => canCaptureFrameNotifier.value = started,
|
||||
|
|
|
@ -101,6 +101,8 @@ class _VectorImageViewState extends State<VectorImageView> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_displaySize == Size.zero) return widget.errorBuilder(context, 'Not sized', null);
|
||||
|
||||
return ValueListenableBuilder<ViewState>(
|
||||
valueListenable: viewStateNotifier,
|
||||
builder: (context, viewState, child) {
|
||||
|
|
|
@ -39,6 +39,9 @@ class FakeMetadataDb extends Fake implements MetadataDb {
|
|||
@override
|
||||
Future<List<AddressDetails>> loadAddresses() => SynchronousFuture([]);
|
||||
|
||||
@override
|
||||
Future<void> saveAddresses(Set<AddressDetails> addresses) => SynchronousFuture(null);
|
||||
|
||||
@override
|
||||
Future<void> updateAddressId(int oldId, AddressDetails? address) => SynchronousFuture(null);
|
||||
|
||||
|
|
|
@ -5,6 +5,10 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
class FakeMetadataFetchService extends Fake implements MetadataFetchService {
|
||||
final Map<AvesEntry, CatalogMetadata> _metaMap = {};
|
||||
|
||||
void setUp(AvesEntry entry, CatalogMetadata metadata) => _metaMap[entry] = metadata;
|
||||
|
||||
@override
|
||||
Future<CatalogMetadata?> getCatalogMetadata(AvesEntry entry, {bool background = false}) => SynchronousFuture(null);
|
||||
Future<CatalogMetadata?> getCatalogMetadata(AvesEntry entry, {bool background = false}) => SynchronousFuture(_metaMap[entry]);
|
||||
}
|
||||
|
|
|
@ -4,6 +4,9 @@ import 'package:aves/model/availability.dart';
|
|||
import 'package:aves/model/covers.dart';
|
||||
import 'package:aves/model/favourites.dart';
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/tag.dart';
|
||||
import 'package:aves/model/metadata/address.dart';
|
||||
import 'package:aves/model/metadata/catalog.dart';
|
||||
import 'package:aves/model/metadata_db.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
|
@ -20,6 +23,7 @@ import 'package:aves/services/window_service.dart';
|
|||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../fake/android_app_service.dart';
|
||||
|
@ -38,6 +42,13 @@ void main() {
|
|||
const sourceAlbum = '${FakeStorageService.primaryPath}Pictures/source';
|
||||
const destinationAlbum = '${FakeStorageService.primaryPath}Pictures/destination';
|
||||
|
||||
const aTag = 'sometag';
|
||||
final australiaLatLng = LatLng(-26, 141);
|
||||
const australiaAddress = AddressDetails(
|
||||
countryCode: 'AU',
|
||||
countryName: 'AUS',
|
||||
);
|
||||
|
||||
setUp(() async {
|
||||
// specify Posix style path context for consistent behaviour when running tests on Windows
|
||||
getIt.registerLazySingleton<p.Context>(() => p.Context(style: p.Style.posix));
|
||||
|
@ -53,7 +64,8 @@ void main() {
|
|||
getIt.registerLazySingleton<StorageService>(() => FakeStorageService());
|
||||
getIt.registerLazySingleton<WindowService>(() => FakeWindowService());
|
||||
|
||||
await settings.init();
|
||||
await settings.init(monitorPlatformSettings: false);
|
||||
settings.canUseAnalysisService = false;
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
|
@ -74,6 +86,57 @@ void main() {
|
|||
return source;
|
||||
}
|
||||
|
||||
test('album/country/tag hidden on launch when their items are hidden by entry prop', () async {
|
||||
settings.hiddenFilters = {const AlbumFilter(testAlbum, 'whatever')};
|
||||
|
||||
final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1');
|
||||
(mediaStoreService as FakeMediaStoreService).entries = {
|
||||
image1,
|
||||
};
|
||||
(metadataFetchService as FakeMetadataFetchService).setUp(
|
||||
image1,
|
||||
CatalogMetadata(
|
||||
contentId: image1.contentId,
|
||||
xmpSubjects: aTag,
|
||||
latitude: australiaLatLng.latitude,
|
||||
longitude: australiaLatLng.longitude,
|
||||
),
|
||||
);
|
||||
|
||||
final source = await _initSource();
|
||||
expect(source.rawAlbums.length, 0);
|
||||
expect(source.sortedCountries.length, 0);
|
||||
expect(source.sortedTags.length, 0);
|
||||
});
|
||||
|
||||
test('album/country/tag hidden on launch when their items are hidden by metadata', () async {
|
||||
settings.hiddenFilters = {TagFilter(aTag)};
|
||||
|
||||
final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1');
|
||||
(mediaStoreService as FakeMediaStoreService).entries = {
|
||||
image1,
|
||||
};
|
||||
(metadataFetchService as FakeMetadataFetchService).setUp(
|
||||
image1,
|
||||
CatalogMetadata(
|
||||
contentId: image1.contentId,
|
||||
xmpSubjects: aTag,
|
||||
latitude: australiaLatLng.latitude,
|
||||
longitude: australiaLatLng.longitude,
|
||||
),
|
||||
);
|
||||
expect(image1.xmpSubjects, []);
|
||||
|
||||
final source = await _initSource();
|
||||
expect(image1.xmpSubjects, [aTag]);
|
||||
expect(image1.addressDetails, australiaAddress.copyWith(contentId: image1.contentId));
|
||||
|
||||
expect(source.visibleEntries.length, 0);
|
||||
expect(source.rawAlbums.length, 0);
|
||||
expect(source.sortedCountries.length, 0);
|
||||
expect(source.sortedTags.length, 0);
|
||||
});
|
||||
|
||||
test('add/remove favourite entry', () async {
|
||||
final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1');
|
||||
(mediaStoreService as FakeMediaStoreService).entries = {
|
||||
|
|
|
@ -26,7 +26,7 @@ void main() {
|
|||
}
|
||||
|
||||
Future<void> configureAndLaunch() async {
|
||||
await settings.init();
|
||||
await settings.init(monitorPlatformSettings: false);
|
||||
settings
|
||||
..keepScreenOn = KeepScreenOn.always
|
||||
..hasAcceptedTerms = false
|
||||
|
|
Loading…
Reference in a new issue