Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2021-08-06 15:35:51 +09:00
commit 9477772ace
138 changed files with 2799 additions and 1592 deletions

View file

@ -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

View file

@ -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,

View file

@ -24,7 +24,7 @@
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.INTERNET" />
<!-- to access media with unredacted metadata with scoped storage (Android Q+) -->
<!-- to access media with original metadata with scoped storage (Android Q+) -->
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<!-- TODO TLAD remove this permission when this is fixed: https://github.com/flutter/flutter/issues/42451 -->
@ -38,26 +38,21 @@
</queries>
<application
android:allowBackup="true"
android:fullBackupOnly="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round">
<!-- TODO TLAD Android 12 https://developer.android.com/about/versions/12/behavior-changes-12#exported -->
android:roundIcon="@mipmap/ic_launcher_round"
tools:targetApi="lollipop">
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
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 +60,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 +104,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 +131,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}" />

View file

@ -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)
}
}

View file

@ -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<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 ->
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<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)
}
}
}
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()
}
}
}

View file

@ -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<String>("title")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val mimeType = call.argument<String>("mimeType")
result.success(edit(title, uri, mimeType))
}
"open" -> {
val title = call.argument<String>("title")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val mimeType = call.argument<String>("mimeType")
result.success(open(title, uri, mimeType))
}
"openMap" -> {
val geoUri = call.argument<String>("geoUri")?.let { Uri.parse(it) }
result.success(openMap(geoUri))
}
"setAs" -> {
val title = call.argument<String>("title")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val mimeType = call.argument<String>("mimeType")
result.success(setAs(title, uri, mimeType))
}
"share" -> {
val title = call.argument<String>("title")
val urisByMimeType = call.argument<Map<String, List<String>>>("urisByMimeType")!!
result.success(shareMultiple(title, urisByMimeType))
}
"getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getAppIcon) }
"copyToClipboard" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::copyToClipboard) }
"edit" -> safe(call, result, ::edit)
"open" -> safe(call, result, ::open)
"openMap" -> safe(call, result, ::openMap)
"setAs" -> safe(call, result, ::setAs)
"share" -> safe(call, result, ::share)
else -> result.notImplemented()
}
}
@ -156,77 +133,131 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
}
}
private fun edit(title: String?, uri: Uri?, mimeType: String?): Boolean {
uri ?: return false
private fun copyToClipboard(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val label = call.argument<String>("label")
if (uri == null) {
result.error("copyToClipboard-args", "failed because of missing arguments", null)
return
}
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
if (clipboard != null) {
val clip = ClipData.newUri(context.contentResolver, label, getShareableUri(uri))
clipboard.setPrimaryClip(clip)
result.success(true)
} else {
result.success(false)
}
}
private fun edit(call: MethodCall, result: MethodChannel.Result) {
val title = call.argument<String>("title")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val mimeType = call.argument<String>("mimeType")
if (uri == null) {
result.error("edit-args", "failed because of missing arguments", null)
return
}
val intent = Intent(Intent.ACTION_EDIT)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
.setDataAndType(getShareableUri(uri), mimeType)
return safeStartActivityChooser(title, intent)
val started = safeStartActivityChooser(title, intent)
result.success(started)
}
private fun open(title: String?, uri: Uri?, mimeType: String?): Boolean {
uri ?: return false
private fun open(call: MethodCall, result: MethodChannel.Result) {
val title = call.argument<String>("title")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val mimeType = call.argument<String>("mimeType")
if (uri == null) {
result.error("open-args", "failed because of missing arguments", null)
return
}
val intent = Intent(Intent.ACTION_VIEW)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.setDataAndType(getShareableUri(uri), mimeType)
return safeStartActivityChooser(title, intent)
val started = safeStartActivityChooser(title, intent)
result.success(started)
}
private fun openMap(geoUri: Uri?): Boolean {
geoUri ?: return false
private fun openMap(call: MethodCall, result: MethodChannel.Result) {
val geoUri = call.argument<String>("geoUri")?.let { Uri.parse(it) }
if (geoUri == null) {
result.error("openMap-args", "failed because of missing arguments", null)
return
}
val intent = Intent(Intent.ACTION_VIEW, geoUri)
return safeStartActivity(intent)
val started = safeStartActivity(intent)
result.success(started)
}
private fun setAs(title: String?, uri: Uri?, mimeType: String?): Boolean {
uri ?: return false
private fun setAs(call: MethodCall, result: MethodChannel.Result) {
val title = call.argument<String>("title")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val mimeType = call.argument<String>("mimeType")
if (uri == null) {
result.error("setAs-args", "failed because of missing arguments", null)
return
}
val intent = Intent(Intent.ACTION_ATTACH_DATA)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.setDataAndType(getShareableUri(uri), mimeType)
return safeStartActivityChooser(title, intent)
val started = safeStartActivityChooser(title, intent)
result.success(started)
}
private fun shareSingle(title: String?, uri: Uri, mimeType: String): Boolean {
val intent = Intent(Intent.ACTION_SEND)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.setType(mimeType)
.putExtra(Intent.EXTRA_STREAM, getShareableUri(uri))
return safeStartActivityChooser(title, intent)
}
private fun shareMultiple(title: String?, urisByMimeType: Map<String, List<String>>?): Boolean {
urisByMimeType ?: return false
private fun share(call: MethodCall, result: MethodChannel.Result) {
val title = call.argument<String>("title")
val urisByMimeType = call.argument<Map<String, List<String>>>("urisByMimeType")
if (urisByMimeType == null) {
result.error("setAs-args", "failed because of missing arguments", null)
return
}
val uriList = ArrayList(urisByMimeType.values.flatten().mapNotNull { Uri.parse(it) })
val mimeTypes = urisByMimeType.keys.toTypedArray()
// simplify share intent for a single item, as some apps can handle one item but not more
if (uriList.size == 1) {
return shareSingle(title, uriList.first(), mimeTypes.first())
}
val started = if (uriList.size == 1) {
val uri = uriList.first()
val mimeType = mimeTypes.first()
var mimeType = "*/*"
if (mimeTypes.size == 1) {
// items have the same mime type & subtype
mimeType = mimeTypes.first()
val intent = Intent(Intent.ACTION_SEND)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.setType(mimeType)
.putExtra(Intent.EXTRA_STREAM, getShareableUri(uri))
safeStartActivityChooser(title, intent)
} else {
// items have different subtypes
val mimeTypeTypes = mimeTypes.map { it.split("/") }.distinct()
if (mimeTypeTypes.size == 1) {
// items have the same mime type
mimeType = "${mimeTypeTypes.first()}/*"
var mimeType = "*/*"
if (mimeTypes.size == 1) {
// items have the same mime type & subtype
mimeType = mimeTypes.first()
} else {
// items have different subtypes
val mimeTypeTypes = mimeTypes.map { it.split("/") }.distinct()
if (mimeTypeTypes.size == 1) {
// items have the same mime type
mimeType = "${mimeTypeTypes.first()}/*"
}
}
val intent = Intent(Intent.ACTION_SEND_MULTIPLE)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uriList)
.setType(mimeType)
safeStartActivityChooser(title, intent)
}
val intent = Intent(Intent.ACTION_SEND_MULTIPLE)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uriList)
.setType(mimeType)
return safeStartActivityChooser(title, intent)
result.success(started)
}
private fun safeStartActivity(intent: Intent): Boolean {
@ -255,7 +286,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))
}
}

View file

@ -8,6 +8,7 @@ import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.R
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.utils.BitmapUtils.centerSquareCrop
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
@ -20,23 +21,31 @@ import java.util.*
class AppShortcutHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"canPin" -> result.success(canPin())
"pin" -> {
GlobalScope.launch(Dispatchers.IO) { pin(call) }
result.success(null)
}
"canPin" -> safe(call, result, ::canPin)
"pin" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::pin) }
else -> result.notImplemented()
}
}
private fun canPin() = ShortcutManagerCompat.isRequestPinShortcutSupported(context)
private fun isSupported() = ShortcutManagerCompat.isRequestPinShortcutSupported(context)
private fun pin(call: MethodCall) {
if (!canPin()) return
private fun canPin(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
result.success(isSupported())
}
val label = call.argument<String>("label") ?: return
private fun pin(call: MethodCall, result: MethodChannel.Result) {
val label = call.argument<String>("label")
val iconBytes = call.argument<ByteArray>("iconBytes")
val filters = call.argument<List<String>>("filters") ?: return
val filters = call.argument<List<String>>("filters")
if (label == null || filters == null) {
result.error("pin-args", "failed because of missing arguments", null)
return
}
if (!isSupported()) {
result.error("pin-unsupported", "failed because the launcher does not support pinning shortcuts", null)
return
}
var icon: IconCompat? = null
if (iconBytes?.isNotEmpty() == true) {
@ -62,6 +71,8 @@ class AppShortcutHandler(private val context: Context) : MethodCallHandler {
.setIntent(intent)
.build()
ShortcutManagerCompat.requestPinShortcut(context, shortcut, null)
result.success(true)
}
companion object {

View file

@ -1,5 +1,6 @@
package deckers.thibault.aves.channel.calls
import deckers.thibault.aves.MainActivity
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
@ -8,24 +9,42 @@ import kotlinx.coroutines.launch
import kotlin.reflect.KSuspendFunction2
// ensure `result` methods are called on the main looper thread
class Coresult internal constructor(private val methodResult: MethodChannel.Result) : MethodChannel.Result {
class Coresult internal constructor(private val call: MethodCall, private val methodResult: MethodChannel.Result) : MethodChannel.Result {
private val mainScope = CoroutineScope(Dispatchers.Main)
override fun success(result: Any?) {
mainScope.launch { methodResult.success(result) }
mainScope.launch {
try {
methodResult.success(result)
} catch (e: Exception) {
MainActivity.notifyError("failed to reply success for method=${call.method}, result=$result, exception=$e")
}
}
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
mainScope.launch { methodResult.error(errorCode, errorMessage, errorDetails) }
mainScope.launch {
try {
methodResult.error(errorCode, errorMessage, errorDetails)
} catch (e: Exception) {
MainActivity.notifyError("failed to reply error for method=${call.method}, errorCode=$errorCode, errorMessage=$errorMessage, errorDetails=$errorDetails, exception=$e")
}
}
}
override fun notImplemented() {
mainScope.launch { methodResult.notImplemented() }
mainScope.launch {
try {
methodResult.notImplemented()
} catch (e: Exception) {
MainActivity.notifyError("failed to reply notImplemented for method=${call.method}, exception=$e")
}
}
}
companion object {
fun safe(call: MethodCall, result: MethodChannel.Result, function: (call: MethodCall, result: MethodChannel.Result) -> Unit) {
val res = Coresult(result)
val res = Coresult(call, result)
try {
function(call, res)
} catch (e: Exception) {
@ -33,12 +52,12 @@ class Coresult internal constructor(private val methodResult: MethodChannel.Resu
}
}
suspend fun safesus(call: MethodCall, result: MethodChannel.Result, function: KSuspendFunction2<MethodCall, MethodChannel.Result, Unit>) {
val res = Coresult(result)
suspend fun safeSuspend(call: MethodCall, result: MethodChannel.Result, function: KSuspendFunction2<MethodCall, MethodChannel.Result, Unit>) {
val res = Coresult(call, result)
try {
function(call, res)
} catch (e: Exception) {
res.error("safe-exception", e.message, e.stackTraceToString())
res.error("safeSuspend-exception", e.message, e.stackTraceToString())
}
}
}

View file

@ -1,12 +1,13 @@
package deckers.thibault.aves.channel.calls
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import android.util.Log
import androidx.exifinterface.media.ExifInterface
@ -37,8 +38,15 @@ import java.util.*
class DebugHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getContextDirs" -> result.success(getContextDirs())
"getEnv" -> result.success(System.getenv())
"crash" -> Handler(Looper.getMainLooper()).postDelayed({ throw TestException() }, 50)
"exception" -> throw TestException()
"safeException" -> safe(call, result) { _, _ -> throw TestException() }
"exceptionInCoroutine" -> GlobalScope.launch(Dispatchers.IO) { throw TestException() }
"safeExceptionInCoroutine" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result) { _, _ -> throw TestException() } }
"getContextDirs" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContextDirs) }
"getEnv" -> safe(call, result, ::getEnv)
"getBitmapFactoryInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getBitmapFactoryInfo) }
"getContentResolverMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverMetadata) }
"getExifInterfaceMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getExifInterfaceMetadata) }
@ -49,24 +57,32 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
}
}
private fun getContextDirs() = hashMapOf(
"cacheDir" to context.cacheDir,
"filesDir" to context.filesDir,
"obbDir" to context.obbDir,
"externalCacheDir" to context.externalCacheDir,
).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
putAll(
hashMapOf(
"codeCacheDir" to context.codeCacheDir,
"noBackupFilesDir" to context.noBackupFilesDir,
private fun getContextDirs(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
val dirs = hashMapOf(
"cacheDir" to context.cacheDir,
"filesDir" to context.filesDir,
"obbDir" to context.obbDir,
"externalCacheDir" to context.externalCacheDir,
).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
putAll(
hashMapOf(
"codeCacheDir" to context.codeCacheDir,
"noBackupFilesDir" to context.noBackupFilesDir,
)
)
)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
put("dataDir", context.dataDir)
}
}.mapValues { it.value?.path }
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
put("dataDir", context.dataDir)
}
}.mapValues { it.value?.path }
result.success(dirs)
}
private fun getEnv(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
result.success(System.getenv())
}
private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
@ -105,7 +121,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
}
var contentUri: Uri = uri
if (uri.scheme == ContentResolver.SCHEME_CONTENT && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)) {
if (StorageUtils.isMediaStoreContentUri(uri)) {
uri.tryParseId()?.let { id ->
contentUri = when {
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
@ -317,4 +333,6 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
private val LOG_TAG = LogUtils.createTag<DebugHandler>()
const val CHANNEL = "deckers.thibault/aves/debug"
}
class TestException internal constructor() : RuntimeException("oops")
}

View file

@ -1,6 +1,7 @@
package deckers.thibault.aves.channel.calls
import android.os.Build
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
@ -8,17 +9,18 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler
class DeviceHandler : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getPerformanceClass" -> result.success(getPerformanceClass())
"getPerformanceClass" -> safe(call, result, ::getPerformanceClass)
else -> result.notImplemented()
}
}
private fun getPerformanceClass(): Int {
private fun getPerformanceClass(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
// TODO TLAD uncomment when the future is here
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// return Build.VERSION.MEDIA_PERFORMANCE_CLASS
// result.success(Build.VERSION.MEDIA_PERFORMANCE_CLASS)
// return
// }
return Build.VERSION.SDK_INT
result.success(Build.VERSION.SDK_INT)
}
companion object {

View file

@ -12,7 +12,7 @@ import com.drew.imaging.ImageMetadataReader
import com.drew.metadata.file.FileTypeDirectory
import com.drew.metadata.xmp.XmpDirectory
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.metadata.Metadata
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
import deckers.thibault.aves.metadata.MultiPage
@ -44,7 +44,7 @@ import java.util.*
class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getExifThumbnails) }
"getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getExifThumbnails) }
"extractMotionPhotoVideo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractMotionPhotoVideo) }
"extractVideoEmbeddedPicture" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractVideoEmbeddedPicture) }
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractXmpDataProp) }
@ -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)) {

View file

@ -2,6 +2,7 @@ package deckers.thibault.aves.channel.calls
import android.content.Context
import android.location.Geocoder
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
@ -18,7 +19,7 @@ class GeocodingHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getAddress" -> GlobalScope.launch(Dispatchers.IO) { Coresult.safe(call, result, ::getAddress) }
"getAddress" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getAddress) }
else -> result.notImplemented()
}
}

View file

@ -0,0 +1,42 @@
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
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class GlobalSearchHandler(private val context: Activity) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"registerCallback" -> GlobalScope.launch(Dispatchers.IO) { 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
}
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

@ -5,7 +5,7 @@ import android.graphics.Rect
import android.net.Uri
import com.bumptech.glide.Glide
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.channel.calls.fetchers.RegionFetcher
import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher
import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher
@ -32,10 +32,10 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getEntry" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getEntry) }
"getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getThumbnail) }
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getRegion) }
"captureFrame" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::captureFrame) }
"rename" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::rename) }
"getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getThumbnail) }
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getRegion) }
"captureFrame" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::captureFrame) }
"rename" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::rename) }
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
"clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) }

View file

