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