diff --git a/android/app/build.gradle b/android/app/build.gradle index 01584967c..42cdb453a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -6,6 +6,8 @@ plugins { id 'com.google.firebase.crashlytics' } +def appId = "deckers.thibault.aves" + // Flutter properties def localProperties = new Properties() @@ -52,13 +54,14 @@ android { } defaultConfig { - applicationId "deckers.thibault.aves" + applicationId appId minSdkVersion 20 targetSdkVersion 30 versionCode flutterVersionCode.toInteger() versionName flutterVersionName manifestPlaceholders = [googleApiKey: keystoreProperties['googleApiKey']] multiDexEnabled true + resValue 'string', 'search_provider', "${appId}.search_provider" } signingConfigs { @@ -73,9 +76,11 @@ android { buildTypes { debug { applicationIdSuffix ".debug" + resValue 'string', 'search_provider', "${appId}.debug.search_provider" } profile { applicationIdSuffix ".profile" + resValue 'string', 'search_provider', "${appId}.profile.search_provider" } release { // specify architectures, to specifically exclude native libs for x86, diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1aa2beace..40cdd7c08 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -38,6 +38,7 @@ - - - @@ -65,6 +58,7 @@ + @@ -108,11 +102,26 @@ + + + + + + + + + + diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index 2f726d6b6..3859f9a9e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -1,5 +1,6 @@ package deckers.thibault.aves +import android.app.SearchManager import android.content.Intent import android.net.Uri import android.os.Build @@ -40,6 +41,7 @@ class MainActivity : FlutterActivity() { MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this)) MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this)) MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this)) + MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this)) MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this)) MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(MetadataHandler(this)) MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this)) @@ -146,6 +148,17 @@ class MainActivity : FlutterActivity() { "mimeType" to intent.type, ) } + Intent.ACTION_SEARCH -> { + return hashMapOf( + "action" to "search", + "query" to intent.getStringExtra(SearchManager.QUERY), + "mimeType" to intent.getStringExtra(SearchManager.EXTRA_DATA_KEY), + "uri" to intent.dataString + ) + } + else -> { + Log.w(LOG_TAG, "unhandled intent action=${intent?.action}") + } } return HashMap() } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt new file mode 100644 index 000000000..ba332db1c --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt @@ -0,0 +1,174 @@ +package deckers.thibault.aves + +import android.app.SearchManager +import android.content.ContentProvider +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.util.Log +import deckers.thibault.aves.model.FieldMap +import deckers.thibault.aves.utils.LogUtils +import io.flutter.FlutterInjector +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.dart.DartExecutor +import io.flutter.embedding.engine.loader.FlutterLoader +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.view.FlutterCallbackInformation +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.util.* +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvider() { + override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? { + return selectionArgs?.firstOrNull()?.let { query -> + // Samsung Finder does not support: + // - resource ID as value for SUGGEST_COLUMN_ICON_1 + // - SUGGEST_COLUMN_ICON_2 + // - SUGGEST_COLUMN_RESULT_CARD_IMAGE + val columns = arrayOf( + SearchManager.SUGGEST_COLUMN_INTENT_DATA, + SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) SearchManager.SUGGEST_COLUMN_CONTENT_TYPE else "mimeType", + SearchManager.SUGGEST_COLUMN_TEXT_1, + SearchManager.SUGGEST_COLUMN_TEXT_2, + SearchManager.SUGGEST_COLUMN_ICON_1, + ) + + val matrixCursor = MatrixCursor(columns) + context?.let { context -> + runBlocking { + getSuggestions(context, query).forEach { + val data = it["data"] + val mimeType = it["mimeType"] + val title = it["title"] + val subtitle = it["subtitle"] + val iconUri = it["iconUri"] + matrixCursor.addRow(arrayOf(data, mimeType, mimeType, title, subtitle, iconUri)) + } + } + } + matrixCursor + } + } + + private suspend fun getSuggestions(context: Context, query: String): List { + if (backgroundFlutterEngine == null) { + initFlutterEngine(context) + } + + val messenger = backgroundFlutterEngine!!.dartExecutor.binaryMessenger + val backgroundChannel = MethodChannel(messenger, BACKGROUND_CHANNEL) + backgroundChannel.setMethodCallHandler(this) + + return suspendCoroutine { cont -> + GlobalScope.launch { + context.runOnUiThread { + backgroundChannel.invokeMethod("getSuggestions", hashMapOf( + "query" to query, + "locale" to Locale.getDefault().toString(), + ), object : MethodChannel.Result { + override fun success(result: Any?) { + @Suppress("UNCHECKED_CAST") + cont.resume(result as List) + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + cont.resumeWithException(Exception("$errorCode: $errorMessage\n$errorDetails")) + } + + override fun notImplemented() { + cont.resumeWithException(NotImplementedError("getSuggestions")) + } + }) + } + } + } + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "initialized" -> { + Log.d(LOG_TAG, "background channel is ready") + result.success(null) + } + else -> result.notImplemented() + } + } + + override fun onCreate(): Boolean = true + + override fun getType(uri: Uri): String? = null + + override fun insert(uri: Uri, values: ContentValues?): Uri = + throw UnsupportedOperationException() + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = + throw UnsupportedOperationException() + + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = + throw UnsupportedOperationException() + + companion object { + private val LOG_TAG = LogUtils.createTag() + private const val BACKGROUND_CHANNEL = "deckers.thibault/aves/global_search_background" + const val SHARED_PREFERENCES_KEY = "platform_search" + const val CALLBACK_HANDLE_KEY = "callback_handle" + + private var backgroundFlutterEngine: FlutterEngine? = null + + private suspend fun initFlutterEngine(context: Context) { + val callbackHandle = context.getSharedPreferences(SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE).getLong(CALLBACK_HANDLE_KEY, 0) + if (callbackHandle == 0L) { + Log.e(LOG_TAG, "failed to retrieve registered callback handle") + return + } + + lateinit var flutterLoader: FlutterLoader + context.runOnUiThread { + // initialization must happen on the main thread + flutterLoader = FlutterInjector.instance().flutterLoader().apply { + startInitialization(context) + ensureInitializationComplete(context, null) + } + } + + val callbackInfo = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle) + if (callbackInfo == null) { + Log.e(LOG_TAG, "failed to find callback information") + return + } + + val args = DartExecutor.DartCallback( + context.assets, + flutterLoader.findAppBundlePath(), + callbackInfo + ) + context.runOnUiThread { + // initialization must happen on the main thread + backgroundFlutterEngine = FlutterEngine(context).apply { + dartExecutor.executeDartCallback(args) + } + } + } + + // convenience methods + + private suspend fun Context.runOnUiThread(r: Runnable) { + suspendCoroutine { cont -> + Handler(mainLooper).post { + r.run() + cont.resume(true) + } + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt index 51a883a0a..14b66a9c1 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt @@ -255,7 +255,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { return when (uri.scheme?.lowercase(Locale.ROOT)) { ContentResolver.SCHEME_FILE -> { uri.path?.let { path -> - val authority = "${context.applicationContext.packageName}.fileprovider" + val authority = "${context.applicationContext.packageName}.file_provider" FileProvider.getUriForFile(context, authority, File(path)) } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt index 6e0f1252e..ad877204b 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt @@ -193,7 +193,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler { } } } - val authority = "${context.applicationContext.packageName}.fileprovider" + val authority = "${context.applicationContext.packageName}.file_provider" val uri = if (displayName != null) { // add extension to ease type identification when sharing this content val displayNameWithExtension = if (extension == null || displayName.endsWith(extension, ignoreCase = true)) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GlobalSearchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GlobalSearchHandler.kt new file mode 100644 index 000000000..a501a80a0 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GlobalSearchHandler.kt @@ -0,0 +1,40 @@ +package deckers.thibault.aves.channel.calls + +import android.app.Activity +import android.content.Context +import android.util.Log +import deckers.thibault.aves.SearchSuggestionsProvider +import deckers.thibault.aves.channel.calls.Coresult.Companion.safe +import deckers.thibault.aves.utils.LogUtils +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler + +class GlobalSearchHandler(private val context: Activity) : MethodCallHandler { + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "registerCallback" -> safe(call, result, ::registerCallback) + else -> result.notImplemented() + } + } + + private fun registerCallback(call: MethodCall, result: MethodChannel.Result) { + val callbackHandle = call.argument("callbackHandle")?.toLong() + if (callbackHandle == null) { + result.error("registerCallback-args", "failed because of missing arguments", null) + return + } + + Log.i(LOG_TAG, "register global search callback") + context.getSharedPreferences(SearchSuggestionsProvider.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE) + .edit() + .putLong(SearchSuggestionsProvider.CALLBACK_HANDLE_KEY, callbackHandle) + .apply() + result.success(true) + } + + companion object { + private val LOG_TAG = LogUtils.createTag() + const val CHANNEL = "deckers.thibault/aves/global_search" + } +} \ No newline at end of file diff --git a/android/app/src/main/res/xml/searchable.xml b/android/app/src/main/res/xml/searchable.xml new file mode 100644 index 000000000..d86475b2f --- /dev/null +++ b/android/app/src/main/res/xml/searchable.xml @@ -0,0 +1,8 @@ + + diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index 7813045cc..d2fc9c400 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -30,6 +30,8 @@ abstract class MetadataDb { Future updateEntryId(int oldId, AvesEntry entry); + Future> searchEntries(String query, {int? limit}); + // date taken Future clearDates(); @@ -235,6 +237,19 @@ class SqfliteMetadataDb implements MetadataDb { ); } + @override + Future> searchEntries(String query, {int? limit}) async { + final db = await _database; + final maps = await db.query( + entryTable, + where: 'title LIKE ?', + whereArgs: ['%$query%'], + orderBy: 'sourceDateTakenMillis DESC', + limit: limit, + ); + return maps.map((map) => AvesEntry.fromMap(map)).toSet(); + } + // date taken @override diff --git a/lib/services/global_search.dart b/lib/services/global_search.dart new file mode 100644 index 000000000..1587e982b --- /dev/null +++ b/lib/services/global_search.dart @@ -0,0 +1,66 @@ +import 'dart:ui'; + +import 'package:aves/services/services.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:intl/intl.dart'; + +class GlobalSearch { + static const platform = MethodChannel('deckers.thibault/aves/global_search'); + + static Future registerCallback() async { + try { + await platform.invokeMethod('registerCallback', { + 'callbackHandle': PluginUtilities.getCallbackHandle(_init)?.toRawHandle(), + }); + } on PlatformException catch (e) { + debugPrint('registerCallback failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + } +} + +Future _init() async { + WidgetsFlutterBinding.ensureInitialized(); + + // service initialization for path context, database + initPlatformServices(); + await metadataDb.init(); + + // `intl` initialization for date formatting + await initializeDateFormatting(); + + const _channel = MethodChannel('deckers.thibault/aves/global_search_background'); + _channel.setMethodCallHandler((call) async { + switch (call.method) { + case 'getSuggestions': + return await _getSuggestions(call.arguments); + default: + throw PlatformException(code: 'not-implemented', message: 'failed to handle method=${call.method}'); + } + }); + await _channel.invokeMethod('initialized'); +} + +Future>> _getSuggestions(dynamic args) async { + final suggestions = >[]; + if (args is Map) { + final query = args['query']; + final locale = args['locale']; + if (query is String && locale is String) { + final entries = await metadataDb.searchEntries(query, limit: 10); + suggestions.addAll(entries.map((entry) { + final date = entry.bestDate; + return { + 'data': entry.uri, + 'mimeType': entry.mimeType, + 'title': entry.bestTitle, + 'subtitle': date != null ? '${DateFormat.yMMMd(locale).format(date)} • ${DateFormat.Hm(locale).format(date)}' : null, + 'iconUri': entry.uri, + }; + })); + } + } + return suggestions; +} diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index 39bc3722e..40ec7576e 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -6,6 +6,7 @@ import 'package:aves/model/settings/screen_on.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/services/global_search.dart'; import 'package:aves/services/services.dart'; import 'package:aves/services/viewer_service.dart'; import 'package:aves/utils/android_file_utils.dart'; @@ -75,6 +76,7 @@ class _HomePageState extends State { final action = intentData['action']; switch (action) { case 'view': + case 'search': _viewerEntry = await _initViewerEntry( uri: intentData['uri'], mimeType: intentData['mimeType'], @@ -107,6 +109,7 @@ class _HomePageState extends State { final source = context.read(); await source.init(); unawaited(source.refresh()); + unawaited(GlobalSearch.registerCallback()); } // `pushReplacement` is not enough in some edge cases diff --git a/scripts/fix_android_log_levels.bat b/scripts/fix_android_log_levels.bat index 537e02b8f..bfdc76a24 100644 --- a/scripts/fix_android_log_levels.bat +++ b/scripts/fix_android_log_levels.bat @@ -12,6 +12,7 @@ adb.exe shell setprop log.tag.AHierarchicalStateMachine ERROR adb.exe shell setprop log.tag.AudioCapabilities ERROR adb.exe shell setprop log.tag.AudioTrack INFO adb.exe shell setprop log.tag.CompatibilityChangeReporter INFO +adb.exe shell setprop log.tag.CustomizedTextParser INFO adb.exe shell setprop log.tag.InputMethodManager WARN adb.exe shell setprop log.tag.InsetsSourceConsumer INFO adb.exe shell setprop log.tag.InputTransport INFO