integration to android global search / samsung finder
This commit is contained in:
parent
4df90602f4
commit
d04adf52a2
12 changed files with 352 additions and 12 deletions
|
@ -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,
|
||||
|
|
|
@ -38,6 +38,7 @@
|
|||
</queries>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
|
@ -50,14 +51,6 @@
|
|||
android:launchMode="singleTop"
|
||||
android:theme="@style/NormalTheme"
|
||||
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>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
|
@ -65,6 +58,7 @@
|
|||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:mimeType="image/*" />
|
||||
<data android:mimeType="video/*" />
|
||||
<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/video" />
|
||||
</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>
|
||||
|
||||
<!-- file provider to share files having a file:// URI -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:authorities="${applicationId}.file_provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
|
@ -120,6 +129,12 @@
|
|||
android:resource="@xml/provider_paths" />
|
||||
</provider>
|
||||
|
||||
<provider
|
||||
android:name=".SearchSuggestionsProvider"
|
||||
android:authorities="@string/search_provider"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedContentProvider" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.geo.API_KEY"
|
||||
android:value="${googleApiKey}" />
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
8
android/app/src/main/res/xml/searchable.xml
Normal file
8
android/app/src/main/res/xml/searchable.xml
Normal 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" />
|
|
@ -30,6 +30,8 @@ abstract class MetadataDb {
|
|||
|
||||
Future<void> updateEntryId(int oldId, AvesEntry entry);
|
||||
|
||||
Future<Set<AvesEntry>> searchEntries(String query, {int? limit});
|
||||
|
||||
// date taken
|
||||
|
||||
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
|
||||
|
||||
@override
|
||||
|
|
66
lib/services/global_search.dart
Normal file
66
lib/services/global_search.dart
Normal 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;
|
||||
}
|
|
@ -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<HomePage> {
|
|||
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<HomePage> {
|
|||
final source = context.read<CollectionSource>();
|
||||
await source.init();
|
||||
unawaited(source.refresh());
|
||||
unawaited(GlobalSearch.registerCallback());
|
||||
}
|
||||
|
||||
// `pushReplacement` is not enough in some edge cases
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue