diff --git a/CHANGELOG.md b/CHANGELOG.md
index dcc47b01f..1208f5714 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,16 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
+## [v1.4.7] - 2021-08-06
+### Added
+- Map
+- Viewer: action to copy to clipboard
+- integration with Android global search (Samsung Finder etc.)
+
+### Fixed
+- auto album identification and naming
+- opening HEIC images from downloads content URI on Android R+
+
## [v1.4.6] - 2021-07-22
### Added
- Albums / Countries / Tags: multiple selection
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 01584967c..999193448 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,18 @@ android {
}
defaultConfig {
- applicationId "deckers.thibault.aves"
+ applicationId appId
+ // minSdkVersion constraints:
+ // - Flutter & other plugins: 16
+ // - google_maps_flutter v2.0.5: 20
+ // - Aves native: 19
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 +80,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..09725f98b 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -24,7 +24,7 @@
tools:ignore="ScopedStorage" />
-
+
@@ -38,26 +38,21 @@
-
+ android:roundIcon="@mipmap/ic_launcher_round"
+ tools:targetApi="lollipop">
-
-
-
@@ -65,6 +60,7 @@
+
@@ -108,11 +104,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..773f2ef19 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,7 @@
package deckers.thibault.aves
+import android.annotation.SuppressLint
+import android.app.SearchManager
import android.content.Intent
import android.net.Uri
import android.os.Build
@@ -29,10 +31,23 @@ class MainActivity : FlutterActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
Log.i(LOG_TAG, "onCreate intent=$intent")
+// StrictMode.setThreadPolicy(
+// StrictMode.ThreadPolicy.Builder()
+// .detectAll()
+// .penaltyLog()
+// .build()
+// )
+// StrictMode.setVmPolicy(
+// StrictMode.VmPolicy.Builder()
+// .detectAll()
+// .penaltyLog()
+// .build()
+// )
super.onCreate(savedInstanceState)
val messenger = flutterEngine!!.dartExecutor.binaryMessenger
+ // dart -> platform -> dart
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
MethodChannel(messenger, AppShortcutHandler.CHANNEL).setMethodCallHandler(AppShortcutHandler(this))
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
@@ -40,18 +55,20 @@ 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))
MethodChannel(messenger, TimeHandler.CHANNEL).setMethodCallHandler(TimeHandler())
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(WindowHandler(this))
+ // result streaming: dart -> platform ->->-> dart
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, StorageAccessStreamHandler.CHANNEL).setStreamHandlerFactory { args -> StorageAccessStreamHandler(this, args) }
- // Media Store change monitoring
+ // change monitoring: platform -> dart
mediaStoreChangeStreamHandler = MediaStoreChangeStreamHandler(this).apply {
EventChannel(messenger, MediaStoreChangeStreamHandler.CHANNEL).setStreamHandler(this)
}
@@ -60,9 +77,11 @@ class MainActivity : FlutterActivity() {
}
// intent handling
+ // notification: platform -> dart
intentStreamHandler = IntentStreamHandler().apply {
EventChannel(messenger, IntentStreamHandler.CHANNEL).setStreamHandler(this)
}
+ // detail fetch: dart -> platform
intentDataMap = extractIntentData(intent)
MethodChannel(messenger, VIEWER_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
@@ -74,6 +93,11 @@ class MainActivity : FlutterActivity() {
}
}
+ // notification: platform -> dart
+ errorStreamHandler = ErrorStreamHandler().apply {
+ EventChannel(messenger, ErrorStreamHandler.CHANNEL).setStreamHandler(this)
+ }
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
setupShortcuts()
}
@@ -93,31 +117,34 @@ class MainActivity : FlutterActivity() {
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
- DOCUMENT_TREE_ACCESS_REQUEST -> {
- val treeUri = data?.data
- if (resultCode != RESULT_OK || treeUri == null) {
- onPermissionResult(requestCode, null)
- return
- }
+ DOCUMENT_TREE_ACCESS_REQUEST -> onDocumentTreeAccessResult(data, resultCode, requestCode)
+ DELETE_PERMISSION_REQUEST -> onDeletePermissionResult(resultCode)
+ CREATE_FILE_REQUEST, OPEN_FILE_REQUEST, SELECT_DIRECTORY_REQUEST -> onPermissionResult(requestCode, data?.data)
+ }
+ }
- // save access permissions across reboots
- val takeFlags = (data.flags
- and (Intent.FLAG_GRANT_READ_URI_PERMISSION
- or Intent.FLAG_GRANT_WRITE_URI_PERMISSION))
- contentResolver.takePersistableUriPermission(treeUri, takeFlags)
+ @SuppressLint("WrongConstant")
+ private fun onDocumentTreeAccessResult(data: Intent?, resultCode: Int, requestCode: Int) {
+ val treeUri = data?.data
+ if (resultCode != RESULT_OK || treeUri == null) {
+ onPermissionResult(requestCode, null)
+ return
+ }
- // resume pending action
- onPermissionResult(requestCode, treeUri)
- }
- DELETE_PERMISSION_REQUEST -> {
- // delete permission may be requested on Android 10+ only
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
- MediaStoreImageProvider.pendingDeleteCompleter?.complete(resultCode == RESULT_OK)
- }
- }
- CREATE_FILE_REQUEST, OPEN_FILE_REQUEST, SELECT_DIRECTORY_REQUEST -> {
- onPermissionResult(requestCode, data?.data)
- }
+ // save access permissions across reboots
+ val takeFlags = (data.flags
+ and (Intent.FLAG_GRANT_READ_URI_PERMISSION
+ or Intent.FLAG_GRANT_WRITE_URI_PERMISSION))
+ contentResolver.takePersistableUriPermission(treeUri, takeFlags)
+
+ // resume pending action
+ onPermissionResult(requestCode, treeUri)
+ }
+
+ private fun onDeletePermissionResult(resultCode: Int) {
+ // delete permission may be requested on Android 10+ only
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ MediaStoreImageProvider.pendingDeleteCompleter?.complete(resultCode == RESULT_OK)
}
}
@@ -146,6 +173,20 @@ class MainActivity : FlutterActivity() {
"mimeType" to intent.type,
)
}
+ Intent.ACTION_SEARCH -> {
+ val viewUri = intent.dataString
+ return if (viewUri != null) hashMapOf(
+ "action" to "view",
+ "uri" to viewUri,
+ "mimeType" to intent.getStringExtra(SearchManager.EXTRA_DATA_KEY),
+ ) else hashMapOf(
+ "action" to "search",
+ "query" to intent.getStringExtra(SearchManager.QUERY),
+ )
+ }
+ else -> {
+ Log.w(LOG_TAG, "unhandled intent action=${intent?.action}")
+ }
}
return HashMap()
}
@@ -211,6 +252,10 @@ class MainActivity : FlutterActivity() {
handler.onDenied()
}
}
+
+ var errorStreamHandler: ErrorStreamHandler? = null
+
+ fun notifyError(error: String) = errorStreamHandler?.notifyError(error)
}
}
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..df5d58108
--- /dev/null
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt
@@ -0,0 +1,188 @@
+package deckers.thibault.aves
+
+import android.app.SearchManager
+import android.content.ContentProvider
+import android.content.ContentResolver
+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 ->
+ val searchShortcutTitle = "${context.resources.getString(R.string.search_shortcut_short_label)} $query"
+ val searchShortcutIcon = context.resourceUri(R.mipmap.ic_shortcut_search)
+ matrixCursor.addRow(arrayOf(null, null, null, searchShortcutTitle, null, searchShortcutIcon))
+
+ 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)
+ }
+ }
+ }
+
+ 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()
+ }
+ }
+}
\ 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..04afd10b8 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
@@ -1,8 +1,6 @@
package deckers.thibault.aves.channel.calls
-import android.content.ContentResolver
-import android.content.Context
-import android.content.Intent
+import android.content.*
import android.content.pm.ApplicationInfo
import android.content.res.Configuration
import android.net.Uri
@@ -12,7 +10,7 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
-import deckers.thibault.aves.channel.calls.Coresult.Companion.safesus
+import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils
@@ -31,34 +29,13 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getPackages" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPackages) }
- "getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getAppIcon) }
- "edit" -> {
- val title = call.argument("title")
- val uri = call.argument("uri")?.let { Uri.parse(it) }
- val mimeType = call.argument("mimeType")
- result.success(edit(title, uri, mimeType))
- }
- "open" -> {
- val title = call.argument("title")
- val uri = call.argument("uri")?.let { Uri.parse(it) }
- val mimeType = call.argument("mimeType")
- result.success(open(title, uri, mimeType))
- }
- "openMap" -> {
- val geoUri = call.argument("geoUri")?.let { Uri.parse(it) }
- result.success(openMap(geoUri))
- }
- "setAs" -> {
- val title = call.argument("title")
- val uri = call.argument("uri")?.let { Uri.parse(it) }
- val mimeType = call.argument("mimeType")
- result.success(setAs(title, uri, mimeType))
- }
- "share" -> {
- val title = call.argument("title")
- val urisByMimeType = call.argument