@ -1,6 +1,5 @@
package deckers.thibault.aves.channel.calls
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
@ -692,7 +691,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
var contentUri: Uri = uri
if (uri.scheme == ContentResolver.SCHEME_CONTENT && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)) {
if (StorageUtils.isMediaStoreContentUri(uri)) {
uri.tryParseId()?.let { id ->
contentUri = when {
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)

View file

@ -23,13 +23,13 @@ import java.util.*
class StorageHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getStorageVolumes" -> safe(call, result, ::getStorageVolumes)
"getFreeSpace" -> safe(call, result, ::getFreeSpace)
"getGrantedDirectories" -> safe(call, result, ::getGrantedDirectories)
"getInaccessibleDirectories" -> safe(call, result, ::getInaccessibleDirectories)
"getRestrictedDirectories" -> safe(call, result, ::getRestrictedDirectories)
"getStorageVolumes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getStorageVolumes) }
"getFreeSpace" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getFreeSpace) }
"getGrantedDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getGrantedDirectories) }
"getInaccessibleDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getInaccessibleDirectories) }
"getRestrictedDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getRestrictedDirectories) }
"revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess)
"deleteEmptyDirectories" -> safe(call, result, ::deleteEmptyDirectories)
"deleteEmptyDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::deleteEmptyDirectories) }
"scanFile" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::scanFile) }
else -> result.notImplemented()
}
@ -38,7 +38,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
private fun getStorageVolumes(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
val volumes = ArrayList<Map<String, Any>>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val sm = context.getSystemService(StorageManager::class.java)
val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager
if (sm != null) {
for (volumePath in getVolumePaths(context)) {
try {

View file

@ -1,5 +1,6 @@
package deckers.thibault.aves.channel.calls
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
@ -8,11 +9,15 @@ import java.util.*
class TimeHandler : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getDefaultTimeZone" -> result.success(TimeZone.getDefault().id)
"getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone)
else -> result.notImplemented()
}
}
private fun getDefaultTimeZone(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
result.success(TimeZone.getDefault().id)
}
companion object {
const val CHANNEL = "deckers.thibault/aves/time"
}

View file

@ -17,7 +17,7 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler {
"keepScreenOn" -> safe(call, result, ::keepScreenOn)
"isRotationLocked" -> safe(call, result, ::isRotationLocked)
"requestOrientation" -> safe(call, result, ::requestOrientation)
"canSetCutoutMode" -> result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
"canSetCutoutMode" -> safe(call, result, ::canSetCutoutMode)
"setCutoutMode" -> safe(call, result, ::setCutoutMode)
else -> result.notImplemented()
}
@ -60,6 +60,10 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler {
result.success(true)
}
private fun canSetCutoutMode(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
}
private fun setCutoutMode(call: MethodCall, result: MethodChannel.Result) {
val use = call.argument<Boolean>("use")
if (use == null) {

View file

@ -24,6 +24,7 @@ import deckers.thibault.aves.utils.MimeTypes.isHeic
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.UriUtils.tryParseId
import io.flutter.plugin.common.MethodChannel
@ -130,7 +131,7 @@ class ThumbnailFetcher internal constructor(
svgFetch -> SvgThumbnail(context, uri)
tiffFetch -> TiffImage(context, uri, pageId)
multiTrackFetch -> MultiTrackImage(context, uri, pageId)
else -> uri
else -> StorageUtils.getGlideSafeUri(uri, mimeType)
}
Glide.with(context)
.asBitmap()

View file

@ -0,0 +1,25 @@
package deckers.thibault.aves.channel.streams
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink
class ErrorStreamHandler : EventChannel.StreamHandler {
// cannot use `lateinit` because we cannot guarantee
// its initialization in `onListen` at the right time
// e.g. when resuming the app after the activity got destroyed
private var eventSink: EventSink? = null
override fun onListen(arguments: Any?, eventSink: EventSink) {
this.eventSink = eventSink
}
override fun onCancel(arguments: Any?) {}
fun notifyError(error: String) {
eventSink?.success(error)
}
companion object {
const val CHANNEL = "deckers.thibault/aves/error"
}
}

View file

@ -120,7 +120,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
} else if (mimeType == MimeTypes.TIFF) {
TiffImage(activity, uri, pageId)
} else {
uri
StorageUtils.getGlideSafeUri(uri, mimeType)
}
val target = Glide.with(activity)

View file

@ -39,7 +39,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
handler = Handler(Looper.getMainLooper())
when (op) {
"requestVolumeAccess" -> requestVolumeAccess()
"requestVolumeAccess" -> GlobalScope.launch(Dispatchers.IO) { requestVolumeAccess() }
"createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() }
"openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() }
"selectDirectory" -> GlobalScope.launch(Dispatchers.IO) { selectDirectory() }
@ -83,18 +83,20 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
putExtra(Intent.EXTRA_TITLE, name)
}
MainActivity.pendingResultHandlers[MainActivity.CREATE_FILE_REQUEST] = PendingResultHandler(null, { uri ->
try {
activity.contentResolver.openOutputStream(uri)?.use { output ->
output as FileOutputStream
// truncate is necessary when overwriting a longer file
output.channel.truncate(0)
output.write(bytes)
GlobalScope.launch(Dispatchers.IO) {
try {
activity.contentResolver.openOutputStream(uri)?.use { output ->
output as FileOutputStream
// truncate is necessary when overwriting a longer file
output.channel.truncate(0)
output.write(bytes)
}
success(true)
} catch (e: Exception) {
error("createFile-write", "failed to write file at uri=$uri", e.message)
}
success(true)
} catch (e: Exception) {
error("createFile-write", "failed to write file at uri=$uri", e.message)
endOfStream()
}
endOfStream()
}, {
success(null)
endOfStream()
@ -115,13 +117,15 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
type = mimeType
}
MainActivity.pendingResultHandlers[MainActivity.OPEN_FILE_REQUEST] = PendingResultHandler(null, { uri ->
activity.contentResolver.openInputStream(uri)?.use { input ->
val buffer = ByteArray(BUFFER_SIZE)
var len: Int
while (input.read(buffer).also { len = it } != -1) {
success(buffer.copyOf(len))
GlobalScope.launch(Dispatchers.IO) {
activity.contentResolver.openInputStream(uri)?.use { input ->
val buffer = ByteArray(BUFFER_SIZE)
var len: Int
while (input.read(buffer).also { len = it } != -1) {
success(buffer.copyOf(len))
}
endOfStream()
}
endOfStream()
}
}, {
success(ByteArray(0))

View file

@ -142,7 +142,7 @@ abstract class ImageProvider {
} else if (sourceMimeType == MimeTypes.TIFF) {
TiffImage(context, sourceUri, pageId)
} else {
sourceUri
StorageUtils.getGlideSafeUri(sourceUri, sourceMimeType)
}
// request a fresh image with the highest quality format

View file

@ -2,18 +2,17 @@ package deckers.thibault.aves.model.provider
import android.content.ContentResolver
import android.net.Uri
import android.provider.MediaStore
import deckers.thibault.aves.utils.StorageUtils
import java.util.*
object ImageProviderFactory {
fun getProvider(uri: Uri): ImageProvider? {
return when (uri.scheme?.lowercase(Locale.ROOT)) {
ContentResolver.SCHEME_CONTENT -> {
// a URI's authority is [userinfo@]host[:port]
// but we only want the host when comparing to Media Store's "authority"
return when (uri.host?.lowercase(Locale.ROOT)) {
MediaStore.AUTHORITY -> MediaStoreImageProvider()
else -> ContentImageProvider()
if (StorageUtils.isMediaStoreContentUri(uri)) {
MediaStoreImageProvider()
} else {
ContentImageProvider()
}
}
ContentResolver.SCHEME_FILE -> FileImageProvider()

View file

@ -25,7 +25,7 @@ object PermissionManager {
var intent: Intent? = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val sm = activity.getSystemService(StorageManager::class.java)
val sm = activity.getSystemService(Context.STORAGE_SERVICE) as? StorageManager
intent = sm?.getStorageVolume(File(path))?.createOpenDocumentTreeIntent()
}

View file

@ -3,6 +3,7 @@ package deckers.thibault.aves.utils
import android.Manifest
import android.annotation.SuppressLint
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.content.pm.PackageManager
import android.media.MediaMetadataRetriever
@ -15,7 +16,10 @@ import android.text.TextUtils
import android.util.Log
import androidx.annotation.RequiresApi
import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath
import deckers.thibault.aves.utils.UriUtils.tryParseId
import java.io.File
import java.io.FileNotFoundException
import java.io.InputStream
@ -183,14 +187,13 @@ object StorageUtils {
// /storage/10F9-3F13/Pictures/ -> 10F9-3F13
private fun getVolumeUuidForTreeUri(context: Context, anyPath: String): String? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
context.getSystemService(StorageManager::class.java)?.let { sm ->
sm.getStorageVolume(File(anyPath))?.let { volume ->
if (volume.isPrimary) {
return "primary"
}
volume.uuid?.let { uuid ->
return uuid.uppercase(Locale.ROOT)
}
val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager
sm?.getStorageVolume(File(anyPath))?.let { volume ->
if (volume.isPrimary) {
return "primary"
}
volume.uuid?.let { uuid ->
return uuid.uppercase(Locale.ROOT)
}
}
}
@ -218,7 +221,8 @@ object StorageUtils {
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
context.getSystemService(StorageManager::class.java)?.let { sm ->
val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager
if (sm != null) {
for (volumePath in getVolumePaths(context)) {
try {
val volume = sm.getStorageVolume(File(volumePath))
@ -395,7 +399,7 @@ object StorageUtils {
return !onPrimaryVolume
}
private fun isMediaStoreContentUri(uri: Uri?): Boolean {
fun isMediaStoreContentUri(uri: Uri?): Boolean {
uri ?: return false
// a URI's authority is [userinfo@]host[:port]
// but we only want the host when comparing to Media Store's "authority"
@ -407,7 +411,7 @@ object StorageUtils {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
val path = uri.path
path ?: return uri
// from Android R, accessing the original URI for a file media content yields a `SecurityException`
// from Android R, accessing the original URI for a `file` or `downloads` media content yields a `SecurityException`
if (path.startsWith("/external/images/") || path.startsWith("/external/video/")) {
// "Caller must hold ACCESS_MEDIA_LOCATION permission to access original"
if (context.checkSelfPermission(Manifest.permission.ACCESS_MEDIA_LOCATION) == PackageManager.PERMISSION_GRANTED) {
@ -418,6 +422,24 @@ object StorageUtils {
return uri
}
// As of Glide v4.12.0, a special loader `QMediaStoreUriLoader` is automatically used
// to work around a bug from Android Q where metadata redaction corrupts HEIC images.
// This loader relies on `MediaStore.setRequireOriginal` but this yields a `SecurityException`
// for some content URIs (e.g. `content://media/external_primary/downloads/...`)
// so we build a typical `images` or `videos` content URI from the original content ID.
fun getGlideSafeUri(uri: Uri, mimeType: String): Uri {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
uri.tryParseId()?.let { id ->
return when {
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
else -> uri
}
}
}
return uri
}
fun openInputStream(context: Context, uri: Uri): InputStream? {
val effectiveUri = getOriginalUri(context, uri)
return try {

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

@ -1,12 +1,12 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.5.20'
ext.kotlin_version = '1.5.21'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.2.2'
classpath 'com.android.tools.build:gradle:7.0.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.gms:google-services:4.3.8'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.7.1'

View file

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip

View file

@ -1,6 +1,7 @@
import 'dart:ui' as ui show Codec;
import 'package:aves/services/android_app_service.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
@ -49,23 +50,18 @@ class AppIconImage extends ImageProvider<AppIconImageKey> {
}
}
class AppIconImageKey {
@immutable
class AppIconImageKey extends Equatable {
final String packageName;
final double size;
final double scale;
@override
List<Object?> get props => [packageName, size, scale];
const AppIconImageKey({
required this.packageName,
required this.size,
this.scale = 1.0,
});
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is AppIconImageKey && other.packageName == packageName && other.size == size && other.scale == scale;
}
@override
int get hashCode => hashValues(packageName, size, scale);
}

View file

@ -3,6 +3,7 @@ import 'dart:math';
import 'dart:ui' as ui show Codec;
import 'package:aves/services/services.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
@ -62,7 +63,8 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
void pause() => imageFileService.cancelRegion(key);
}
class RegionProviderKey {
@immutable
class RegionProviderKey extends Equatable {
// do not store the entry as it is, because the key should be constant
// but the entry attributes may change over time
final String uri, mimeType;
@ -72,6 +74,9 @@ class RegionProviderKey {
final Rectangle<int> region;
final Size imageSize;
@override
List<Object?> get props => [uri, pageId, rotationDegrees, isFlipped, sampleSize, region, imageSize];
const RegionProviderKey({
required this.uri,
required this.mimeType,
@ -83,24 +88,6 @@ class RegionProviderKey {
required this.imageSize,
});
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.pageId == pageId && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.sampleSize == sampleSize && other.region == region && other.imageSize == imageSize;
}
@override
int get hashCode => hashValues(
uri,
mimeType,
pageId,
rotationDegrees,
isFlipped,
sampleSize,
region,
imageSize,
);
@override
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, region=$region, imageSize=$imageSize}';
}

View file

@ -1,6 +1,7 @@
import 'dart:ui' as ui show Codec;
import 'package:aves/services/services.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
@ -63,7 +64,8 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
void pause() => imageFileService.cancelThumbnail(key);
}
class ThumbnailProviderKey {
@immutable
class ThumbnailProviderKey extends Equatable {
// do not store the entry as it is, because the key should be constant
// but the entry attributes may change over time
final String uri, mimeType;
@ -73,6 +75,9 @@ class ThumbnailProviderKey {
final int dateModifiedSecs;
final double extent;
@override
List<Object?> get props => [uri, pageId, dateModifiedSecs, extent];
const ThumbnailProviderKey({
required this.uri,
required this.mimeType,
@ -83,20 +88,6 @@ class ThumbnailProviderKey {
this.extent = 0,
});
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is ThumbnailProviderKey && other.uri == uri && other.pageId == pageId && other.dateModifiedSecs == dateModifiedSecs && other.extent == extent;
}
@override
int get hashCode => hashValues(
uri,
pageId,
dateModifiedSecs,
extent,
);
@override
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, dateModifiedSecs=$dateModifiedSecs, extent=$extent}';
}

View file

@ -3,15 +3,20 @@ import 'dart:ui' as ui show Codec;
import 'package:aves/services/services.dart';
import 'package:aves/utils/pedantic.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
class UriImage extends ImageProvider<UriImage> {
@immutable
class UriImage extends ImageProvider<UriImage> with EquatableMixin {
final String uri, mimeType;
final int? pageId, rotationDegrees, expectedContentLength;
final bool isFlipped;
final double scale;
@override
List<Object?> get props => [uri, pageId, rotationDegrees, isFlipped, scale];
const UriImage({
required this.uri,
required this.mimeType,
@ -71,22 +76,6 @@ class UriImage extends ImageProvider<UriImage> {
}
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is UriImage && other.uri == uri && other.mimeType == mimeType && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.pageId == pageId && other.scale == scale;
}
@override
int get hashCode => hashValues(
uri,
mimeType,
rotationDegrees,
isFlipped,
pageId,
scale,
);
@override
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, pageId=$pageId, scale=$scale}';
}

View file

@ -62,6 +62,8 @@
"chipActionCreateAlbum": "Create album",
"@chipActionCreateAlbum": {},
"entryActionCopyToClipboard": "Copy to clipboard",
"@entryActionCopyToClipboard": {},
"entryActionDelete": "Delete",
"@entryActionDelete": {},
"entryActionExport": "Export",
@ -319,6 +321,8 @@
"@menuActionSort": {},
"menuActionGroup": "Group",
"@menuActionGroup": {},
"menuActionMap": "Map",
"@menuActionMap": {},
"menuActionStats": "Stats",
"@menuActionStats": {},
@ -704,6 +708,9 @@
"settingsCoordinateFormatTitle": "Coordinate Format",
"@settingsCoordinateFormatTitle": {},
"mapPageTitle": "Map",
"@mapPageTitle": {},
"statsPageTitle": "Stats",
"@statsPageTitle": {},
"statsImage": "{count, plural, =1{image} other{images}}",

View file

@ -31,6 +31,7 @@
"chipActionSetCover": "대표 이미지 변경",
"chipActionCreateAlbum": "앨범 만들기",
"entryActionCopyToClipboard": "클립보드에 복사",
"entryActionDelete": "삭제",
"entryActionExport": "내보내기",
"entryActionInfo": "상세정보",
@ -146,6 +147,7 @@
"menuActionSort": "정렬",
"menuActionGroup": "묶음",
"menuActionMap": "지도",
"menuActionStats": "통계",
"aboutPageTitle": "앱 정보",
@ -337,6 +339,8 @@
"settingsCoordinateFormatTile": "좌표 표현",
"settingsCoordinateFormatTitle": "좌표 표현",
"mapPageTitle": "지도",
"statsPageTitle": "통계",
"statsImage": "{count, plural, other{사진}}",
"statsVideo": "{count, plural, other{동영상}}",

View file

@ -1,7 +1,7 @@
import 'dart:isolate';
import 'package:aves/services/services.dart';
import 'package:aves/widgets/aves_app.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/material.dart';
void main() {
@ -20,12 +20,11 @@ void main() {
//
// flutter run --profile --trace-skia
Isolate.current.addErrorListener(RawReceivePort((pair) async {
initPlatformServices();
Isolate.current.addErrorListener(RawReceivePort((pair) {
final List<dynamic> errorAndStacktrace = pair;
await FirebaseCrashlytics.instance.recordError(
errorAndStacktrace.first,
errorAndStacktrace.last,
);
reportService.recordError(errorAndStacktrace.first, errorAndStacktrace.last);
}).sendPort);
runApp(const AvesApp());

View file

@ -6,6 +6,7 @@ enum ChipSetAction {
// general
sort,
group,
map,
select,
selectAll,
selectNone,
@ -35,6 +36,8 @@ extension ExtraChipSetAction on ChipSetAction {
return context.l10n.collectionActionSelectAll;
case ChipSetAction.selectNone:
return context.l10n.collectionActionSelectNone;
case ChipSetAction.map:
return context.l10n.menuActionMap;
case ChipSetAction.stats:
return context.l10n.menuActionStats;
case ChipSetAction.createAlbum:
@ -68,6 +71,8 @@ extension ExtraChipSetAction on ChipSetAction {
case ChipSetAction.selectAll:
case ChipSetAction.selectNone:
return null;
case ChipSetAction.map:
return AIcons.map;
case ChipSetAction.stats:
return AIcons.stats;
case ChipSetAction.createAlbum:

View file

@ -5,6 +5,7 @@ enum CollectionAction {
select,
selectAll,
selectNone,
map,
stats,
// apply to entry set
copy,

View file

@ -19,6 +19,7 @@ enum EntryAction {
// motion photo,
viewMotionPhotoVideo,
// external
copyToClipboard,
edit,
open,
openMap,
@ -42,6 +43,7 @@ class EntryActions {
EntryAction.delete,
EntryAction.rename,
EntryAction.export,
EntryAction.copyToClipboard,
EntryAction.print,
EntryAction.viewSource,
EntryAction.viewMotionPhotoVideo,
@ -68,6 +70,8 @@ extension ExtraEntryAction on EntryAction {
case EntryAction.toggleFavourite:
// different data depending on toggle state
return context.l10n.entryActionAddFavourite;
case EntryAction.copyToClipboard:
return context.l10n.entryActionCopyToClipboard;
case EntryAction.delete:
return context.l10n.entryActionDelete;
case EntryAction.export:
@ -116,6 +120,8 @@ extension ExtraEntryAction on EntryAction {
case EntryAction.toggleFavourite:
// different data depending on toggle state
return AIcons.favourite;
case EntryAction.copyToClipboard:
return AIcons.clipboard;
case EntryAction.delete:
return AIcons.delete;
case EntryAction.export:

View file

@ -3,6 +3,7 @@ import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/services/services.dart';
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
@ -77,10 +78,13 @@ class Covers with ChangeNotifier {
}
@immutable
class CoverRow {
class CoverRow extends Equatable {
final CollectionFilter filter;
final int contentId;
@override
List<Object?> get props => [filter, contentId];
const CoverRow({
required this.filter,
required this.contentId,
@ -99,16 +103,4 @@ class CoverRow {
'filter': filter.toJson(),
'contentId': contentId,
};
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is CoverRow && other.filter == filter && other.contentId == contentId;
}
@override
int get hashCode => hashValues(filter, contentId);
@override
String toString() => '$runtimeType#${shortHash(this)}{filter=$filter, contentId=$contentId}';
}

View file

@ -13,7 +13,6 @@ import 'package:aves/services/service_policy.dart';
import 'package:aves/services/services.dart';
import 'package:aves/services/svg_metadata_service.dart';
import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:collection/collection.dart';
import 'package:country_code/country_code.dart';
@ -382,13 +381,6 @@ class AvesEntry {
LatLng? get latLng => hasGps ? LatLng(_catalogMetadata!.latitude!, _catalogMetadata!.longitude!) : null;
String? get geoUri {
if (!hasGps) return null;
final latitude = roundToPrecision(_catalogMetadata!.latitude!, decimals: 6);
final longitude = roundToPrecision(_catalogMetadata!.longitude!, decimals: 6);
return 'geo:$latitude,$longitude?q=$latitude,$longitude';
}
List<String>? _xmpSubjects;
List<String> get xmpSubjects {

View file

@ -10,6 +10,8 @@ import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
extension ExtraAvesEntry on AvesEntry {
bool isThumbnailReady({double extent = 0}) => _isReady(_getThumbnailProviderKey(extent));
ThumbnailProvider getThumbnail({double extent = 0}) {
return ThumbnailProvider(_getThumbnailProviderKey(extent));
}

View file

@ -1,7 +1,7 @@
import 'package:aves/model/entry.dart';
import 'package:aves/services/services.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/widgets.dart';
final Favourites favourites = Favourites._private();
@ -62,10 +62,13 @@ class Favourites with ChangeNotifier {
}
@immutable
class FavouriteRow {
class FavouriteRow extends Equatable {
final int contentId;
final String path;
@override
List<Object?> get props => [contentId, path];
const FavouriteRow({
required this.contentId,
required this.path,
@ -82,16 +85,4 @@ class FavouriteRow {
'contentId': contentId,
'path': path,
};
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is FavouriteRow && other.contentId == contentId && other.path == path;
}
@override
int get hashCode => hashValues(contentId, path);
@override
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, path=$path}';
}

View file

@ -16,6 +16,9 @@ class AlbumFilter extends CollectionFilter {
final String album;
final String? displayName;
@override
List<Object?> get props => [album];
const AlbumFilter(this.album, this.displayName);
AlbumFilter.fromMap(Map<String, dynamic> json)
@ -78,16 +81,4 @@ class AlbumFilter extends CollectionFilter {
// key `album-{path}` is expected by test driver
@override
String get key => '$type-$album';
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is AlbumFilter && other.album == album;
}
@override
int get hashCode => hashValues(type, album);
@override
String toString() => '$runtimeType#${shortHash(this)}{album=$album}';
}

View file

@ -10,6 +10,9 @@ class FavouriteFilter extends CollectionFilter {
static const instance = FavouriteFilter._private();
@override
List<Object?> get props => [];
const FavouriteFilter._private();
@override
@ -37,13 +40,4 @@ class FavouriteFilter extends CollectionFilter {
@override
String get key => type;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is FavouriteFilter;
}
@override
int get hashCode => type.hashCode;
}

View file

@ -11,10 +11,12 @@ import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/filters/type.dart';
import 'package:aves/utils/color_utils.dart';
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
abstract class CollectionFilter implements Comparable<CollectionFilter> {
@immutable
abstract class CollectionFilter extends Equatable implements Comparable<CollectionFilter> {
static const List<String> categoryOrder = [
QueryFilter.type,
FavouriteFilter.type,
@ -88,20 +90,15 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
}
}
class FilterGridItem<T extends CollectionFilter> {
@immutable
class FilterGridItem<T extends CollectionFilter> with EquatableMixin {
final T filter;
final AvesEntry? entry;
@override
List<Object?> get props => [filter, entry?.uri];
const FilterGridItem(this.filter, this.entry);
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is FilterGridItem && other.filter == filter && other.entry == entry;
}
@override
int get hashCode => hashValues(filter, entry);
}
typedef EntryFilter = bool Function(AvesEntry);

View file

@ -2,7 +2,6 @@ import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
class LocationFilter extends CollectionFilter {
@ -10,14 +9,17 @@ class LocationFilter extends CollectionFilter {
static const locationSeparator = ';';
final LocationLevel level;
String _location;
String? _countryCode;
late EntryFilter _test;
late final String _location;
late final String? _countryCode;
late final EntryFilter _test;
LocationFilter(this.level, this._location) {
final split = _location.split(locationSeparator);
if (split.isNotEmpty) _location = split[0];
if (split.length > 1) _countryCode = split[1];
@override
List<Object?> get props => [level, _location, _countryCode];
LocationFilter(this.level, String location) {
final split = location.split(locationSeparator);
_location = split.isNotEmpty ? split[0] : location;
_countryCode = split.length > 1 ? split[1] : null;
if (_location.isEmpty) {
_test = (entry) => !entry.hasGps;
@ -75,18 +77,6 @@ class LocationFilter extends CollectionFilter {
@override
String get key => '$type-$level-$_location';
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is LocationFilter && other.level == level && other._location == _location;
}
@override
int get hashCode => hashValues(type, level, _location);
@override
String toString() => '$runtimeType#${shortHash(this)}{level=$level, location=$_location}';
// U+0041 Latin Capital letter A
// U+1F1E6 🇦 REGIONAL INDICATOR SYMBOL LETTER A
static const _countryCodeToFlagDiff = 0x1F1E6 - 0x0041;

View file

@ -3,20 +3,22 @@ import 'package:aves/ref/mime_types.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/mime_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
class MimeFilter extends CollectionFilter {
static const type = 'mime';
final String mime;
late EntryFilter _test;
late String _label;
late IconData _icon;
late final EntryFilter _test;
late final String _label;
late final IconData _icon;
static final image = MimeFilter(MimeTypes.anyImage);
static final video = MimeFilter(MimeTypes.anyVideo);
@override
List<Object?> get props => [mime];
MimeFilter(this.mime) {
IconData? icon;
var lowMime = mime.toLowerCase();
@ -73,16 +75,4 @@ class MimeFilter extends CollectionFilter {
@override
String get key => '$type-$mime';
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is MimeFilter && other.mime == mime;
}
@override
int get hashCode => hashValues(type, mime);
@override
String toString() => '$runtimeType#${shortHash(this)}{mime=$mime}';
}

View file

@ -1,12 +1,13 @@
import 'package:aves/model/filters/filters.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
class PathFilter extends CollectionFilter {
static const type = 'path';
final String path;
@override
List<Object?> get props => [path];
const PathFilter(this.path);
PathFilter.fromMap(Map<String, dynamic> json)
@ -31,16 +32,4 @@ class PathFilter extends CollectionFilter {
@override
String get key => '$type-$path';
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is PathFilter && other.path == path;
}
@override
int get hashCode => hashValues(type, path);
@override
String toString() => '$runtimeType#${shortHash(this)}{path=$path}';
}

View file

@ -12,7 +12,10 @@ class QueryFilter extends CollectionFilter {
final String query;
final bool colorful;
late EntryFilter _test;
late final EntryFilter _test;
@override
List<Object?> get props => [query];
QueryFilter(this.query, {this.colorful = true}) {
var upQuery = query.toUpperCase();
@ -63,16 +66,4 @@ class QueryFilter extends CollectionFilter {
@override
String get key => '$type-$query';
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is QueryFilter && other.query == query;
}
@override
int get hashCode => hashValues(type, query);
@override
String toString() => '$runtimeType#${shortHash(this)}{query=$query}';
}

View file

@ -1,14 +1,16 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
class TagFilter extends CollectionFilter {
static const type = 'tag';
final String tag;
late EntryFilter _test;
late final EntryFilter _test;
@override
List<Object?> get props => [tag];
TagFilter(this.tag) {
if (tag.isEmpty) {
@ -49,16 +51,4 @@ class TagFilter extends CollectionFilter {
@override
String get key => '$type-$tag';
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is TagFilter && other.tag == tag;
}
@override
int get hashCode => hashValues(type, tag);
@override
String toString() => '$runtimeType#${shortHash(this)}{tag=$tag}';
}

View file

@ -1,7 +1,6 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
class TypeFilter extends CollectionFilter {
@ -14,8 +13,8 @@ class TypeFilter extends CollectionFilter {
static const _sphericalVideo = 'spherical_video'; // subset of videos
final String itemType;
late EntryFilter _test;
late IconData _icon;
late final EntryFilter _test;
late final IconData _icon;
static final animated = TypeFilter._private(_animated);
static final geotiff = TypeFilter._private(_geotiff);
@ -23,12 +22,19 @@ class TypeFilter extends CollectionFilter {
static final panorama = TypeFilter._private(_panorama);
static final sphericalVideo = TypeFilter._private(_sphericalVideo);
@override
List<Object?> get props => [itemType];
TypeFilter._private(this.itemType) {
switch (itemType) {
case _animated:
_test = (entry) => entry.isAnimated;
_icon = AIcons.animated;
break;
case _geotiff:
_test = (entry) => entry.isGeotiff;
_icon = AIcons.geo;
break;
case _motionPhoto:
_test = (entry) => entry.isMotionPhoto;
_icon = AIcons.motionPhoto;
@ -41,10 +47,6 @@ class TypeFilter extends CollectionFilter {
_test = (entry) => entry.isVideo && entry.is360;
_icon = AIcons.threeSixty;
break;
case _geotiff:
_test = (entry) => entry.isGeotiff;
_icon = AIcons.geo;
break;
}
}
@ -91,16 +93,4 @@ class TypeFilter extends CollectionFilter {
@override
String get key => '$type-$itemType';
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is TypeFilter && other.itemType == itemType;
}
@override
int get hashCode => hashValues(type, itemType);
@override
String toString() => '$runtimeType#${shortHash(this)}{itemType=$itemType}';
}

View file

@ -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
@ -284,7 +299,7 @@ class SqfliteMetadataDb implements MetadataDb {
await batch.commit(noResult: true);
debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
} catch (error, stack) {
debugPrint('$runtimeType failed to save metadata with exception=$error\n$stack');
debugPrint('$runtimeType failed to save metadata with error=$error\n$stack');
}
}

View file

@ -13,7 +13,6 @@ import 'package:aves/services/services.dart';
import 'package:aves/utils/pedantic.dart';
import 'package:collection/collection.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
@ -120,7 +119,7 @@ class Settings extends ChangeNotifier {
// to allow settings customization without Firebase context (e.g. before a Flutter Driver test)
Future<void> initFirebase() async {
await Firebase.app().setAutomaticDataCollectionEnabled(isCrashlyticsEnabled);
await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(isCrashlyticsEnabled);
await reportService.setCollectionEnabled(isCrashlyticsEnabled);
}
Future<void> reset({required bool includeInternalKeys}) async {

View file

@ -29,7 +29,8 @@ mixin AlbumMixin on SourceBase {
void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent());
String getAlbumDisplayName(BuildContext? context, String dirPath) {
assert(!dirPath.endsWith(pContext.separator));
final separator = pContext.separator;
assert(!dirPath.endsWith(separator));
if (context != null) {
final type = androidFileUtils.getAlbumType(dirPath);
@ -52,8 +53,9 @@ mixin AlbumMixin on SourceBase {
String unique(String dirPath, Set<String?> others) {
final parts = pContext.split(dirPath);
for (var i = parts.length - 1; i > 0; i--) {
final testName = pContext.joinAll(['', ...parts.skip(i)]);
if (others.every((item) => !item!.endsWith(testName))) return testName;
final name = pContext.joinAll(['', ...parts.skip(i)]);
final testName = '$separator$name';
if (others.every((item) => !item!.endsWith(testName))) return name;
}
return dirPath;
}

View file

@ -1,41 +1,25 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
@immutable
class SectionKey {
const SectionKey();
}
class EntryAlbumSectionKey extends SectionKey {
class EntryAlbumSectionKey extends SectionKey with EquatableMixin {
final String? directory;
@override
List<Object?> get props => [directory];
const EntryAlbumSectionKey(this.directory);
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is EntryAlbumSectionKey && other.directory == directory;
}
@override
int get hashCode => directory.hashCode;
@override
String toString() => '$runtimeType#${shortHash(this)}{directory=$directory}';
}
class EntryDateSectionKey extends SectionKey {
class EntryDateSectionKey extends SectionKey with EquatableMixin {
final DateTime? date;
@override
List<Object?> get props => [date];
const EntryDateSectionKey(this.date);
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is EntryDateSectionKey && other.date == date;
}
@override
int get hashCode => date.hashCode;
@override
String toString() => '$runtimeType#${shortHash(this)}{date=$date}';
}

View file

@ -1,10 +1,12 @@
import 'dart:typed_data';
import 'package:aves/model/entry.dart';
import 'package:aves/services/services.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart';
class AndroidAppService {
static const platform = MethodChannel('deckers.thibault/aves/app');
@ -20,7 +22,7 @@ class AndroidAppService {
}
return packages;
} on PlatformException catch (e) {
debugPrint('getPackages failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
await reportService.recordChannelError('getPackages', e);
}
return {};
}
@ -33,11 +35,24 @@ class AndroidAppService {
});
if (result != null) return result as Uint8List;
} on PlatformException catch (e) {
debugPrint('getAppIcon failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('getAppIcon', e);
}
return Uint8List(0);
}
static Future<bool> copyToClipboard(String uri, String? label) async {
try {
final result = await platform.invokeMethod('copyToClipboard', <String, dynamic>{
'uri': uri,
'label': label,
});
if (result != null) return result as bool;
} on PlatformException catch (e) {
await reportService.recordChannelError('copyToClipboard', e);
}
return false;
}
static Future<bool> edit(String uri, String mimeType) async {
try {
final result = await platform.invokeMethod('edit', <String, dynamic>{
@ -46,7 +61,7 @@ class AndroidAppService {
});
if (result != null) return result as bool;
} on PlatformException catch (e) {
debugPrint('edit failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('edit', e);
}
return false;
}
@ -59,19 +74,23 @@ class AndroidAppService {
});
if (result != null) return result as bool;
} on PlatformException catch (e) {
debugPrint('open failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('open', e);
}
return false;
}
static Future<bool> openMap(String geoUri) async {
static Future<bool> openMap(LatLng latLng) async {
final latitude = roundToPrecision(latLng.latitude, decimals: 6);
final longitude = roundToPrecision(latLng.longitude, decimals: 6);
final geoUri = 'geo:$latitude,$longitude?q=$latitude,$longitude';
try {
final result = await platform.invokeMethod('openMap', <String, dynamic>{
'geoUri': geoUri,
});
if (result != null) return result as bool;
} on PlatformException catch (e) {
debugPrint('openMap failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('openMap', e);
}
return false;
}
@ -84,7 +103,7 @@ class AndroidAppService {
});
if (result != null) return result as bool;
} on PlatformException catch (e) {
debugPrint('setAs failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('setAs', e);
}
return false;
}
@ -99,7 +118,7 @@ class AndroidAppService {
});
if (result != null) return result as bool;
} on PlatformException catch (e) {
debugPrint('shareEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('shareEntries', e);
}
return false;
}
@ -113,7 +132,7 @@ class AndroidAppService {
});
if (result != null) return result as bool;
} on PlatformException catch (e) {
debugPrint('shareSingle failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('shareSingle', e);
}
return false;
}

View file

@ -1,17 +1,57 @@
import 'package:aves/model/entry.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:flutter/foundation.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/services.dart';
class AndroidDebugService {
static const platform = MethodChannel('deckers.thibault/aves/debug');
static Future<void> crash() async {
try {
await platform.invokeMethod('crash');
} on PlatformException catch (e) {
await reportService.recordChannelError('crash', e);
}
}
static Future<void> exception() async {
try {
await platform.invokeMethod('exception');
} on PlatformException catch (e) {
await reportService.recordChannelError('exception', e);
}
}
static Future<void> safeException() async {
try {
await platform.invokeMethod('safeException');
} on PlatformException catch (e) {
await reportService.recordChannelError('safeException', e);
}
}
static Future<void> exceptionInCoroutine() async {
try {
await platform.invokeMethod('exceptionInCoroutine');
} on PlatformException catch (e) {
await reportService.recordChannelError('exceptionInCoroutine', e);
}
}
static Future<void> safeExceptionInCoroutine() async {
try {
await platform.invokeMethod('safeExceptionInCoroutine');
} on PlatformException catch (e) {
await reportService.recordChannelError('safeExceptionInCoroutine', e);
}
}
static Future<Map> getContextDirs() async {
try {
final result = await platform.invokeMethod('getContextDirs');
if (result != null) return result as Map;
} on PlatformException catch (e) {
debugPrint('getContextDirs failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
await reportService.recordChannelError('getContextDirs', e);
}
return {};
}
@ -21,7 +61,7 @@ class AndroidDebugService {
final result = await platform.invokeMethod('getEnv');
if (result != null) return result as Map;
} on PlatformException catch (e) {
debugPrint('getEnv failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
await reportService.recordChannelError('getEnv', e);
}
return {};
}
@ -34,7 +74,7 @@ class AndroidDebugService {
});
if (result != null) return result as Map;
} on PlatformException catch (e) {
debugPrint('getBitmapFactoryInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('getBitmapFactoryInfo', e);
}
return {};
}
@ -48,7 +88,7 @@ class AndroidDebugService {
});
if (result != null) return result as Map;
} on PlatformException catch (e) {
debugPrint('getContentResolverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('getContentResolverMetadata', e);
}
return {};
}
@ -63,7 +103,7 @@ class AndroidDebugService {
});
if (result != null) return result as Map;
} on PlatformException catch (e) {
debugPrint('getExifInterfaceMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('getExifInterfaceMetadata', e);
}
return {};
}
@ -76,7 +116,7 @@ class AndroidDebugService {
});
if (result != null) return result as Map;
} on PlatformException catch (e) {
debugPrint('getMediaMetadataRetrieverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('getMediaMetadataRetrieverMetadata', e);
}
return {};
}
@ -91,7 +131,7 @@ class AndroidDebugService {
});
if (result != null) return result as Map;
} on PlatformException catch (e) {
debugPrint('getMetadataExtractorSummary failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('getMetadataExtractorSummary', e);
}
return {};
}
@ -105,7 +145,7 @@ class AndroidDebugService {
});
if (result != null) return result as Map;
} on PlatformException catch (e) {
debugPrint('getTiffStructure failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('getTiffStructure', e);
}
return {};
}

View file

@ -24,7 +24,7 @@ class AppShortcutService {
return result;
}
} on PlatformException catch (e) {
debugPrint('canPin failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
await reportService.recordChannelError('canPin', e);
}
return false;
}
@ -50,7 +50,7 @@ class AppShortcutService {
'filters': filters.map((filter) => filter.toJson()).toList(),
});
} on PlatformException catch (e) {
debugPrint('pin failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('pin', e);
}
}
}

View file

@ -1,6 +1,5 @@
import 'package:flutter/foundation.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
class DeviceService {
static const platform = MethodChannel('deckers.thibault/aves/device');
@ -11,7 +10,7 @@ class DeviceService {
final result = await platform.invokeMethod('getPerformanceClass');
if (result != null) return result as int;
} on PlatformException catch (e) {
debugPrint('getPerformanceClass failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('getPerformanceClass', e);
}
return 0;
}

View file

@ -1,7 +1,7 @@
import 'dart:typed_data';
import 'package:aves/model/entry.dart';
import 'package:flutter/foundation.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/services.dart';
abstract class EmbeddedDataService {
@ -27,7 +27,7 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
});
if (result != null) return (result as List).cast<Uint8List>();
} on PlatformException catch (e) {
debugPrint('getExifThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('getExifThumbnail', e);
}
return [];
}
@ -43,7 +43,7 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
});
if (result != null) return result as Map;
} on PlatformException catch (e) {
debugPrint('extractMotionPhotoVideo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('extractMotionPhotoVideo', e);
}
return {};
}
@ -57,7 +57,7 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
});
if (result != null) return result as Map;
} on PlatformException catch (e) {
debugPrint('extractVideoEmbeddedPicture failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('extractVideoEmbeddedPicture', e);
}
return {};
}
@ -75,7 +75,7 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
});
if (result != null) return result as Map;
} on PlatformException catch (e) {
debugPrint('extractXmpDataProp failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('extractXmpDataProp', e);
}
return {};
}

View file

@ -1,8 +1,8 @@
import 'dart:async';
import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:latlong2/latlong.dart';
class GeocodingService {
@ -21,7 +21,7 @@ class GeocodingService {
});
return (result as List).cast<Map>().map((map) => Address.fromMap(map)).toList();
} on PlatformException catch (e) {
debugPrint('getAddress failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
await reportService.recordChannelError('getAddress', e);
}
return [];
}

View file

@ -0,0 +1,65 @@
import 'dart:ui';
import 'package:aves/services/services.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) {
await reportService.recordChannelError('registerCallback', e);
}
}
}
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: 9);
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

@ -1,14 +1,15 @@
import 'dart:async';
import 'dart:math';
import 'dart:typed_data';
import 'dart:ui';
import 'package:aves/model/entry.dart';
import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/output_buffer.dart';
import 'package:aves/services/service_policy.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:streams_channel/streams_channel.dart';
abstract class ImageFileService {
@ -124,7 +125,7 @@ class PlatformImageFileService implements ImageFileService {
}) as Map;
return AvesEntry.fromMap(result);
} on PlatformException catch (e) {
debugPrint('getEntry failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('getEntry', e);
}
return null;
}
@ -188,7 +189,7 @@ class PlatformImageFileService implements ImageFileService {
);
return completer.future;
} on PlatformException catch (e) {
debugPrint('getImage failed with code=${e.code}, exception=${e.message}, details=${e.details}');
reportService.recordChannelError('getImage', e);
}
return Future.sync(() => Uint8List(0));
}
@ -223,7 +224,7 @@ class PlatformImageFileService implements ImageFileService {
});
if (result != null) return result as Uint8List;
} on PlatformException catch (e) {
debugPrint('getRegion failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('getRegion', e);
}
return Uint8List(0);
},
@ -260,7 +261,7 @@ class PlatformImageFileService implements ImageFileService {
});
if (result != null) return result as Uint8List;
} on PlatformException catch (e) {
debugPrint('getThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('getThumbnail', e);
}
return Uint8List(0);
},
@ -274,7 +275,7 @@ class PlatformImageFileService implements ImageFileService {
try {
return platform.invokeMethod('clearSizedThumbnailDiskCache');
} on PlatformException catch (e) {
debugPrint('clearSizedThumbnailDiskCache failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('clearSizedThumbnailDiskCache', e);
}
}
@ -295,7 +296,7 @@ class PlatformImageFileService implements ImageFileService {
'entries': entries.map(_toPlatformEntryMap).toList(),
}).map((event) => ImageOpEvent.fromMap(event));
} on PlatformException catch (e) {
debugPrint('delete failed with code=${e.code}, exception=${e.message}, details=${e.details}');
reportService.recordChannelError('delete', e);
return Stream.error(e);
}
}
@ -314,7 +315,7 @@ class PlatformImageFileService implements ImageFileService {
'destinationPath': destinationAlbum,
}).map((event) => MoveOpEvent.fromMap(event));
} on PlatformException catch (e) {
debugPrint('move failed with code=${e.code}, exception=${e.message}, details=${e.details}');
reportService.recordChannelError('move', e);
return Stream.error(e);
}
}
@ -333,7 +334,7 @@ class PlatformImageFileService implements ImageFileService {
'destinationPath': destinationAlbum,
}).map((event) => ExportOpEvent.fromMap(event));
} on PlatformException catch (e) {
debugPrint('export failed with code=${e.code}, exception=${e.message}, details=${e.details}');
reportService.recordChannelError('export', e);
return Stream.error(e);
}
}
@ -356,7 +357,7 @@ class PlatformImageFileService implements ImageFileService {
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e) {
debugPrint('captureFrame failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('captureFrame', e);
}
return {};
}
@ -371,7 +372,7 @@ class PlatformImageFileService implements ImageFileService {
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e) {
debugPrint('rename failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('rename', e);
}
return {};
}
@ -386,7 +387,7 @@ class PlatformImageFileService implements ImageFileService {
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e) {
debugPrint('rotate failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('rotate', e);
}
return {};
}
@ -400,7 +401,7 @@ class PlatformImageFileService implements ImageFileService {
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e) {
debugPrint('flip failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('flip', e);
}
return {};
}

View file

@ -1,11 +1,14 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
@immutable
class ImageOpEvent {
class ImageOpEvent extends Equatable {
final bool success;
final String uri;
@override
List<Object?> get props => [success, uri];
const ImageOpEvent({
required this.success,
required this.uri,
@ -17,18 +20,6 @@ class ImageOpEvent {
uri: map['uri'],
);
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is ImageOpEvent && other.success == success && other.uri == uri;
}
@override
int get hashCode => hashValues(success, uri);
@override
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri}';
}
class MoveOpEvent extends ImageOpEvent {
@ -55,6 +46,9 @@ class MoveOpEvent extends ImageOpEvent {
class ExportOpEvent extends MoveOpEvent {
final int? pageId;
@override
List<Object?> get props => [success, uri, pageId];
const ExportOpEvent({required bool success, required String uri, this.pageId, required Map newFields})
: super(
success: success,
@ -70,16 +64,4 @@ class ExportOpEvent extends MoveOpEvent {
newFields: map['newFields'] ?? {},
);
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is ExportOpEvent && other.success == success && other.uri == uri && other.pageId == pageId;
}
@override
int get hashCode => hashValues(success, uri, pageId);
@override
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri, pageId=$pageId, newFields=$newFields}';
}

View file

@ -1,8 +1,8 @@
import 'dart:async';
import 'package:aves/model/entry.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:streams_channel/streams_channel.dart';
abstract class MediaStoreService {
@ -26,7 +26,7 @@ class PlatformMediaStoreService implements MediaStoreService {
});
return (result as List).cast<int>();
} on PlatformException catch (e) {
debugPrint('checkObsoleteContentIds failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('checkObsoleteContentIds', e);
}
return [];
}
@ -39,7 +39,7 @@ class PlatformMediaStoreService implements MediaStoreService {
});
return (result as List).cast<int>();
} on PlatformException catch (e) {
debugPrint('checkObsoletePaths failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('checkObsoletePaths', e);
}
return [];
}
@ -51,7 +51,7 @@ class PlatformMediaStoreService implements MediaStoreService {
'knownEntries': knownEntries,
}).map((event) => AvesEntry.fromMap(event));
} on PlatformException catch (e) {
debugPrint('getEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}');
reportService.recordChannelError('getEntries', e);
return Stream.error(e);
}
}

View file

@ -3,7 +3,7 @@ import 'package:aves/model/metadata.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/panorama.dart';
import 'package:aves/services/service_policy.dart';
import 'package:flutter/foundation.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/services.dart';
abstract class MetadataService {
@ -36,7 +36,7 @@ class PlatformMetadataService implements MetadataService {
});
if (result != null) return result as Map;
} on PlatformException catch (e) {
debugPrint('getAllMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('getAllMetadata', e);
}
return {};
}
@ -66,7 +66,7 @@ class PlatformMetadataService implements MetadataService {
result['contentId'] = entry.contentId;
return CatalogMetadata.fromMap(result);
} on PlatformException catch (e) {
debugPrint('getCatalogMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('getCatalogMetadata', e);
}
return null;
}
@ -92,7 +92,7 @@ class PlatformMetadataService implements MetadataService {
}) as Map;
return OverlayMetadata.fromMap(result);
} on PlatformException catch (e) {
debugPrint('getOverlayMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('getOverlayMetadata', e);
}
return null;
}
@ -114,7 +114,7 @@ class PlatformMetadataService implements MetadataService {
}
return MultiPageInfo.fromPageMaps(entry, pageMaps);
} on PlatformException catch (e) {
debugPrint('getMultiPageInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('getMultiPageInfo', e);
}
return null;
}
@ -132,7 +132,7 @@ class PlatformMetadataService implements MetadataService {
}) as Map;
return PanoramaInfo.fromMap(result);
} on PlatformException catch (e) {
debugPrint('PanoramaInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('PanoramaInfo', e);
}
return null;
}
@ -146,7 +146,7 @@ class PlatformMetadataService implements MetadataService {
'prop': prop,
});
} on PlatformException catch (e) {
debugPrint('getContentResolverProp failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('getContentResolverProp', e);
}
return null;
}

View file

@ -0,0 +1,55 @@
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
abstract class ReportService {
bool get isCollectionEnabled;
Future<void> setCollectionEnabled(bool enabled);
Future<void> log(String message);
Future<void> setCustomKey(String key, Object value);
Future<void> setCustomKeys(Map<String, Object> map);
Future<void> recordError(dynamic exception, StackTrace? stack);
Future<void> recordFlutterError(FlutterErrorDetails flutterErrorDetails);
Future<void> recordChannelError(String method, PlatformException e) {
return recordError('$method failed with code=${e.code}, exception=${e.message}, details=${e.details}}', null);
}
}
class CrashlyticsReportService extends ReportService {
FirebaseCrashlytics get instance => FirebaseCrashlytics.instance;
@override
bool get isCollectionEnabled => instance.isCrashlyticsCollectionEnabled;
@override
Future<void> setCollectionEnabled(bool enabled) => instance.setCrashlyticsCollectionEnabled(enabled);
@override
Future<void> log(String message) => instance.log(message);
@override
Future<void> setCustomKey(String key, Object value) => instance.setCustomKey(key, value);
@override
Future<void> setCustomKeys(Map<String, Object> map) {
final _instance = instance;
return Future.forEach<MapEntry<String, Object>>(map.entries, (kv) => _instance.setCustomKey(kv.key, kv.value));
}
@override
Future<void> recordError(dynamic exception, StackTrace? stack) {
return instance.recordError(exception, stack);
}
@override
Future<void> recordFlutterError(FlutterErrorDetails flutterErrorDetails) {
return instance.recordFlutterError(flutterErrorDetails);
}
}

View file

@ -4,6 +4,7 @@ import 'package:aves/services/embedded_data_service.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/media_store_service.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/services/report_service.dart';
import 'package:aves/services/storage_service.dart';
import 'package:aves/services/time_service.dart';
import 'package:aves/services/window_service.dart';
@ -20,6 +21,7 @@ final EmbeddedDataService embeddedDataService = getIt<EmbeddedDataService>();
final ImageFileService imageFileService = getIt<ImageFileService>();
final MediaStoreService mediaStoreService = getIt<MediaStoreService>();
final MetadataService metadataService = getIt<MetadataService>();
final ReportService reportService = getIt<ReportService>();
final StorageService storageService = getIt<StorageService>();
final TimeService timeService = getIt<TimeService>();
final WindowService windowService = getIt<WindowService>();
@ -33,6 +35,7 @@ void initPlatformServices() {
getIt.registerLazySingleton<ImageFileService>(() => PlatformImageFileService());
getIt.registerLazySingleton<MediaStoreService>(() => PlatformMediaStoreService());
getIt.registerLazySingleton<MetadataService>(() => PlatformMetadataService());
getIt.registerLazySingleton<ReportService>(() => CrashlyticsReportService());
getIt.registerLazySingleton<StorageService>(() => PlatformStorageService());
getIt.registerLazySingleton<TimeService>(() => PlatformTimeService());
getIt.registerLazySingleton<WindowService>(() => PlatformWindowService());

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:typed_data';
import 'package:aves/services/output_buffer.dart';
import 'package:aves/services/services.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@ -47,7 +48,7 @@ class PlatformStorageService implements StorageService {
final result = await platform.invokeMethod('getStorageVolumes');
return (result as List).cast<Map>().map((map) => StorageVolume.fromMap(map)).toSet();
} on PlatformException catch (e) {
debugPrint('getStorageVolumes failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
await reportService.recordChannelError('getStorageVolumes', e);
}
return {};
}
@ -60,7 +61,7 @@ class PlatformStorageService implements StorageService {
});
return result as int?;
} on PlatformException catch (e) {
debugPrint('getFreeSpace failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
await reportService.recordChannelError('getFreeSpace', e);
}
return null;
}
@ -71,7 +72,7 @@ class PlatformStorageService implements StorageService {
final result = await platform.invokeMethod('getGrantedDirectories');
return (result as List).cast<String>();
} on PlatformException catch (e) {
debugPrint('getGrantedDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
await reportService.recordChannelError('getGrantedDirectories', e);
}
return [];
}
@ -83,7 +84,7 @@ class PlatformStorageService implements StorageService {
'path': path,
});
} on PlatformException catch (e) {
debugPrint('revokeDirectoryAccess failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
await reportService.recordChannelError('revokeDirectoryAccess', e);
}
return;
}
@ -98,7 +99,7 @@ class PlatformStorageService implements StorageService {
return (result as List).cast<Map>().map(VolumeRelativeDirectory.fromMap).toSet();
}
} on PlatformException catch (e) {
debugPrint('getInaccessibleDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
await reportService.recordChannelError('getInaccessibleDirectories', e);
}
return {};
}
@ -111,7 +112,7 @@ class PlatformStorageService implements StorageService {
return (result as List).cast<Map>().map(VolumeRelativeDirectory.fromMap).toSet();
}
} on PlatformException catch (e) {
debugPrint('getRestrictedDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
await reportService.recordChannelError('getRestrictedDirectories', e);
}
return {};
}
@ -134,7 +135,7 @@ class PlatformStorageService implements StorageService {
);
return completer.future;
} on PlatformException catch (e) {
debugPrint('requestVolumeAccess failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
await reportService.recordChannelError('requestVolumeAccess', e);
}
return false;
}
@ -148,7 +149,7 @@ class PlatformStorageService implements StorageService {
});
if (result != null) return result as int;
} on PlatformException catch (e) {
debugPrint('deleteEmptyDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
await reportService.recordChannelError('deleteEmptyDirectories', e);
}
return 0;
}
@ -164,7 +165,7 @@ class PlatformStorageService implements StorageService {
});
if (result != null) return Uri.tryParse(result);
} on PlatformException catch (e) {
debugPrint('scanFile failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
await reportService.recordChannelError('scanFile', e);
}
return null;
}
@ -172,7 +173,7 @@ class PlatformStorageService implements StorageService {
@override
Future<bool?> createFile(String name, String mimeType, Uint8List bytes) async {
try {
final completer = Completer<bool>();
final completer = Completer<bool?>();
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'createFile',
'name': name,
@ -188,7 +189,7 @@ class PlatformStorageService implements StorageService {
);
return completer.future;
} on PlatformException catch (e) {
debugPrint('createFile failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
await reportService.recordChannelError('createFile', e);
}
return false;
}
@ -215,7 +216,7 @@ class PlatformStorageService implements StorageService {
);
return completer.future;
} on PlatformException catch (e) {
debugPrint('openFile failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
await reportService.recordChannelError('openFile', e);
}
return Uint8List(0);
}
@ -223,7 +224,7 @@ class PlatformStorageService implements StorageService {
@override
Future<String?> selectDirectory() async {
try {
final completer = Completer<String>();
final completer = Completer<String?>();
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'selectDirectory',
}).listen(
@ -236,7 +237,7 @@ class PlatformStorageService implements StorageService {
);
return completer.future;
} on PlatformException catch (e) {
debugPrint('selectDirectory failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
await reportService.recordChannelError('selectDirectory', e);
}
return null;
}

View file

@ -1,4 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/services.dart';
abstract class TimeService {
@ -13,7 +13,7 @@ class PlatformTimeService implements TimeService {
try {
return await platform.invokeMethod('getDefaultTimeZone');
} on PlatformException catch (e) {
debugPrint('getDefaultTimeZone failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('getDefaultTimeZone', e);
}
return null;
}

View file

@ -1,4 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/services.dart';
class ViewerService {
@ -10,7 +10,7 @@ class ViewerService {
final result = await platform.invokeMethod('getIntentData');
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e) {
debugPrint('getIntentData failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('getIntentData', e);
}
return {};
}
@ -21,7 +21,7 @@ class ViewerService {
'uri': uri,
});
} on PlatformException catch (e) {
debugPrint('pick failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('pick', e);
}
}
}

View file

@ -1,4 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
@ -24,7 +24,7 @@ class PlatformWindowService implements WindowService {
'on': on,
});
} on PlatformException catch (e) {
debugPrint('keepScreenOn failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('keepScreenOn', e);
}
}
@ -34,7 +34,7 @@ class PlatformWindowService implements WindowService {
final result = await platform.invokeMethod('isRotationLocked');
if (result != null) return result as bool;
} on PlatformException catch (e) {
debugPrint('isRotationLocked failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('isRotationLocked', e);
}
return false;
}
@ -62,7 +62,7 @@ class PlatformWindowService implements WindowService {
'orientation': orientationCode,
});
} on PlatformException catch (e) {
debugPrint('requestOrientation failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('requestOrientation', e);
}
}
@ -72,7 +72,7 @@ class PlatformWindowService implements WindowService {
final result = await platform.invokeMethod('canSetCutoutMode');
if (result != null) return result as bool;
} on PlatformException catch (e) {
debugPrint('canSetCutoutMode failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('canSetCutoutMode', e);
}
return false;
}
@ -84,7 +84,7 @@ class PlatformWindowService implements WindowService {
'use': use,
});
} on PlatformException catch (e) {
debugPrint('setCutoutMode failed with code=${e.code}, exception=${e.message}, details=${e.details}');
await reportService.recordChannelError('setCutoutMode', e);
}
}
}

