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'
|
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,
|
||||||
|
|
|
@ -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}" />
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)) {
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)) {
|
||||||
|
|
|
@ -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<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
|
||||||
|
|
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/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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue