integration to android global search / samsung finder

This commit is contained in:
Thibault Deckers 2021-07-27 17:16:02 +09:00
parent 4df90602f4
commit d04adf52a2
12 changed files with 352 additions and 12 deletions

View file

@ -6,6 +6,8 @@ plugins {
id 'com.google.firebase.crashlytics' id 'com.google.firebase.crashlytics'
} }
def appId = "deckers.thibault.aves"
// Flutter properties // Flutter properties
def localProperties = new Properties() def localProperties = new Properties()
@ -52,13 +54,14 @@ android {
} }
defaultConfig { defaultConfig {
applicationId "deckers.thibault.aves" applicationId appId
minSdkVersion 20 minSdkVersion 20
targetSdkVersion 30 targetSdkVersion 30
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
manifestPlaceholders = [googleApiKey: keystoreProperties['googleApiKey']] manifestPlaceholders = [googleApiKey: keystoreProperties['googleApiKey']]
multiDexEnabled true multiDexEnabled true
resValue 'string', 'search_provider', "${appId}.search_provider"
} }
signingConfigs { signingConfigs {
@ -73,9 +76,11 @@ android {
buildTypes { buildTypes {
debug { debug {
applicationIdSuffix ".debug" applicationIdSuffix ".debug"
resValue 'string', 'search_provider', "${appId}.debug.search_provider"
} }
profile { profile {
applicationIdSuffix ".profile" applicationIdSuffix ".profile"
resValue 'string', 'search_provider', "${appId}.profile.search_provider"
} }
release { release {
// specify architectures, to specifically exclude native libs for x86, // specify architectures, to specifically exclude native libs for x86,

View file

@ -38,6 +38,7 @@
</queries> </queries>
<application <application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:requestLegacyExternalStorage="true" android:requestLegacyExternalStorage="true"
@ -50,14 +51,6 @@
android:launchMode="singleTop" android:launchMode="singleTop"
android:theme="@style/NormalTheme" android:theme="@style/NormalTheme"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
@ -65,6 +58,7 @@
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" /> <data android:mimeType="image/*" />
<data android:mimeType="video/*" /> <data android:mimeType="video/*" />
<data android:mimeType="vnd.android.cursor.dir/image" /> <data android:mimeType="vnd.android.cursor.dir/image" />
@ -108,11 +102,26 @@
<data android:mimeType="vnd.android.cursor.dir/image" /> <data android:mimeType="vnd.android.cursor.dir/image" />
<data android:mimeType="vnd.android.cursor.dir/video" /> <data android:mimeType="vnd.android.cursor.dir/video" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
</intent-filter>
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<meta-data
android:name="android.app.searchable"
android:resource="@xml/searchable" />
</activity> </activity>
<!-- file provider to share files having a file:// URI --> <!-- file provider to share files having a file:// URI -->
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.file_provider"
android:exported="false" android:exported="false"
android:grantUriPermissions="true"> android:grantUriPermissions="true">
<meta-data <meta-data
@ -120,6 +129,12 @@
android:resource="@xml/provider_paths" /> android:resource="@xml/provider_paths" />
</provider> </provider>
<provider
android:name=".SearchSuggestionsProvider"
android:authorities="@string/search_provider"
android:exported="true"
tools:ignore="ExportedContentProvider" />
<meta-data <meta-data
android:name="com.google.android.geo.API_KEY" android:name="com.google.android.geo.API_KEY"
android:value="${googleApiKey}" /> android:value="${googleApiKey}" />

View file

@ -1,5 +1,6 @@
package deckers.thibault.aves package deckers.thibault.aves
import android.app.SearchManager
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
@ -40,6 +41,7 @@ class MainActivity : FlutterActivity() {
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this)) MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this)) MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this))
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this)) MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this)) MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(MetadataHandler(this)) MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(MetadataHandler(this))
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this)) MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
@ -146,6 +148,17 @@ class MainActivity : FlutterActivity() {
"mimeType" to intent.type, "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() return HashMap()
} }

View file

@ -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<String>?, selection: String?, selectionArgs: Array<String>?, 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<FieldMap> {
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<FieldMap>)
}
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<String>?): Int =
throw UnsupportedOperationException()
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?): Int =
throw UnsupportedOperationException()
companion object {
private val LOG_TAG = LogUtils.createTag<SearchSuggestionsProvider>()
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<Boolean> { cont ->
Handler(mainLooper).post {
r.run()
cont.resume(true)
}
}
}
}
}

View file

@ -255,7 +255,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
return when (uri.scheme?.lowercase(Locale.ROOT)) { return when (uri.scheme?.lowercase(Locale.ROOT)) {
ContentResolver.SCHEME_FILE -> { ContentResolver.SCHEME_FILE -> {
uri.path?.let { path -> uri.path?.let { path ->
val authority = "${context.applicationContext.packageName}.fileprovider" val authority = "${context.applicationContext.packageName}.file_provider"
FileProvider.getUriForFile(context, authority, File(path)) FileProvider.getUriForFile(context, authority, File(path))
} }
} }

View file

@ -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) { val uri = if (displayName != null) {
// add extension to ease type identification when sharing this content // add extension to ease type identification when sharing this content
val displayNameWithExtension = if (extension == null || displayName.endsWith(extension, ignoreCase = true)) { val displayNameWithExtension = if (extension == null || displayName.endsWith(extension, ignoreCase = true)) {

View file

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

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<searchable xmlns:android="http://schemas.android.com/apk/res/android"
android:includeInGlobalSearch="true"
android:label="@string/app_name"
android:searchSuggestAuthority="@string/search_provider"
android:searchSuggestIntentAction="android.intent.action.SEARCH"
android:searchSuggestSelection=" ?"
android:searchSuggestThreshold="3" />

View file

@ -30,6 +30,8 @@ abstract class MetadataDb {
Future<void> updateEntryId(int oldId, AvesEntry entry); Future<void> updateEntryId(int oldId, AvesEntry entry);
Future<Set<AvesEntry>> searchEntries(String query, {int? limit});
// date taken // date taken
Future<void> clearDates(); Future<void> clearDates();
@ -235,6 +237,19 @@ class SqfliteMetadataDb implements MetadataDb {
); );
} }
@override
Future<Set<AvesEntry>> 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 // date taken
@override @override

View file

@ -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<void> registerCallback() async {
try {
await platform.invokeMethod('registerCallback', <String, dynamic>{
'callbackHandle': PluginUtilities.getCallbackHandle(_init)?.toRawHandle(),
});
} on PlatformException catch (e) {
debugPrint('registerCallback failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
}
}
Future<void> _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<List<Map<String, String?>>> _getSuggestions(dynamic args) async {
final suggestions = <Map<String, String?>>[];
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;
}

View file

@ -6,6 +6,7 @@ import 'package:aves/model/settings/screen_on.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/global_search.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/services.dart';
import 'package:aves/services/viewer_service.dart'; import 'package:aves/services/viewer_service.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
@ -75,6 +76,7 @@ class _HomePageState extends State<HomePage> {
final action = intentData['action']; final action = intentData['action'];
switch (action) { switch (action) {
case 'view': case 'view':
case 'search':
_viewerEntry = await _initViewerEntry( _viewerEntry = await _initViewerEntry(
uri: intentData['uri'], uri: intentData['uri'],
mimeType: intentData['mimeType'], mimeType: intentData['mimeType'],
@ -107,6 +109,7 @@ class _HomePageState extends State<HomePage> {
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

View file

@ -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.AudioCapabilities ERROR
adb.exe shell setprop log.tag.AudioTrack INFO adb.exe shell setprop log.tag.AudioTrack INFO
adb.exe shell setprop log.tag.CompatibilityChangeReporter 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.InputMethodManager WARN
adb.exe shell setprop log.tag.InsetsSourceConsumer INFO adb.exe shell setprop log.tag.InsetsSourceConsumer INFO
adb.exe shell setprop log.tag.InputTransport INFO adb.exe shell setprop log.tag.InputTransport INFO