View file

@ -3,6 +3,8 @@ import 'package:flutter/scheduler.dart';
class Durations {
// Flutter animations (with margin)
static const popupMenuAnimation = Duration(milliseconds: 300 + 10); // ref `_kMenuDuration` used in `_PopupMenuRoute`
// page transition duration also available via `ModalRoute.of(context)!.transitionDuration * timeDilation`
static const pageTransitionAnimation = Duration(milliseconds: 300 + 10); // ref `transitionDuration` used in `MaterialRouteTransitionMixin`
static const dialogTransitionAnimation = Duration(milliseconds: 150 + 10); // ref `transitionDuration` used in `DialogRoute`
static const drawerTransitionAnimation = Duration(milliseconds: 246 + 10); // ref `_kBaseSettleDuration` used in `DrawerControllerState`
static const toggleableTransitionAnimation = Duration(milliseconds: 200 + 10); // ref `_kToggleDuration` used in `ToggleableStateMixin`

View file

@ -36,6 +36,7 @@ class AIcons {
static const IconData skip10 = Icons.forward_10_outlined;
static const IconData captureFrame = Icons.screenshot_outlined;
static const IconData clear = Icons.clear_outlined;
static const IconData clipboard = Icons.content_copy_outlined;
static const IconData createAlbum = Icons.add_circle_outline;
static const IconData debug = Icons.whatshot_outlined;
static const IconData delete = Icons.delete_outlined;
@ -49,6 +50,7 @@ class AIcons {
static const IconData import = MdiIcons.fileImportOutline;
static const IconData info = Icons.info_outlined;
static const IconData layers = Icons.layers_outlined;
static const IconData map = Icons.map_outlined;
static const IconData newTier = Icons.fiber_new_outlined;
static const IconData openOutside = Icons.open_in_new_outlined;
static const IconData pin = Icons.push_pin_outlined;

View file

@ -3,13 +3,14 @@ import 'package:aves/services/services.dart';
import 'package:aves/utils/change_notifier.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
class AndroidFileUtils {
late String primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath, videoCapturesPath;
late final String separator, primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath, videoCapturesPath;
Set<StorageVolume> storageVolumes = {};
Set<Package> _packages = {};
List<String> _potentialAppDirs = [];
@ -21,9 +22,10 @@ class AndroidFileUtils {
AndroidFileUtils._private();
Future<void> init() async {
separator = pContext.separator;
storageVolumes = await storageService.getStorageVolumes();
// path_provider getExternalStorageDirectory() gives '/storage/emulated/0/Android/data/deckers.thibault.aves/files'
primaryStorage = storageVolumes.firstWhereOrNull((volume) => volume.isPrimary)?.path ?? '/';
primaryStorage = storageVolumes.firstWhereOrNull((volume) => volume.isPrimary)?.path ?? separator;
// standard
dcimPath = pContext.join(primaryStorage, 'DCIM');
downloadPath = pContext.join(primaryStorage, 'Download');
moviesPath = pContext.join(primaryStorage, 'Movies');
@ -38,11 +40,11 @@ class AndroidFileUtils {
appNameChangeNotifier.notifyListeners();
}
bool isCameraPath(String path) => path.startsWith(dcimPath) && (path.endsWith('Camera') || path.endsWith('100ANDRO'));
bool isCameraPath(String path) => path.startsWith(dcimPath) && (path.endsWith('${separator}Camera') || path.endsWith('${separator}100ANDRO'));
bool isScreenshotsPath(String path) => (path.startsWith(dcimPath) || path.startsWith(picturesPath)) && path.endsWith('Screenshots');
bool isScreenshotsPath(String path) => (path.startsWith(dcimPath) || path.startsWith(picturesPath)) && path.endsWith('${separator}Screenshots');
bool isScreenRecordingsPath(String path) => (path.startsWith(dcimPath) || path.startsWith(moviesPath)) && (path.endsWith('Screen recordings') || path.endsWith('ScreenRecords'));
bool isScreenRecordingsPath(String path) => (path.startsWith(dcimPath) || path.startsWith(moviesPath)) && (path.endsWith('${separator}Screen recordings') || path.endsWith('${separator}ScreenRecords'));
bool isVideoCapturesPath(String path) => path == videoCapturesPath;
@ -53,7 +55,7 @@ class AndroidFileUtils {
final volume = storageVolumes.firstWhereOrNull((v) => path.startsWith(v.path));
// storage volume path includes trailing '/', but argument path may or may not,
// which is an issue when the path is at the root
return volume != null || path.endsWith('/') ? volume : getStorageVolume('$path/');
return volume != null || path.endsWith(separator) ? volume : getStorageVolume('$path$separator');
}
bool isOnRemovableStorage(String path) => getStorageVolume(path)?.isRemovable ?? false;
@ -155,9 +157,12 @@ class StorageVolume {
}
@immutable
class VolumeRelativeDirectory {
class VolumeRelativeDirectory extends Equatable {
final String volumePath, relativeDir;
@override
List<Object?> get props => [volumePath, relativeDir];
const VolumeRelativeDirectory({
required this.volumePath,
required this.relativeDir,
@ -187,13 +192,4 @@ class VolumeRelativeDirectory {
final volume = androidFileUtils.storageVolumes.firstWhereOrNull((volume) => volume.path == volumePath);
return volume?.getDescription(context) ?? volumePath;
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is VolumeRelativeDirectory && other.volumePath == volumePath && other.relativeDir == relativeDir;
}
@override
int get hashCode => hashValues(volumePath, relativeDir);
}

View file

@ -44,7 +44,6 @@ class Constants {
Dependency(
name: 'AndroidSVG',
license: 'Apache 2.0',
licenseUrl: 'https://github.com/BigBadaboom/androidsvg/blob/master/LICENSE',
sourceUrl: 'https://github.com/BigBadaboom/androidsvg',
),
Dependency(
@ -56,19 +55,16 @@ class Constants {
Dependency(
name: 'CWAC-Document',
license: 'Apache 2.0',
licenseUrl: 'https://github.com/commonsguy/cwac-document/blob/master/LICENSE',
sourceUrl: 'https://github.com/commonsguy/cwac-document',
),
Dependency(
name: 'Glide',
license: 'Apache 2.0, BSD 2-Clause',
licenseUrl: 'https://github.com/bumptech/glide/blob/master/LICENSE',
sourceUrl: 'https://github.com/bumptech/glide',
),
Dependency(
name: 'Metadata Extractor',
license: 'Apache 2.0',
licenseUrl: 'https://github.com/drewnoakes/metadata-extractor/blob/master/LICENSE',
sourceUrl: 'https://github.com/drewnoakes/metadata-extractor',
),
];
@ -77,49 +73,44 @@ class Constants {
Dependency(
name: 'Connectivity Plus',
license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/blob/main/packages/connectivity_plus/LICENSE',
licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/blob/main/packages/connectivity_plus/connectivity_plus/LICENSE',
sourceUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/connectivity_plus',
),
Dependency(
name: 'FlutterFire (Core, Crashlytics)',
license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/FirebaseExtended/flutterfire/blob/master/LICENSE',
sourceUrl: 'https://github.com/FirebaseExtended/flutterfire',
),
Dependency(
name: 'fijkplayer (Aves fork)',
license: 'MIT',
licenseUrl: 'https://github.com/deckerst/fijkplayer/blob/master/LICENSE',
sourceUrl: 'https://github.com/deckerst/fijkplayer',
),
Dependency(
name: 'Google API Availability',
license: 'MIT',
licenseUrl: 'https://github.com/Baseflow/flutter-google-api-availability/blob/master/LICENSE',
sourceUrl: 'https://github.com/Baseflow/flutter-google-api-availability',
),
Dependency(
name: 'Google Maps for Flutter',
license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/google_maps_flutter/google_maps_flutter/LICENSE',
sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/google_maps_flutter/google_maps_flutter',
sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/google_maps_flutter/google_maps_flutter',
),
Dependency(
name: 'Package Info Plus',
license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/package_info_plus/LICENSE',
licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/blob/main/packages/package_info_plus/package_info_plus/LICENSE',
sourceUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/package_info_plus',
),
Dependency(
name: 'Permission Handler',
license: 'MIT',
licenseUrl: 'https://github.com/Baseflow/flutter-permission-handler/blob/develop/permission_handler/LICENSE',
sourceUrl: 'https://github.com/Baseflow/flutter-permission-handler',
),
Dependency(
name: 'Printing',
license: 'Apache 2.0',
licenseUrl: 'https://github.com/DavBfr/dart_pdf/blob/master/LICENSE',
sourceUrl: 'https://github.com/DavBfr/dart_pdf',
),
Dependency(
@ -130,21 +121,19 @@ class Constants {
),
Dependency(
name: 'sqflite',
license: 'MIT',
licenseUrl: 'https://github.com/tekartik/sqflite/blob/master/sqflite/LICENSE',
license: 'BSD 2-Clause',
sourceUrl: 'https://github.com/tekartik/sqflite',
),
Dependency(
name: 'Streams Channel (Aves fork)',
license: 'Apache 2.0',
licenseUrl: 'https://github.com/deckerst/aves_streams_channel/blob/master/LICENSE',
sourceUrl: 'https://github.com/deckerst/aves_streams_channel',
),
Dependency(
name: 'URL Launcher',
license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher/LICENSE',
sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher',
sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/url_launcher/url_launcher',
),
];
@ -152,37 +141,36 @@ class Constants {
Dependency(
name: 'Charts',
license: 'Apache 2.0',
licenseUrl: 'https://github.com/google/charts/blob/master/LICENSE',
sourceUrl: 'https://github.com/google/charts',
),
Dependency(
name: 'Custom rounded rectangle border',
license: 'MIT',
sourceUrl: 'https://github.com/lekanbar/custom_rounded_rectangle_border',
),
Dependency(
name: 'Decorated Icon',
license: 'MIT',
licenseUrl: 'https://github.com/benPesso/flutter_decorated_icon/blob/master/LICENSE',
sourceUrl: 'https://github.com/benPesso/flutter_decorated_icon',
),
Dependency(
name: 'Expansion Tile Card (Aves fork)',
license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/deckerst/expansion_tile_card/blob/master/LICENSE',
sourceUrl: 'https://github.com/deckerst/expansion_tile_card',
),
Dependency(
name: 'FlexColorPicker',
license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/rydmike/flex_color_picker/blob/master/LICENSE',
sourceUrl: 'https://github.com/rydmike/flex_color_picker',
),
Dependency(
name: 'Flutter Highlight',
license: 'MIT',
licenseUrl: 'https://github.com/git-touch/highlight/blob/master/LICENSE',
sourceUrl: 'https://github.com/git-touch/highlight',
),
Dependency(
name: 'Flutter Map',
license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/fleaflet/flutter_map/blob/master/LICENSE',
sourceUrl: 'https://github.com/fleaflet/flutter_map',
),
Dependency(
@ -194,19 +182,16 @@ class Constants {
Dependency(
name: 'Flutter Staggered Animations',
license: 'MIT',
licenseUrl: 'https://github.com/mobiten/flutter_staggered_animations/blob/master/LICENSE',
sourceUrl: 'https://github.com/mobiten/flutter_staggered_animations',
),
Dependency(
name: 'Material Design Icons Flutter',
license: 'MIT',
licenseUrl: 'https://github.com/ziofat/material_design_icons_flutter/blob/master/LICENSE',
sourceUrl: 'https://github.com/ziofat/material_design_icons_flutter',
),
Dependency(
name: 'Overlay Support',
license: 'Apache 2.0',
licenseUrl: 'https://github.com/boyan01/overlay_support/blob/master/LICENSE',
sourceUrl: 'https://github.com/boyan01/overlay_support',
),
Dependency(
@ -218,19 +203,16 @@ class Constants {
Dependency(
name: 'Panorama',
license: 'Apache 2.0',
licenseUrl: 'https://github.com/zesage/panorama/blob/master/LICENSE',
sourceUrl: 'https://github.com/zesage/panorama',
),
Dependency(
name: 'Percent Indicator',
license: 'BSD 2-Clause',
licenseUrl: 'https://github.com/diegoveloper/flutter_percent_indicator/blob/master/LICENSE',
sourceUrl: 'https://github.com/diegoveloper/flutter_percent_indicator/',
sourceUrl: 'https://github.com/diegoveloper/flutter_percent_indicator',
),
Dependency(
name: 'Provider',
license: 'MIT',
licenseUrl: 'https://github.com/rrousselGit/provider/blob/master/LICENSE',
sourceUrl: 'https://github.com/rrousselGit/provider',
),
];
@ -239,21 +221,28 @@ class Constants {
Dependency(
name: 'Collection',
license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/dart-lang/collection/blob/master/LICENSE',
sourceUrl: 'https://github.com/dart-lang/collection',
),
Dependency(
name: 'Country Code',
license: 'MIT',
licenseUrl: 'https://github.com/denixport/dart.country/blob/master/LICENSE',
sourceUrl: 'https://github.com/denixport/dart.country',
),
Dependency(
name: 'Equatable',
license: 'MIT',
sourceUrl: 'https://github.com/felangel/equatable',
),
Dependency(
name: 'Event Bus',
license: 'MIT',
licenseUrl: 'https://github.com/marcojakob/dart-event-bus/blob/master/LICENSE',
sourceUrl: 'https://github.com/marcojakob/dart-event-bus',
),
Dependency(
name: 'Fluster',
license: 'MIT',
sourceUrl: 'https://github.com/alfonsocejudo/fluster',
),
Dependency(
name: 'Flutter Lints',
license: 'BSD 3-Clause',
@ -263,49 +252,41 @@ class Constants {
Dependency(
name: 'Get It',
license: 'MIT',
licenseUrl: 'https://github.com/fluttercommunity/get_it/blob/master/LICENSE',
sourceUrl: 'https://github.com/fluttercommunity/get_it',
),
Dependency(
name: 'Github',
license: 'MIT',
licenseUrl: 'https://github.com/SpinlockLabs/github.dart/blob/master/LICENSE',
sourceUrl: 'https://github.com/SpinlockLabs/github.dart',
),
Dependency(
name: 'Intl',
license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/dart-lang/intl/blob/master/LICENSE',
sourceUrl: 'https://github.com/dart-lang/intl',
),
Dependency(
name: 'LatLong2',
license: 'Apache 2.0',
licenseUrl: 'https://github.com/jifalops/dart-latlong/blob/master/LICENSE',
sourceUrl: 'https://github.com/jifalops/dart-latlong',
),
Dependency(
name: 'PDF for Dart and Flutter',
license: 'Apache 2.0',
licenseUrl: 'https://github.com/DavBfr/dart_pdf/blob/master/LICENSE',
sourceUrl: 'https://github.com/DavBfr/dart_pdf',
),
Dependency(
name: 'Tuple',
license: 'BSD 2-Clause',
licenseUrl: 'https://github.com/dart-lang/tuple/blob/master/LICENSE',
sourceUrl: 'https://github.com/dart-lang/tuple',
),
Dependency(
name: 'Version',
license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/dartninja/version/blob/master/LICENSE',
sourceUrl: 'https://github.com/dartninja/version',
),
Dependency(
name: 'XML',
license: 'MIT',
licenseUrl: 'https://github.com/renggli/dart-xml/blob/master/LICENSE',
sourceUrl: 'https://github.com/renggli/dart-xml',
),
];
@ -320,7 +301,7 @@ class Dependency {
const Dependency({
required this.name,
required this.license,
required this.licenseUrl,
String? licenseUrl,
required this.sourceUrl,
});
}) : licenseUrl = licenseUrl ?? '$sourceUrl/blob/master/LICENSE';
}

View file

@ -15,8 +15,8 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/providers/highlight_info_provider.dart';
import 'package:aves/widgets/home_page.dart';
import 'package:aves/widgets/welcome_page.dart';
import 'package:equatable/equatable.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -43,6 +43,7 @@ class _AvesAppState extends State<AvesApp> {
List<NavigatorObserver> _navigatorObservers = [];
final EventChannel _mediaStoreChangeChannel = const EventChannel('deckers.thibault/aves/mediastorechange');
final EventChannel _newIntentChannel = const EventChannel('deckers.thibault/aves/intent');
final EventChannel _errorChannel = const EventChannel('deckers.thibault/aves/error');
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator');
Widget getFirstPage({Map? intentData}) => settings.hasAcceptedTerms ? HomePage(intentData: intentData) : const WelcomePage();
@ -50,10 +51,11 @@ class _AvesAppState extends State<AvesApp> {
@override
void initState() {
super.initState();
initPlatformServices();
EquatableConfig.stringify = true;
_appSetup = _setup();
_mediaStoreChangeChannel.receiveBroadcastStream().listen((event) => _onMediaStoreChange(event as String?));
_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map?));
_errorChannel.receiveBroadcastStream().listen((event) => _onError(event as String?));
}
@override
@ -122,18 +124,17 @@ class _AvesAppState extends State<AvesApp> {
Future<void> _setup() async {
await Firebase.initializeApp().then((app) {
final crashlytics = FirebaseCrashlytics.instance;
FlutterError.onError = crashlytics.recordFlutterError;
crashlytics.setCustomKey('locales', window.locales.join(', '));
FlutterError.onError = reportService.recordFlutterError;
final now = DateTime.now();
crashlytics.setCustomKey('timezone', '${now.timeZoneName} (${now.timeZoneOffset})');
crashlytics.setCustomKey(
'build_mode',
kReleaseMode
? 'release'
: kProfileMode
? 'profile'
: 'debug');
reportService.setCustomKeys({
'locales': window.locales.join(', '),
'time_zone': '${now.timeZoneName} (${now.timeZoneOffset})',
'build_mode': kReleaseMode
? 'release'
: kProfileMode
? 'profile'
: 'debug',
});
});
await settings.init();
await settings.initFirebase();
@ -148,7 +149,7 @@ class _AvesAppState extends State<AvesApp> {
// do not reset when relaunching the app
if (appModeNotifier.value == AppMode.main && (intentData == null || intentData.isEmpty == true)) return;
FirebaseCrashlytics.instance.log('New intent');
reportService.log('New intent');
_navigatorKey.currentState!.pushReplacement(DirectMaterialPageRoute(
settings: const RouteSettings(name: HomePage.routeName),
builder: (_) => getFirstPage(intentData: intentData),
@ -169,4 +170,6 @@ class _AvesAppState extends State<AvesApp> {
});
}
}
void _onError(String? error) => reportService.recordError(error, null);
}

View file

@ -22,9 +22,10 @@ import 'package:aves/widgets/common/basic/menu_row.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/map/map_page.dart';
import 'package:aves/widgets/search/search_button.dart';
import 'package:aves/widgets/search/search_delegate.dart';
import 'package:aves/widgets/stats/stats.dart';
import 'package:aves/widgets/stats/stats_page.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
@ -212,6 +213,11 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
enabled: isNotEmpty,
child: MenuRow(text: context.l10n.collectionActionSelect, icon: AIcons.select),
),
PopupMenuItem(
value: CollectionAction.map,
enabled: isNotEmpty,
child: MenuRow(text: context.l10n.menuActionMap, icon: AIcons.map),
),
PopupMenuItem(
value: CollectionAction.stats,
enabled: isNotEmpty,
@ -292,6 +298,9 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
case CollectionAction.selectNone:
context.read<Selection<AvesEntry>>().clearSelection();
break;
case CollectionAction.map:
_goToMap();
break;
case CollectionAction.stats:
_goToStats();
break;
@ -377,6 +386,19 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
);
}
void _goToMap() {
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: MapPage.routeName),
builder: (context) => MapPage(
source: source,
parentCollection: collection,
),
),
);
}
void _goToStats() {
Navigator.push(
context,

View file

@ -19,6 +19,7 @@ import 'package:provider/provider.dart';
class ThumbnailImage extends StatefulWidget {
final AvesEntry entry;
final double extent;
final bool progressive;
final BoxFit? fit;
final bool showLoadingBackground;
final ValueNotifier<bool>? cancellableNotifier;
@ -28,6 +29,7 @@ class ThumbnailImage extends StatefulWidget {
Key? key,
required this.entry,
required this.extent,
this.progressive = true,
this.fit,
this.showLoadingBackground = true,
this.cancellableNotifier,
@ -93,7 +95,7 @@ class _ThumbnailImageState extends State<ThumbnailImage> {
_lastException = null;
_providers.clear();
_providers.addAll([
if (!entry.isSvg)
if (widget.progressive && !entry.isSvg)
_ConditionalImageProvider(
ScrollAwareImageProvider(
context: _scrollAwareContext,

View file

@ -72,6 +72,9 @@ class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerPr
Stream<T> get opStream => widget.opStream;
static const radius = 160.0;
static const strokeWidth = 16.0;
@override
void initState() {
super.initState();
@ -104,40 +107,55 @@ class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerPr
@override
Widget build(BuildContext context) {
final progressColor = Theme.of(context).accentColor;
return AbsorbPointer(
child: StreamBuilder<T>(
stream: opStream,
builder: (context, snapshot) {
final processedCount = processed.length.toDouble();
final total = widget.itemCount;
assert(processedCount <= total);
final percent = min(1.0, processedCount / total);
return FadeTransition(
opacity: _animation,
child: Container(
decoration: const BoxDecoration(
gradient: RadialGradient(
colors: [
Colors.black,
Colors.black54,
],
),
),
child: Center(
child: CircularPercentIndicator(
percent: percent,
lineWidth: 16,
radius: 160,
backgroundColor: Colors.white24,
progressColor: Theme.of(context).accentColor,
animation: true,
center: Text(NumberFormat.percentPattern().format(percent)),
animateFromLastPercent: true,
),
stream: opStream,
builder: (context, snapshot) {
final processedCount = processed.length.toDouble();
final total = widget.itemCount;
assert(processedCount <= total);
final percent = min(1.0, processedCount / total);
return FadeTransition(
opacity: _animation,
child: Container(
decoration: const BoxDecoration(
gradient: RadialGradient(
colors: [
Colors.black,
Colors.black54,
],
),
),
);
}),
child: Center(
child: Stack(
children: [
Container(
width: radius,
height: radius,
padding: const EdgeInsets.all(strokeWidth / 2),
child: CircularProgressIndicator(
color: progressColor.withOpacity(.1),
strokeWidth: strokeWidth,
),
),
CircularPercentIndicator(
percent: percent,
lineWidth: strokeWidth,
radius: radius,
backgroundColor: Colors.white24,
progressColor: progressColor,
animation: true,
center: Text(NumberFormat.percentPattern().format(percent)),
animateFromLastPercent: true,
),
],
),
),
),
);
},
),
);
}
}

View file

@ -1,18 +1,18 @@
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/material.dart';
class CrashlyticsRouteTracker extends NavigatorObserver {
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) => FirebaseCrashlytics.instance.log('Nav didPush to ${_name(route)}');
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) => reportService.log('Nav didPush to ${_name(route)}');
@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) => FirebaseCrashlytics.instance.log('Nav didPop to ${_name(previousRoute)}');
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) => reportService.log('Nav didPop to ${_name(previousRoute)}');
@override
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) => FirebaseCrashlytics.instance.log('Nav didRemove to ${_name(previousRoute)}');
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) => reportService.log('Nav didRemove to ${_name(previousRoute)}');
@override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) => FirebaseCrashlytics.instance.log('Nav didReplace to ${_name(newRoute)}');
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) => reportService.log('Nav didReplace to ${_name(newRoute)}');
String _name(Route<dynamic>? route) => route?.settings.name ?? 'unnamed ${route?.runtimeType}';
}

View file

@ -3,6 +3,7 @@ import 'dart:math';
import 'package:aves/model/source/section_keys.dart';
import 'package:aves/theme/durations.dart';
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
@ -221,13 +222,17 @@ class SectionedListLayout<T> {
String toString() => '$runtimeType#${shortHash(this)}{sectionCount=${sections.length} columnCount=$columnCount, tileExtent=$tileExtent}';
}
class SectionLayout {
@immutable
class SectionLayout extends Equatable {
final SectionKey sectionKey;
final int firstIndex, lastIndex, bodyFirstIndex;
final double minOffset, maxOffset, bodyMinOffset;
final double headerExtent, tileExtent, spacing, mainAxisStride;
final IndexedWidgetBuilder builder;
@override
List<Object?> get props => [sectionKey, firstIndex, lastIndex, minOffset, maxOffset, headerExtent, tileExtent, spacing];
const SectionLayout({
required this.sectionKey,
required this.firstIndex,
@ -263,15 +268,6 @@ class SectionLayout {
if (scrollOffset < 0) return firstIndex;
return bodyFirstIndex + (scrollOffset / mainAxisStride).ceil() - 1;
}
@override
bool operator ==(Object other) => identical(this, other) || other is SectionLayout && runtimeType == other.runtimeType && sectionKey == other.sectionKey && firstIndex == other.firstIndex && lastIndex == other.lastIndex && minOffset == other.minOffset && maxOffset == other.maxOffset && headerExtent == other.headerExtent && tileExtent == other.tileExtent && spacing == other.spacing;
@override
int get hashCode => hashValues(sectionKey, firstIndex, lastIndex, minOffset, maxOffset, headerExtent, tileExtent, spacing);
@override
String toString() => '$runtimeType#${shortHash(this)}{sectionKey=$sectionKey, firstIndex=$firstIndex, lastIndex=$lastIndex, minOffset=$minOffset, maxOffset=$maxOffset, headerExtent=$headerExtent, tileExtent=$tileExtent, spacing=$spacing}';
}
class _GridRow extends MultiChildRenderObjectWidget {

View file

@ -1,28 +1,22 @@
import 'dart:ui';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
@immutable
class MagnifierState {
const MagnifierState({
required this.position,
required this.scale,
required this.source,
});
class MagnifierState extends Equatable {
final Offset position;
final double? scale;
final ChangeSource source;
@override
bool operator ==(Object other) => identical(this, other) || other is MagnifierState && runtimeType == other.runtimeType && position == other.position && scale == other.scale;
List<Object?> get props => [position, scale, source];
@override
int get hashCode => hashValues(position, scale, source);
@override
String toString() => '$runtimeType#${shortHash(this)}{position: $position, scale: $scale, source: $source}';
const MagnifierState({
required this.position,
required this.scale,
required this.source,
});
}
enum ChangeSource { internal, gesture, animation }

View file

@ -6,6 +6,7 @@ import 'package:aves/widgets/common/magnifier/magnifier.dart';
import 'package:aves/widgets/common/magnifier/pan/corner_hit_detector.dart';
import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart';
import 'package:aves/widgets/common/magnifier/scale/state.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/widgets.dart';
/// Internal widget in which controls all animations lifecycle, core responses
@ -276,17 +277,21 @@ class _MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateM
}
}
class _CenterWithOriginalSizeDelegate extends SingleChildLayoutDelegate {
@immutable
class _CenterWithOriginalSizeDelegate extends SingleChildLayoutDelegate with EquatableMixin {
final Size subjectSize;
final Alignment basePosition;
final bool applyScale;
@override
List<Object?> get props => [subjectSize, basePosition, applyScale];
const _CenterWithOriginalSizeDelegate(
this.subjectSize,
this.basePosition,
this.applyScale,
);
final Size subjectSize;
final Alignment basePosition;
final bool applyScale;
@override
Offset getPositionForChild(Size size, Size childSize) {
final childWidth = applyScale ? subjectSize.width : childSize.width;
@ -309,10 +314,4 @@ class _CenterWithOriginalSizeDelegate extends SingleChildLayoutDelegate {
bool shouldRelayout(_CenterWithOriginalSizeDelegate oldDelegate) {
return oldDelegate != this;
}
@override
bool operator ==(Object other) => identical(this, other) || other is _CenterWithOriginalSizeDelegate && runtimeType == other.runtimeType && subjectSize == other.subjectSize && basePosition == other.basePosition && applyScale == other.applyScale;
@override
int get hashCode => hashValues(subjectSize, basePosition, applyScale);
}

View file

@ -2,17 +2,22 @@ import 'dart:ui';
import 'package:aves/widgets/common/magnifier/controller/controller.dart';
import 'package:aves/widgets/common/magnifier/scale/scale_level.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
/// Internal class to wrap custom scale boundaries (min, max and initial)
/// Also, stores values regarding the two sizes: the container and the child.
class ScaleBoundaries {
@immutable
class ScaleBoundaries extends Equatable {
final ScaleLevel _minScale;
final ScaleLevel _maxScale;
final ScaleLevel _initialScale;
final Size viewportSize;
final Size childSize;
@override
List<Object?> get props => [_minScale, _maxScale, _initialScale, viewportSize, childSize];
const ScaleBoundaries({
required ScaleLevel minScale,
required ScaleLevel maxScale,
@ -57,13 +62,4 @@ class ScaleBoundaries {
Offset childToStatePosition(double scale, Offset childPosition) {
return (_childCenter - childPosition) * scale;
}
@override
bool operator ==(Object other) => identical(this, other) || other is ScaleBoundaries && runtimeType == other.runtimeType && _minScale == other._minScale && _maxScale == other._maxScale && _initialScale == other._initialScale && viewportSize == other.viewportSize && childSize == other.childSize;
@override
int get hashCode => hashValues(_minScale, _maxScale, _initialScale, viewportSize, childSize);
@override
String toString() => '$runtimeType#${shortHash(this)}{viewportSize=$viewportSize, childSize=$childSize, initialScale=$initialScale, minScale=$minScale, maxScale=$maxScale}';
}

View file

@ -1,12 +1,17 @@
import 'dart:math';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
class ScaleLevel {
@immutable
class ScaleLevel extends Equatable {
final ScaleReference ref;
final double factor;
@override
List<Object?> get props => [ref, factor];
const ScaleLevel({
this.ref = ScaleReference.absolute,
this.factor = 1.0,
@ -15,18 +20,6 @@ class ScaleLevel {
static double scaleForContained(Size containerSize, Size childSize) => min(containerSize.width / childSize.width, containerSize.height / childSize.height);
static double scaleForCovering(Size containerSize, Size childSize) => max(containerSize.width / childSize.width, containerSize.height / childSize.height);
@override
String toString() => '$runtimeType#${shortHash(this)}{ref=$ref, factor=$factor}';
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is ScaleLevel && other.ref == ref && other.factor == factor;
}
@override
int get hashCode => hashValues(ref, factor);
}
enum ScaleReference { absolute, contained, covered }

View file

@ -1,29 +1,24 @@
import 'dart:ui';
import 'package:aves/widgets/common/magnifier/controller/state.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
@immutable
class ScaleStateChange {
const ScaleStateChange({
required this.state,
required this.source,
this.childFocalPoint,
});
class ScaleStateChange extends Equatable {
final ScaleState state;
final ChangeSource source;
final Offset? childFocalPoint;
@override
bool operator ==(Object other) => identical(this, other) || other is ScaleStateChange && runtimeType == other.runtimeType && state == other.state && childFocalPoint == other.childFocalPoint;
List<Object?> get props => [state, source, childFocalPoint];
@override
int get hashCode => hashValues(state, source, childFocalPoint);
@override
String toString() => '$runtimeType#${shortHash(this)}{scaleState: $state, source: $source, childFocalPoint: $childFocalPoint}';
const ScaleStateChange({
required this.state,
required this.source,
this.childFocalPoint,
});
}
enum ScaleState {

View file

@ -0,0 +1,47 @@
import 'package:aves/model/settings/enums.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/viewer/info/common.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:url_launcher/url_launcher.dart';
class Attribution extends StatelessWidget {
final EntryMapStyle style;
const Attribution({
Key? key,
required this.style,
}) : super(key: key);
@override
Widget build(BuildContext context) {
switch (style) {
case EntryMapStyle.osmHot:
return _buildAttributionMarkdown(context, context.l10n.mapAttributionOsmHot);
case EntryMapStyle.stamenToner:
case EntryMapStyle.stamenWatercolor:
return _buildAttributionMarkdown(context, context.l10n.mapAttributionStamen);
default:
return const SizedBox.shrink();
}
}
Widget _buildAttributionMarkdown(BuildContext context, String data) {
return Padding(
padding: const EdgeInsets.only(top: 4),
child: MarkdownBody(
data: data,
selectable: true,
styleSheet: MarkdownStyleSheet(
a: TextStyle(color: Theme.of(context).accentColor),
p: const TextStyle(color: Colors.white70, fontSize: InfoRowGroup.fontSize),
),
onTapLink: (text, href, title) async {
if (href != null && await canLaunch(href)) {
await launch(href);
}
},
),
);
}
}

View file

@ -13,61 +13,18 @@ import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
class MapDecorator extends StatelessWidget {
final Widget? child;
static const mapBorderRadius = BorderRadius.all(Radius.circular(24)); // to match button circles
static const mapBackground = Color(0xFFDBD5D3);
static const mapLoadingGrid = Color(0xFFC4BEBB);
const MapDecorator({
Key? key,
this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
onScaleStart: (details) {
// absorb scale gesture here to prevent scrolling
// and triggering by mistake a move to the image page above
},
child: ClipRRect(
borderRadius: mapBorderRadius,
child: Container(
color: mapBackground,
height: 200,
child: Stack(
children: [
const GridPaper(
color: mapLoadingGrid,
interval: 10,
divisions: 1,
subdivisions: 1,
child: CustomPaint(
size: Size.infinite,
),
),
if (child != null) child!,
],
),
),
),
);
}
}
import 'package:latlong2/latlong.dart';
class MapButtonPanel extends StatelessWidget {
final String geoUri;
final void Function(double amount) zoomBy;
final LatLng latLng;
final Future<void> Function(double amount)? zoomBy;
static const double padding = 4;
const MapButtonPanel({
Key? key,
required this.geoUri,
required this.zoomBy,
required this.latLng,
this.zoomBy,
}) : super(key: key);
@override
@ -86,7 +43,7 @@ class MapButtonPanel extends StatelessWidget {
children: [
MapOverlayButton(
icon: AIcons.openOutside,
onPressed: () => AndroidAppService.openMap(geoUri).then((success) {
onPressed: () => AndroidAppService.openMap(latLng).then((success) {
if (!success) showNoMatchingAppDialog(context);
}),
tooltip: context.l10n.entryActionOpenMap,
@ -120,13 +77,13 @@ class MapButtonPanel extends StatelessWidget {
const Spacer(),
MapOverlayButton(
icon: AIcons.zoomIn,
onPressed: () => zoomBy(1),
onPressed: zoomBy != null ? () => zoomBy!(1) : null,
tooltip: context.l10n.viewerInfoMapZoomInTooltip,
),
const SizedBox(height: padding),
MapOverlayButton(
icon: AIcons.zoomOut,
onPressed: () => zoomBy(-1),
onPressed: zoomBy != null ? () => zoomBy!(-1) : null,
tooltip: context.l10n.viewerInfoMapZoomOutTooltip,
),
],
@ -141,7 +98,7 @@ class MapButtonPanel extends StatelessWidget {
class MapOverlayButton extends StatelessWidget {
final IconData icon;
final String tooltip;
final VoidCallback onPressed;
final VoidCallback? onPressed;
const MapOverlayButton({
Key? key,

View file

@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
class MapDecorator extends StatelessWidget {
final bool interactive;
final Widget? child;
static const mapBorderRadius = BorderRadius.all(Radius.circular(24)); // to match button circles
static const mapBackground = Color(0xFFDBD5D3);
static const mapLoadingGrid = Color(0xFFC4BEBB);
const MapDecorator({
Key? key,
required this.interactive,
this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
onScaleStart: interactive
? null
: (details) {
// absorb scale gesture here to prevent scrolling
// and triggering by mistake a move to the image page above
},
child: ClipRRect(
borderRadius: mapBorderRadius,
child: Container(
color: mapBackground,
child: Stack(
children: [
const GridPaper(
color: mapLoadingGrid,
interval: 10,
divisions: 1,
subdivisions: 1,
child: CustomPaint(
size: Size.infinite,
),
),
if (child != null) child!,
],
),
),
),
);
}
}

View file

@ -0,0 +1,41 @@
import 'package:aves/model/entry.dart';
import 'package:fluster/fluster.dart';
import 'package:flutter/foundation.dart';
class GeoEntry extends Clusterable {
AvesEntry? entry;
GeoEntry({
this.entry,
double? latitude,
double? longitude,
bool? isCluster = false,
int? clusterId,
int? pointsSize,
String? markerId,
String? childMarkerId,
}) : super(
latitude: latitude,
longitude: longitude,
isCluster: isCluster,
clusterId: clusterId,
pointsSize: pointsSize,
markerId: markerId,
childMarkerId: childMarkerId,
);
factory GeoEntry.createCluster(BaseCluster cluster, double longitude, double latitude) {
return GeoEntry(
latitude: latitude,
longitude: longitude,
isCluster: cluster.isCluster,
clusterId: cluster.id,
pointsSize: cluster.pointsSize,
markerId: cluster.id.toString(),
childMarkerId: cluster.childMarkerId,
);
}
@override
String toString() => '$runtimeType#${shortHash(this)}{isCluster=$isCluster, lat=$latitude, lng=$longitude, clusterId=$clusterId, pointsSize=$pointsSize, markerId=$markerId, childMarkerId=$childMarkerId}';
}

View file

@ -0,0 +1,202 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/map_style.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/services.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/map/attribution.dart';
import 'package:aves/widgets/common/map/buttons.dart';
import 'package:aves/widgets/common/map/decorator.dart';
import 'package:aves/widgets/common/map/geo_entry.dart';
import 'package:aves/widgets/common/map/google/map.dart';
import 'package:aves/widgets/common/map/leaflet/map.dart';
import 'package:aves/widgets/common/map/marker.dart';
import 'package:aves/widgets/common/map/zoomed_bounds.dart';
import 'package:equatable/equatable.dart';
import 'package:fluster/fluster.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class GeoMap extends StatefulWidget {
final List<AvesEntry> entries;
final bool interactive;
final double? mapHeight;
final ValueNotifier<bool> isAnimatingNotifier;
final UserZoomChangeCallback? onUserZoomChange;
static const markerImageExtent = 48.0;
static const pointerSize = Size(8, 6);
const GeoMap({
Key? key,
required this.entries,
required this.interactive,
this.mapHeight,
required this.isAnimatingNotifier,
this.onUserZoomChange,
}) : super(key: key);
@override
_GeoMapState createState() => _GeoMapState();
}
class _GeoMapState extends State<GeoMap> with TickerProviderStateMixin {
// as of google_maps_flutter v2.0.6, Google Maps initialization is blocking
// cf https://github.com/flutter/flutter/issues/28493
// it is especially severe the first time, but still significant afterwards
// so we prevent loading it while scrolling or animating
bool _googleMapsLoaded = false;
late ValueNotifier<ZoomedBounds> boundsNotifier;
List<AvesEntry> get entries => widget.entries;
bool get interactive => widget.interactive;
double? get mapHeight => widget.mapHeight;
@override
void initState() {
super.initState();
boundsNotifier = ValueNotifier(ZoomedBounds.fromPoints(
points: entries.map((v) => v.latLng!).toSet(),
collocationZoom: settings.infoMapZoom,
));
}
@override
Widget build(BuildContext context) {
final markers = entries.map((entry) {
var latLng = entry.latLng!;
return GeoEntry(
entry: entry,
latitude: latLng.latitude,
longitude: latLng.longitude,
markerId: entry.uri,
);
}).toList();
final markerCluster = Fluster<GeoEntry>(
// we keep clustering on the whole range of zooms (including the maximum)
// to avoid collocated entries overlapping
minZoom: 0,
maxZoom: 22,
// TODO TLAD [map] derive `radius` / `extent`, from device pixel ratio and marker extent?
// (radius=120, extent=2 << 8) is equivalent to (radius=240, extent=2 << 9)
radius: 240,
extent: 2 << 9,
nodeSize: 64,
points: markers,
createCluster: (base, lng, lat) => GeoEntry.createCluster(base, lng, lat),
);
return FutureBuilder<bool>(
future: availability.isConnected,
builder: (context, snapshot) {
if (snapshot.data != true) return const SizedBox();
return Selector<Settings, EntryMapStyle>(
selector: (context, s) => s.infoMapStyle,
builder: (context, mapStyle, child) {
final isGoogleMaps = mapStyle.isGoogleMaps;
final progressive = !isGoogleMaps;
Widget _buildMarker(MarkerKey key) => ImageMarker(
key: key,
entry: key.entry,
count: key.count,
extent: GeoMap.markerImageExtent,
pointerSize: GeoMap.pointerSize,
progressive: progressive,
);
Widget child = isGoogleMaps
? EntryGoogleMap(
boundsNotifier: boundsNotifier,
interactive: interactive,
style: mapStyle,
markerBuilder: _buildMarker,
markerCluster: markerCluster,
markerEntries: entries,
onUserZoomChange: widget.onUserZoomChange,
)
: EntryLeafletMap(
boundsNotifier: boundsNotifier,
interactive: interactive,
style: mapStyle,
markerBuilder: _buildMarker,
markerCluster: markerCluster,
markerEntries: entries,
markerSize: Size(
GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2,
GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2 + GeoMap.pointerSize.height,
),
onUserZoomChange: widget.onUserZoomChange,
);
child = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
mapHeight != null
? SizedBox(
height: mapHeight,
child: child,
)
: Expanded(child: child),
Attribution(style: mapStyle),
],
);
return AnimatedSize(
alignment: Alignment.topCenter,
curve: Curves.easeInOutCubic,
duration: Durations.mapStyleSwitchAnimation,
vsync: this,
child: ValueListenableBuilder<bool>(
valueListenable: widget.isAnimatingNotifier,
builder: (context, animating, child) {
if (!animating && isGoogleMaps) {
_googleMapsLoaded = true;
}
Widget replacement = Stack(
children: [
MapDecorator(
interactive: interactive,
),
MapButtonPanel(
latLng: boundsNotifier.value.center,
),
],
);
if (mapHeight != null) {
replacement = SizedBox(
height: mapHeight,
child: replacement,
);
}
return Visibility(
visible: !isGoogleMaps || _googleMapsLoaded,
replacement: replacement,
child: child!,
);
},
child: child,
),
);
},
);
},
);
}
}
@immutable
class MarkerKey extends LocalKey with EquatableMixin {
final AvesEntry entry;
final int? count;
@override
List<Object?> get props => [entry, count];
const MarkerKey(this.entry, this.count);
}
typedef EntryMarkerBuilder = Widget Function(MarkerKey key);
typedef UserZoomChangeCallback = void Function(double zoom);

View file

@ -0,0 +1,224 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_images.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/utils/change_notifier.dart';
import 'package:aves/widgets/common/map/buttons.dart';
import 'package:aves/widgets/common/map/decorator.dart';
import 'package:aves/widgets/common/map/geo_entry.dart';
import 'package:aves/widgets/common/map/geo_map.dart';
import 'package:aves/widgets/common/map/google/marker_generator.dart';
import 'package:aves/widgets/common/map/zoomed_bounds.dart';
import 'package:collection/collection.dart';
import 'package:fluster/fluster.dart';
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:latlong2/latlong.dart' as ll;
class EntryGoogleMap extends StatefulWidget {
final ValueNotifier<ZoomedBounds> boundsNotifier;
final bool interactive;
final EntryMapStyle style;
final EntryMarkerBuilder markerBuilder;
final Fluster<GeoEntry> markerCluster;
final List<AvesEntry> markerEntries;
final UserZoomChangeCallback? onUserZoomChange;
const EntryGoogleMap({
Key? key,
required this.boundsNotifier,
required this.interactive,
required this.style,
required this.markerBuilder,
required this.markerCluster,
required this.markerEntries,
this.onUserZoomChange,
}) : super(key: key);
@override
State<StatefulWidget> createState() => _EntryGoogleMapState();
}
class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObserver {
GoogleMapController? _controller;
final Map<MarkerKey, Uint8List> _markerBitmaps = {};
final AChangeNotifier _markerBitmapChangeNotifier = AChangeNotifier();
ValueNotifier<ZoomedBounds> get boundsNotifier => widget.boundsNotifier;
ZoomedBounds get bounds => boundsNotifier.value;
static const uninitializedLatLng = LatLng(0, 0);
@override
void initState() {
super.initState();
}
@override
void didUpdateWidget(covariant EntryGoogleMap oldWidget) {
super.didUpdateWidget(oldWidget);
const eq = DeepCollectionEquality();
if (!eq.equals(widget.markerEntries, oldWidget.markerEntries)) {
_markerBitmaps.clear();
}
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.inactive:
case AppLifecycleState.paused:
case AppLifecycleState.detached:
break;
case AppLifecycleState.resumed:
// workaround for blank Google Maps when resuming app
// cf https://github.com/flutter/flutter/issues/40284
_controller?.setMapStyle(null);
break;
}
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<ZoomedBounds?>(
valueListenable: boundsNotifier,
builder: (context, visibleRegion, child) {
final allEntries = widget.markerEntries;
final clusters = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : <GeoEntry>[];
final clusterByMarkerKey = Map.fromEntries(clusters.map((v) {
if (v.isCluster!) {
final uri = v.childMarkerId;
final entry = allEntries.firstWhere((v) => v.uri == uri);
return MapEntry(MarkerKey(entry, v.pointsSize), v);
}
return MapEntry(MarkerKey(v.entry!, null), v);
}));
return Stack(
children: [
MarkerGeneratorWidget<MarkerKey>(
markers: clusterByMarkerKey.keys.map(widget.markerBuilder).toList(),
isReadyToRender: (key) => key.entry.isThumbnailReady(extent: GeoMap.markerImageExtent),
onRendered: (key, bitmap) {
_markerBitmaps[key] = bitmap;
_markerBitmapChangeNotifier.notifyListeners();
},
),
MapDecorator(
interactive: widget.interactive,
child: _buildMap(clusterByMarkerKey),
),
MapButtonPanel(
latLng: bounds.center,
zoomBy: _zoomBy,
),
],
);
},
);
}
Widget _buildMap(Map<MarkerKey, GeoEntry> clusterByMarkerKey) {
return AnimatedBuilder(
animation: _markerBitmapChangeNotifier,
builder: (context, child) {
final markers = <Marker>{};
clusterByMarkerKey.forEach((markerKey, cluster) {
final bytes = _markerBitmaps[markerKey];
if (bytes != null) {
final latLng = LatLng(cluster.latitude!, cluster.longitude!);
markers.add(Marker(
markerId: MarkerId(cluster.markerId!),
icon: BitmapDescriptor.fromBytes(bytes),
position: latLng,
));
}
});
final interactive = widget.interactive;
return GoogleMap(
initialCameraPosition: CameraPosition(
target: _toGoogleLatLng(bounds.center),
zoom: bounds.zoom,
),
onMapCreated: (controller) {
_controller = controller;
controller.getZoomLevel().then(_updateVisibleRegion);
setState(() {});
},
// TODO TLAD [map] add common compass button for both google/leaflet
compassEnabled: false,
mapToolbarEnabled: false,
mapType: _toMapType(widget.style),
// TODO TLAD [map] allow rotation when leaflet scale layer is fixed
rotateGesturesEnabled: false,
scrollGesturesEnabled: interactive,
// zoom controls disabled to use provider agnostic controls
zoomControlsEnabled: false,
zoomGesturesEnabled: interactive,
// lite mode disabled because it lacks camera animation
liteModeEnabled: false,
// tilt disabled to match leaflet
tiltGesturesEnabled: false,
myLocationEnabled: false,
myLocationButtonEnabled: false,
markers: markers,
onCameraMove: (position) => _updateVisibleRegion(position.zoom),
);
},
);
}
Future<void> _updateVisibleRegion(double zoom) async {
final bounds = await _controller?.getVisibleRegion();
if (bounds != null && (bounds.northeast != uninitializedLatLng || bounds.southwest != uninitializedLatLng)) {
boundsNotifier.value = ZoomedBounds(
west: bounds.southwest.longitude,
south: bounds.southwest.latitude,
east: bounds.northeast.longitude,
north: bounds.northeast.latitude,
zoom: zoom,
);
} else {
// the visible region is sometimes uninitialized when queried right after creation,
// so we query it again next frame
WidgetsBinding.instance!.addPostFrameCallback((_) {
if (!mounted) return;
_updateVisibleRegion(zoom);
});
}
}
Future<void> _zoomBy(double amount) async {
final controller = _controller;
if (controller == null) return;
widget.onUserZoomChange?.call(await controller.getZoomLevel() + amount);
await controller.animateCamera(CameraUpdate.zoomBy(amount));
}
// `LatLng` used by `google_maps_flutter` is not the one from `latlong2` package
LatLng _toGoogleLatLng(ll.LatLng latLng) => LatLng(latLng.latitude, latLng.longitude);
MapType _toMapType(EntryMapStyle style) {
switch (style) {
case EntryMapStyle.googleNormal:
return MapType.normal;
case EntryMapStyle.googleHybrid:
return MapType.hybrid;
case EntryMapStyle.googleTerrain:
return MapType.terrain;
default:
return MapType.none;
}
}
}

View file

@ -0,0 +1,121 @@
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:provider/provider.dart';
// generate bitmap from widget, for Google Maps
class MarkerGeneratorWidget<T extends Key> extends StatefulWidget {
final List<Widget> markers;
final bool Function(T markerKey) isReadyToRender;
final void Function(T markerKey, Uint8List bitmap) onRendered;
const MarkerGeneratorWidget({
Key? key,
required this.markers,
required this.isReadyToRender,
required this.onRendered,
}) : super(key: key);
@override
_MarkerGeneratorWidgetState createState() => _MarkerGeneratorWidgetState<T>();
}
class _MarkerGeneratorWidgetState<T extends Key> extends State<MarkerGeneratorWidget<T>> {
final Set<_MarkerGeneratorItem<T>> _items = {};
@override
void initState() {
super.initState();
_checkNextFrame();
}
@override
void didUpdateWidget(covariant MarkerGeneratorWidget<T> oldWidget) {
super.didUpdateWidget(oldWidget);
widget.markers.forEach((markerWidget) {
final item = getOrCreate(markerWidget.key as T);
item.globalKey = GlobalKey();
});
_checkNextFrame();
}
void _checkNextFrame() {
WidgetsBinding.instance!.addPostFrameCallback((_) async {
if (!mounted) return;
final waitingItems = _items.where((v) => v.isWaiting).toSet();
final readyItems = waitingItems.where((v) => widget.isReadyToRender(v.markerKey)).toSet();
readyItems.forEach((v) async {
final bitmap = await v.render();
if (bitmap != null) {
widget.onRendered(v.markerKey, bitmap);
}
});
if (readyItems.length < waitingItems.length) {
_checkNextFrame();
setState(() {});
}
});
}
@override
Widget build(BuildContext context) {
return Transform.translate(
offset: Offset(context.select<MediaQueryData, double>((mq) => mq.size.width), 0),
child: Material(
type: MaterialType.transparency,
child: Stack(
children: _items.map((item) {
return RepaintBoundary(
key: item.globalKey,
child: widget.markers.firstWhereOrNull((v) => v.key == item.markerKey) ?? const SizedBox(),
);
}).toList(),
),
),
);
}
_MarkerGeneratorItem getOrCreate(T markerKey) {
final existingItem = _items.firstWhereOrNull((v) => v.markerKey == markerKey);
if (existingItem != null) return existingItem;
final newItem = _MarkerGeneratorItem(markerKey);
_items.add(newItem);
return newItem;
}
}
enum MarkerGeneratorItemState { waiting, rendering, done }
class _MarkerGeneratorItem<T extends Key> {
final T markerKey;
GlobalKey? globalKey;
MarkerGeneratorItemState state = MarkerGeneratorItemState.waiting;
_MarkerGeneratorItem(this.markerKey);
bool get isWaiting => state == MarkerGeneratorItemState.waiting;
Future<Uint8List?> render() async {
Uint8List? bytes;
final _globalKey = globalKey;
if (_globalKey != null) {
state = MarkerGeneratorItemState.rendering;
final boundary = _globalKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
if (boundary.hasSize && boundary.size != Size.zero) {
final image = await boundary.toImage(pixelRatio: ui.window.devicePixelRatio);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
bytes = byteData?.buffer.asUint8List();
}
state = bytes != null ? MarkerGeneratorItemState.done : MarkerGeneratorItemState.waiting;
}
return bytes;
}
@override
String toString() => '$runtimeType#${shortHash(this)}{markerKey=$markerKey, globalKey=$globalKey, state=$state}';
}

View file

@ -0,0 +1,16 @@
import 'package:aves/widgets/common/map/latlng_utils.dart';
import 'package:flutter/widgets.dart';
import 'package:latlong2/latlong.dart';
class LatLngTween extends Tween<LatLng?> {
LatLngTween({
required LatLng? begin,
required LatLng? end,
}) : super(
begin: begin,
end: end,
);
@override
LatLng? lerp(double t) => LatLngUtils.lerp(begin, end, t);
}

View file

@ -0,0 +1,14 @@
import 'package:latlong2/latlong.dart';
class LatLngUtils {
static LatLng? lerp(LatLng? a, LatLng? b, double t) {
if (a == null && b == null) return null;
final _a = a ?? LatLng(0, 0);
final _b = b ?? LatLng(0, 0);
return LatLng(
_a.latitude + (_b.latitude - _a.latitude) * t,
_a.longitude + (_b.longitude - _a.longitude) * t,
);
}
}

View file

@ -0,0 +1,202 @@
import 'dart:async';
import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/widgets/common/map/buttons.dart';
import 'package:aves/widgets/common/map/decorator.dart';
import 'package:aves/widgets/common/map/geo_entry.dart';
import 'package:aves/widgets/common/map/geo_map.dart';
import 'package:aves/widgets/common/map/latlng_tween.dart';
import 'package:aves/widgets/common/map/leaflet/scale_layer.dart';
import 'package:aves/widgets/common/map/leaflet/tile_layers.dart';
import 'package:aves/widgets/common/map/zoomed_bounds.dart';
import 'package:fluster/fluster.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
class EntryLeafletMap extends StatefulWidget {
final ValueNotifier<ZoomedBounds> boundsNotifier;
final bool interactive;
final EntryMapStyle style;
final EntryMarkerBuilder markerBuilder;
final Fluster<GeoEntry> markerCluster;
final List<AvesEntry> markerEntries;
final Size markerSize;
final UserZoomChangeCallback? onUserZoomChange;
const EntryLeafletMap({
Key? key,
required this.boundsNotifier,
required this.interactive,
required this.style,
required this.markerBuilder,
required this.markerCluster,
required this.markerEntries,
required this.markerSize,
this.onUserZoomChange,
}) : super(key: key);
@override
State<StatefulWidget> createState() => _EntryLeafletMapState();
}
class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderStateMixin {
final MapController _mapController = MapController();
final List<StreamSubscription> _subscriptions = [];
ValueNotifier<ZoomedBounds> get boundsNotifier => widget.boundsNotifier;
ZoomedBounds get bounds => boundsNotifier.value;
// duration should match the uncustomizable Google Maps duration
static const _cameraAnimationDuration = Duration(milliseconds: 400);
static const _zoomMin = 1.0;
// TODO TLAD [map] also limit zoom on pinch-to-zoom gesture
static const _zoomMax = 16.0;
// TODO TLAD [map] allow rotation when leaflet scale layer is fixed
static const interactiveFlags = InteractiveFlag.all & ~InteractiveFlag.rotate;
@override
void initState() {
super.initState();
_subscriptions.add(_mapController.mapEventStream.listen((event) => _updateVisibleRegion()));
WidgetsBinding.instance!.addPostFrameCallback((_) => _updateVisibleRegion());
}
@override
void dispose() {
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<ZoomedBounds?>(
valueListenable: boundsNotifier,
builder: (context, visibleRegion, child) {
final allEntries = widget.markerEntries;
final clusters = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : <GeoEntry>[];
final clusterByMarkerKey = Map.fromEntries(clusters.map((v) {
if (v.isCluster!) {
final uri = v.childMarkerId;
final entry = allEntries.firstWhere((v) => v.uri == uri);
return MapEntry(MarkerKey(entry, v.pointsSize), v);
}
return MapEntry(MarkerKey(v.entry!, null), v);
}));
return Stack(
children: [
MapDecorator(
interactive: widget.interactive,
child: _buildMap(clusterByMarkerKey),
),
MapButtonPanel(
latLng: bounds.center,
zoomBy: _zoomBy,
),
],
);
},
);
}
Widget _buildMap(Map<MarkerKey, GeoEntry> clusterByMarkerKey) {
final markerSize = widget.markerSize;
final markers = clusterByMarkerKey.entries.map((kv) {
final markerKey = kv.key;
final cluster = kv.value;
final latLng = LatLng(cluster.latitude!, cluster.longitude!);
return Marker(
point: latLng,
builder: (context) => GestureDetector(
onTap: () => _moveTo(latLng),
child: widget.markerBuilder(markerKey),
),
width: markerSize.width,
height: markerSize.height,
anchorPos: AnchorPos.align(AnchorAlign.top),
);
}).toList();
return FlutterMap(
options: MapOptions(
center: bounds.center,
zoom: bounds.zoom,
interactiveFlags: widget.interactive ? interactiveFlags : InteractiveFlag.none,
),
mapController: _mapController,
children: [
_buildMapLayer(),
ScaleLayerWidget(
options: ScaleLayerOptions(),
),
MarkerLayerWidget(
options: MarkerLayerOptions(
markers: markers,
rotate: true,
rotateAlignment: Alignment.bottomCenter,
),
),
],
);
}
Widget _buildMapLayer() {
switch (widget.style) {
case EntryMapStyle.osmHot:
return const OSMHotLayer();
case EntryMapStyle.stamenToner:
return const StamenTonerLayer();
case EntryMapStyle.stamenWatercolor:
return const StamenWatercolorLayer();
default:
return const SizedBox.shrink();
}
}
void _updateVisibleRegion() {
final bounds = _mapController.bounds;
if (bounds != null) {
boundsNotifier.value = ZoomedBounds(
west: bounds.west,
south: bounds.south,
east: bounds.east,
north: bounds.north,
zoom: _mapController.zoom,
);
}
}
Future<void> _zoomBy(double amount) async {
final endZoom = (_mapController.zoom + amount).clamp(_zoomMin, _zoomMax);
widget.onUserZoomChange?.call(endZoom);
final zoomTween = Tween<double>(begin: _mapController.zoom, end: endZoom);
await _animateCamera((animation) => _mapController.move(_mapController.center, zoomTween.evaluate(animation)));
}
Future<void> _moveTo(LatLng point) async {
final centerTween = LatLngTween(begin: _mapController.center, end: point);
await _animateCamera((animation) => _mapController.move(centerTween.evaluate(animation)!, _mapController.zoom));
}
Future<void> _animateCamera(void Function(Animation<double> animation) animate) async {
final controller = AnimationController(duration: _cameraAnimationDuration, vsync: this);
final animation = CurvedAnimation(parent: controller, curve: Curves.fastOutSlowIn);
controller.addListener(() => animate(animation));
animation.addStatusListener((status) {
if (status == AnimationStatus.completed) {
controller.dispose();
} else if (status == AnimationStatus.dismissed) {
controller.dispose();
}
});
await controller.forward();
}
}

View file

@ -1,12 +1,11 @@
import 'dart:math';
import 'package:aves/widgets/common/basic/outlined_text.dart';
import 'package:aves/widgets/common/map/leaflet/scalebar_utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map/plugin_api.dart';
import 'scalebar_utils.dart' as util;
class ScaleLayerOptions extends LayerOptions {
final Widget Function(double width, String distance) builder;
@ -24,6 +23,7 @@ class ScaleLayerOptions extends LayerOptions {
}
}
// TODO TLAD [map] scale bar should not rotate together with map layer
class ScaleLayerWidget extends StatelessWidget {
final ScaleLayerOptions options;
@ -86,7 +86,7 @@ class ScaleLayer extends StatelessWidget {
: 2);
final distance = scale[max(0, min(20, level))].toDouble();
final start = map.project(center);
final targetPoint = util.calculateEndingGlobalCoordinates(center, 90, distance);
final targetPoint = ScaleBarUtils.calculateEndingGlobalCoordinates(center, 90, distance);
final end = map.project(targetPoint);
final displayDistance = distance > 999 ? '${(distance / 1000).toStringAsFixed(0)} km' : '${distance.toStringAsFixed(0)} m';
final width = end.x - (start.x as double);

View file

@ -0,0 +1,119 @@
import 'dart:math';
import 'package:aves/utils/math_utils.dart';
import 'package:latlong2/latlong.dart';
class ScaleBarUtils {
static LatLng calculateEndingGlobalCoordinates(LatLng start, double startBearing, double distance) {
var mSemiMajorAxis = 6378137.0; //WGS84 major axis
var mSemiMinorAxis = (1.0 - 1.0 / 298.257223563) * 6378137.0;
var mFlattening = 1.0 / 298.257223563;
// double mInverseFlattening = 298.257223563;
var a = mSemiMajorAxis;
var b = mSemiMinorAxis;
var aSquared = a * a;
var bSquared = b * b;
var f = mFlattening;
var phi1 = toRadians(start.latitude);
var alpha1 = toRadians(startBearing);
var cosAlpha1 = cos(alpha1);
var sinAlpha1 = sin(alpha1);
var s = distance;
var tanU1 = (1.0 - f) * tan(phi1);
var cosU1 = 1.0 / sqrt(1.0 + tanU1 * tanU1);
var sinU1 = tanU1 * cosU1;
// eq. 1
var sigma1 = atan2(tanU1, cosAlpha1);
// eq. 2
var sinAlpha = cosU1 * sinAlpha1;
var sin2Alpha = sinAlpha * sinAlpha;
var cos2Alpha = 1 - sin2Alpha;
var uSquared = cos2Alpha * (aSquared - bSquared) / bSquared;
// eq. 3
var A = 1 + (uSquared / 16384) * (4096 + uSquared * (-768 + uSquared * (320 - 175 * uSquared)));
// eq. 4
var B = (uSquared / 1024) * (256 + uSquared * (-128 + uSquared * (74 - 47 * uSquared)));
// iterate until there is a negligible change in sigma
double deltaSigma;
var sOverbA = s / (b * A);
var sigma = sOverbA;
double sinSigma;
var prevSigma = sOverbA;
double sigmaM2;
double cosSigmaM2;
double cos2SigmaM2;
for (;;) {
// eq. 5
sigmaM2 = 2.0 * sigma1 + sigma;
cosSigmaM2 = cos(sigmaM2);
cos2SigmaM2 = cosSigmaM2 * cosSigmaM2;
sinSigma = sin(sigma);
var cosSignma = cos(sigma);
// eq. 6
deltaSigma = B * sinSigma * (cosSigmaM2 + (B / 4.0) * (cosSignma * (-1 + 2 * cos2SigmaM2) - (B / 6.0) * cosSigmaM2 * (-3 + 4 * sinSigma * sinSigma) * (-3 + 4 * cos2SigmaM2)));
// eq. 7
sigma = sOverbA + deltaSigma;
// break after converging to tolerance
if ((sigma - prevSigma).abs() < 0.0000000000001) break;
prevSigma = sigma;
}
sigmaM2 = 2.0 * sigma1 + sigma;
cosSigmaM2 = cos(sigmaM2);
cos2SigmaM2 = cosSigmaM2 * cosSigmaM2;
var cosSigma = cos(sigma);
sinSigma = sin(sigma);
// eq. 8
var phi2 = atan2(sinU1 * cosSigma + cosU1 * sinSigma * cosAlpha1, (1.0 - f) * sqrt(sin2Alpha + pow(sinU1 * sinSigma - cosU1 * cosSigma * cosAlpha1, 2.0)));
// eq. 9
// This fixes the pole crossing defect spotted by Matt Feemster. When a
// path passes a pole and essentially crosses a line of latitude twice -
// once in each direction - the longitude calculation got messed up.
// Using
// atan2 instead of atan fixes the defect. The change is in the next 3
// lines.
// double tanLambda = sinSigma * sinAlpha1 / (cosU1 * cosSigma - sinU1 *
// sinSigma * cosAlpha1);
// double lambda = Math.atan(tanLambda);
var lambda = atan2(sinSigma * sinAlpha1, (cosU1 * cosSigma - sinU1 * sinSigma * cosAlpha1));
// eq. 10
var C = (f / 16) * cos2Alpha * (4 + f * (4 - 3 * cos2Alpha));
// eq. 11
var L = lambda - (1 - C) * f * sinAlpha * (sigma + C * sinSigma * (cosSigmaM2 + C * cosSigma * (-1 + 2 * cos2SigmaM2)));
// eq. 12
// double alpha2 = Math.atan2(sinAlpha, -sinU1 * sinSigma + cosU1 *
// cosSigma * cosAlpha1);
// build result
var latitude = toDegrees(phi2);
var longitude = start.longitude + toDegrees(L);
// if ((endBearing != null) && (endBearing.length > 0)) {
// endBearing[0] = toDegrees(alpha2);
// }
latitude = latitude < -90 ? -90 : latitude;
latitude = latitude > 90 ? 90 : latitude;
longitude = longitude < -180 ? -180 : longitude;
longitude = longitude > 180 ? 180 : longitude;
return LatLng(latitude, longitude);
}
}

Some files were not shown because too many files have changed in this diff Show more