diff --git a/.flutter b/.flutter
index 2663184aa..603104015 160000
--- a/.flutter
+++ b/.flutter
@@ -1 +1 @@
-Subproject commit 2663184aa79047d0a33a14a3b607954f8fdd8730
+Subproject commit 603104015dd692ea3403755b55d07813d5cf8965
diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml
index 7b0990bcf..955b3b3fb 100644
--- a/.github/workflows/dependency-review.yml
+++ b/.github/workflows/dependency-review.yml
@@ -22,6 +22,6 @@ jobs:
egress-policy: audit
- name: 'Checkout Repository'
- uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: 'Dependency Review'
- uses: actions/dependency-review-action@5a2ce3f5b92ee19cbb1541a4984c76d921601d7c # v4.3.4
+ uses: actions/dependency-review-action@4081bf99e2866ebe428fc0477b69eb4fcda7220a # v4.4.0
diff --git a/.github/workflows/quality-check.yml b/.github/workflows/quality-check.yml
index 7265b74c5..3971f6bd7 100644
--- a/.github/workflows/quality-check.yml
+++ b/.github/workflows/quality-check.yml
@@ -23,7 +23,7 @@ jobs:
egress-policy: audit
- name: Checkout repository
- uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Get Flutter packages
run: scripts/pub_get_all.sh
@@ -59,17 +59,17 @@ jobs:
# Building relies on the Android Gradle plugin,
# which requires a modern Java version (not the default one).
- name: Set up JDK for Android Gradle plugin
- uses: actions/setup-java@b36c23c0d998641eff861008f374ee103c25ac73 # v4.4.0
+ uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b # v4.5.0
with:
distribution: 'temurin'
java-version: '21'
- name: Checkout repository
- uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@c36620d31ac7c881962c3d9dd939c40ec9434f2b # v3.26.12
+ uses: github/codeql-action/init@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -83,6 +83,6 @@ jobs:
./flutterw build apk --profile -t lib/main_play.dart --flavor play
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@c36620d31ac7c881962c3d9dd939c40ec9434f2b # v3.26.12
+ uses: github/codeql-action/analyze@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
with:
category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 68c6ea2d4..657445e42 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -13,7 +13,9 @@ jobs:
name: GitHub release
runs-on: ubuntu-latest
permissions:
+ attestations: write
contents: write
+ id-token: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
@@ -23,13 +25,13 @@ jobs:
# Building relies on the Android Gradle plugin,
# which requires a modern Java version (not the default one).
- name: Set up JDK for Android Gradle plugin
- uses: actions/setup-java@b36c23c0d998641eff861008f374ee103c25ac73 # v4.4.0
+ uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b # v4.5.0
with:
distribution: 'temurin'
java-version: '21'
- name: Checkout repository
- uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Get Flutter packages
run: scripts/pub_get_all.sh
@@ -72,6 +74,11 @@ jobs:
AVES_KEY_PASSWORD: ${{ secrets.AVES_KEY_PASSWORD }}
AVES_GOOGLE_API_KEY: ${{ secrets.AVES_GOOGLE_API_KEY }}
+ - name: Generate artifact attestation
+ uses: actions/attest-build-provenance@1c608d11d69870c2092266b3f9a6f3abbf17002c # v1.4.3
+ with:
+ subject-path: 'outputs/*'
+
- name: Create GitHub release
uses: ncipollo/release-action@2c591bcc8ecdcd2db72b97d6147f871fcd833ba5 # v1.14.0
with:
@@ -96,7 +103,7 @@ jobs:
egress-policy: audit
- name: Checkout repository
- uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Get appbundle from artifacts
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml
index 6e2676543..a88b1673d 100644
--- a/.github/workflows/scorecards.yml
+++ b/.github/workflows/scorecards.yml
@@ -36,7 +36,7 @@ jobs:
egress-policy: audit
- name: "Checkout code"
- uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
@@ -71,6 +71,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
- uses: github/codeql-action/upload-sarif@c36620d31ac7c881962c3d9dd939c40ec9434f2b # v3.26.12
+ uses: github/codeql-action/upload-sarif@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
with:
sarif_file: results.sarif
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf952d08f..7b290ba4c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
+## [v1.11.17] - 2024-10-30
+
+### Added
+
+- Map: create shortcut to custom region and filters
+- Video: frame stepping forward/backward
+- Video: custom playback buttons
+- English (Shavian) translation (thanks Paranoid Android)
+
+### Changed
+
+- upgraded Flutter to stable v3.24.4
+
+### Fixed
+
+- crash when loading large collection
+- Viewer: copying content URI item
+- Albums: creating album with same name as existing empty directory
+- Privacy: tagging while vaults are unlocked does not yield recent tags visible when vaults are locked
+
## [v1.11.16] - 2024-10-10
### Fixed
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 2bf34a887..eb8171a4a 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -1,5 +1,3 @@
-import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
-
plugins {
id 'com.android.application'
id 'com.google.devtools.ksp'
@@ -30,16 +28,15 @@ if (keystorePropertiesFile.exists()) {
keystoreProperties["googleApiKey"] = System.getenv("AVES_GOOGLE_API_KEY") ?: ""
}
+kotlin {
+ jvmToolchain 17
+}
+
android {
namespace 'deckers.thibault.aves'
compileSdk 35
// cf https://developer.android.com/studio/projects/install-ndk#default-ndk-per-agp
- ndkVersion '26.1.10909125'
-
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_21
- targetCompatibility JavaVersion.VERSION_21
- }
+ ndkVersion '27.0.12077973'
defaultConfig {
applicationId packageName
@@ -133,15 +130,6 @@ android {
}
}
-tasks.withType(KotlinCompile).configureEach {
- sourceCompatibility = JavaVersion.VERSION_21
- targetCompatibility = JavaVersion.VERSION_21
-}
-
-kotlin {
- jvmToolchain(21)
-}
-
flutter {
source '../..'
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 58947842a..c12dd891b 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -332,6 +332,9 @@
>("uris")
- try {
- if (!pickedUris.isNullOrEmpty()) {
- val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this@MainActivity, Uri.parse(uriString)) }
- val intent = Intent().apply {
- val firstUri = toUri(pickedUris.first())
- if (pickedUris.size == 1) {
- data = firstUri
- } else {
- clipData = ClipData.newUri(contentResolver, null, firstUri).apply {
- pickedUris.drop(1).forEach {
- addItem(ClipData.Item(toUri(it)))
- }
- }
+ if (pickedUris.isNullOrEmpty()) {
+ setResult(RESULT_CANCELED)
+ // move code triggering `Binder` call off the main thread
+ defaultScope.launch { finish() }
+ return
+ }
+
+ val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this@MainActivity, Uri.parse(uriString)) }
+ val intent = Intent().apply {
+ val firstUri = toUri(pickedUris.first())
+ if (pickedUris.size == 1) {
+ data = firstUri
+ } else {
+ clipData = ClipData.newUri(contentResolver, null, firstUri).apply {
+ pickedUris.drop(1).forEach {
+ addItem(ClipData.Item(toUri(it)))
}
- addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
- setResult(RESULT_OK, intent)
- } else {
- setResult(RESULT_CANCELED)
}
- // move code triggering `Binder` call off the main thread
- defaultScope.launch { finish() }
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
+ }
+ // move code triggering `Binder` call off the main thread
+ defaultScope.launch {
+ submitPickedItemsIntent(intent, result)
+ }
+ }
+
+ private fun submitPickedItemsIntent(intent: Intent, result: MethodChannel.Result) {
+ try {
+ setResult(RESULT_OK, intent)
+ finish()
} catch (e: Exception) {
- if (e is TransactionTooLargeException || e.cause is TransactionTooLargeException) {
- result.error("submitPickedItems-large", "transaction too large with ${pickedUris?.size} URIs", e)
+ setResult(RESULT_CANCELED)
+ if (e is SecurityException && intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION != 0) {
+ // in some environments, providing the write flag yields a `SecurityException`:
+ // "UID XXXX does not have permission to content://XXXX"
+ // so we retry without it
+ Log.i(LOG_TAG, "retry submitting picked items without FLAG_GRANT_WRITE_URI_PERMISSION")
+ intent.flags = intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION.inv()
+ submitPickedItemsIntent(intent, result)
+ } else if (e.anyCauseIs()) {
+ result.error("submitPickedItems-large", "transaction too large with ${intent.clipData?.itemCount} URIs", e)
} else {
- result.error("submitPickedItems-exception", "failed to pick ${pickedUris?.size} URIs", e)
+ result.error("submitPickedItems-exception", "failed to pick ${intent.clipData?.itemCount} URIs", e)
}
}
}
@@ -552,6 +572,7 @@ open class MainActivity : FlutterFragmentActivity() {
const val INTENT_DATA_KEY_EXPLORER_PATH = "explorerPath"
const val INTENT_DATA_KEY_FILTERS = "filters"
const val INTENT_DATA_KEY_MIME_TYPE = "mimeType"
+ const val INTENT_DATA_KEY_MIME_TYPES = "mimeTypes"
const val INTENT_DATA_KEY_PAGE = "page"
const val INTENT_DATA_KEY_QUERY = "query"
const val INTENT_DATA_KEY_SECURE_URIS = "secureUris"
@@ -566,6 +587,8 @@ open class MainActivity : FlutterFragmentActivity() {
// dart page routes
const val COLLECTION_PAGE_ROUTE_NAME = "/collection"
+ const val ENTRY_VIEWER_PAGE_ROUTE_NAME = "/viewer"
+ const val EXPLORER_PAGE_ROUTE_NAME = "/explorer"
const val MAP_PAGE_ROUTE_NAME = "/map"
const val SEARCH_PAGE_ROUTE_NAME = "/search"
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AnalysisHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AnalysisHandler.kt
index 6c940da75..0ee874058 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AnalysisHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AnalysisHandler.kt
@@ -1,5 +1,6 @@
package deckers.thibault.aves.channel.calls
+import android.app.ActivityManager
import android.content.Context
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
@@ -51,6 +52,17 @@ class AnalysisHandler(private val activity: FlutterFragmentActivity, private val
return
}
+ val activityManager: ActivityManager = activity.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
+ val runningAppProcesses = activityManager.runningAppProcesses
+ if (runningAppProcesses != null) {
+ val importance = runningAppProcesses[0].importance
+ if (importance < ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) {
+ // the app is in the background
+ result.error("startAnalysis-background", "app is in the background (process importance=$importance)", null)
+ return
+ }
+ }
+
// can be null or empty
val allEntryIds = call.argument>("entryIds")
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt
index 65c3f38a3..7825e084f 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt
@@ -23,11 +23,15 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.MainActivity
+import deckers.thibault.aves.MainActivity.Companion.COLLECTION_PAGE_ROUTE_NAME
+import deckers.thibault.aves.MainActivity.Companion.ENTRY_VIEWER_PAGE_ROUTE_NAME
+import deckers.thibault.aves.MainActivity.Companion.EXPLORER_PAGE_ROUTE_NAME
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_EXPLORER_PATH
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_FILTERS_ARRAY
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_FILTERS_STRING
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_PAGE
import deckers.thibault.aves.MainActivity.Companion.EXTRA_STRING_ARRAY_SEPARATOR
+import deckers.thibault.aves.MainActivity.Companion.MAP_PAGE_ROUTE_NAME
import deckers.thibault.aves.R
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
@@ -35,6 +39,7 @@ import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils
+import deckers.thibault.aves.utils.anyCauseIs
import deckers.thibault.aves.utils.getApplicationInfoCompat
import deckers.thibault.aves.utils.queryIntentActivitiesCompat
import io.flutter.plugin.common.MethodCall
@@ -303,7 +308,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val started = safeStartActivityChooser(title, intent)
result.success(started)
} catch (e: Exception) {
- if (e is TransactionTooLargeException || e.cause is TransactionTooLargeException) {
+ if (e.anyCauseIs()) {
result.error("share-large", "transaction too large with ${uriList.size} URIs", e)
} else {
result.error("share-exception", "failed to share ${uriList.size} URIs", e)
@@ -354,12 +359,17 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
// shortcuts
private fun pinShortcut(call: MethodCall, result: MethodChannel.Result) {
+ // common arguments
val label = call.argument("label")
val iconBytes = call.argument("iconBytes")
+ val route = call.argument("route")
+ // route dependent arguments
val filters = call.argument>("filters")
- val explorerPath = call.argument("explorerPath")
- val uri = call.argument("uri")?.let { Uri.parse(it) }
- if (label == null) {
+ val explorerPath = call.argument("path")
+ val viewUri = call.argument("viewUri")?.let { Uri.parse(it) }
+ val geoUri = call.argument("geoUri")?.let { Uri.parse(it) }
+
+ if (label == null || route == null) {
result.error("pin-args", "missing arguments", null)
return
}
@@ -383,24 +393,60 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
// so that foreground is rendered at the intended scale
val supportAdaptiveIcon = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
- icon = IconCompat.createWithResource(context, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_collection else R.drawable.ic_shortcut_collection)
+ val resId = when (route) {
+ MAP_PAGE_ROUTE_NAME -> if (supportAdaptiveIcon) R.mipmap.ic_shortcut_map else R.drawable.ic_shortcut_map
+ else -> if (supportAdaptiveIcon) R.mipmap.ic_shortcut_collection else R.drawable.ic_shortcut_collection
+ }
+ icon = IconCompat.createWithResource(context, resId)
}
- val intent = when {
- filters != null -> Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
- .putExtra(EXTRA_KEY_PAGE, "/collection")
- .putExtra(EXTRA_KEY_FILTERS_ARRAY, filters.toTypedArray())
- // on API 25, `String[]` or `ArrayList` extras are null when using the shortcut
- // so we use a joined `String` as fallback
- .putExtra(EXTRA_KEY_FILTERS_STRING, filters.joinToString(EXTRA_STRING_ARRAY_SEPARATOR))
+ val intent: Intent = when (route) {
+ COLLECTION_PAGE_ROUTE_NAME -> {
+ if (filters == null) {
+ result.error("pin-filters", "collection shortcut requires filters", null)
+ return
+ }
+ Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
+ .putExtra(EXTRA_KEY_PAGE, route)
+ .putExtra(EXTRA_KEY_FILTERS_ARRAY, filters.toTypedArray())
+ // on API 25, `String[]` or `ArrayList` extras are null when using the shortcut
+ // so we use a joined `String` as fallback
+ .putExtra(EXTRA_KEY_FILTERS_STRING, filters.joinToString(EXTRA_STRING_ARRAY_SEPARATOR))
+ }
- explorerPath != null -> Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
- .putExtra(EXTRA_KEY_PAGE, "/explorer")
- .putExtra(EXTRA_KEY_EXPLORER_PATH, explorerPath)
+ ENTRY_VIEWER_PAGE_ROUTE_NAME -> {
+ if (viewUri == null) {
+ result.error("pin-viewUri", "viewer shortcut requires URI", null)
+ return
+ }
+ Intent(Intent.ACTION_VIEW, viewUri, context, MainActivity::class.java)
+ }
+
+ EXPLORER_PAGE_ROUTE_NAME -> {
+ Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
+ .putExtra(EXTRA_KEY_PAGE, route)
+ .putExtra(EXTRA_KEY_EXPLORER_PATH, explorerPath)
+ }
+
+ MAP_PAGE_ROUTE_NAME -> {
+ if (geoUri == null) {
+ result.error("pin-geoUri", "map shortcut requires URI", null)
+ return
+ }
+ Intent(Intent.ACTION_VIEW, geoUri, context, MainActivity::class.java).apply {
+ putExtra(EXTRA_KEY_PAGE, route)
+ // filters are optional
+ filters?.let {
+ putExtra(EXTRA_KEY_FILTERS_ARRAY, it.toTypedArray())
+ // on API 25, `String[]` or `ArrayList` extras are null when using the shortcut
+ // so we use a joined `String` as fallback
+ putExtra(EXTRA_KEY_FILTERS_STRING, it.joinToString(EXTRA_STRING_ARRAY_SEPARATOR))
+ }
+ }
+ }
- uri != null -> Intent(Intent.ACTION_VIEW, uri, context, MainActivity::class.java)
else -> {
- result.error("pin-intent", "failed to build intent", null)
+ result.error("pin-route", "unsupported shortcut route=$route", null)
return
}
}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFetchBytesHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFetchBytesHandler.kt
index 232fa09cd..8012e7d7a 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFetchBytesHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFetchBytesHandler.kt
@@ -16,6 +16,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
+import java.util.Date
import kotlin.math.roundToInt
class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
@@ -44,7 +45,7 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
val defaultSizeDip = call.argument("defaultSizeDip")?.toDouble()
val quality = call.argument("quality")
- if (uri == null || mimeType == null || dateModifiedSecs == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null || quality == null) {
+ if (uri == null || mimeType == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null || quality == null) {
result.error("getThumbnail-args", "missing arguments", null)
return
}
@@ -54,7 +55,7 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
context = context,
uri = uri,
mimeType = mimeType,
- dateModifiedSecs = dateModifiedSecs,
+ dateModifiedSecs = dateModifiedSecs ?: (Date().time / 1000),
rotationDegrees = rotationDegrees,
isFlipped = isFlipped,
width = (widthDip * density).roundToInt(),
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt
index baee7c5e8..9b2893745 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt
@@ -29,6 +29,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
when (call.method) {
"getDataUsage" -> ioScope.launch { safe(call, result, ::getDataUsage) }
"getStorageVolumes" -> ioScope.launch { safe(call, result, ::getStorageVolumes) }
+ "getCacheDirectory" -> ioScope.launch { safe(call, result, ::getCacheDirectory) }
"getUntrackedTrashPaths" -> ioScope.launch { safe(call, result, ::getUntrackedTrashPaths) }
"getUntrackedVaultPaths" -> ioScope.launch { safe(call, result, ::getUntrackedVaultPaths) }
"getVaultRoot" -> ioScope.launch { safe(call, result, ::getVaultRoot) }
@@ -122,6 +123,18 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
result.success(volumes)
}
+ private fun getCacheDirectory(call: MethodCall, result: MethodChannel.Result) {
+ val external = call.argument("external")
+ if (external == null) {
+ result.error("getCacheDirectory-args", "missing arguments", null)
+ return
+ }
+
+ val dir = (if (external) context.externalCacheDir else context.cacheDir)
+ result.success(dir!!.path)
+ }
+
+
private fun getUntrackedTrashPaths(call: MethodCall, result: MethodChannel.Result) {
val knownPaths = call.argument>("knownPaths")
if (knownPaths == null) {
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt
index 1d6d6a60a..9734cd497 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt
@@ -137,8 +137,7 @@ abstract class ImageProvider {
"success" to false,
)
- // prevent naming with a `.` prefix as it would hide the file and remove it from the Media Store
- if (sourcePath != null && !desiredName.startsWith('.')) {
+ if (sourcePath != null) {
try {
var newFields: FieldMap = skippedFieldMap
if (!isCancelledOp()) {
@@ -570,6 +569,20 @@ abstract class ImageProvider {
}
}
+ fun createTimeStampFileName() = Date().time.toString()
+
+ private fun sanitizeDesiredFileName(desiredName: String): String {
+ var name = desiredName
+ // prevent creating hidden files
+ while (name.isNotEmpty() && name.startsWith(".")) {
+ name = name.substring(1)
+ }
+ if (name.isEmpty()) {
+ name = createTimeStampFileName()
+ }
+ return name
+ }
+
// returns available name to use, or `null` to skip it
suspend fun resolveTargetFileNameWithoutExtension(
contextWrapper: ContextWrapper,
@@ -578,18 +591,19 @@ abstract class ImageProvider {
mimeType: String,
conflictStrategy: NameConflictStrategy,
): NameConflictResolution {
- var resolvedName: String? = desiredNameWithoutExtension
+ val sanitizedNameWithoutExtension = sanitizeDesiredFileName(desiredNameWithoutExtension)
+ var resolvedName: String? = sanitizedNameWithoutExtension
var replacementFile: File? = null
val extension = extensionFor(mimeType)
- val targetFile = File(dir, "$desiredNameWithoutExtension$extension")
+ val targetFile = File(dir, "$sanitizedNameWithoutExtension$extension")
when (conflictStrategy) {
NameConflictStrategy.RENAME -> {
- var nameWithoutExtension = desiredNameWithoutExtension
+ var nameWithoutExtension = sanitizedNameWithoutExtension
var i = 0
while (File(dir, "$nameWithoutExtension$extension").exists()) {
i++
- nameWithoutExtension = "$desiredNameWithoutExtension ($i)"
+ nameWithoutExtension = "$sanitizedNameWithoutExtension ($i)"
}
resolvedName = nameWithoutExtension
}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt
index f05e6991f..fc921de8c 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt
@@ -40,6 +40,7 @@ import java.io.FileOutputStream
import java.io.IOException
import java.io.OutputStream
import java.io.SyncFailedException
+import java.util.Date
import java.util.Locale
import java.util.concurrent.CompletableFuture
import kotlin.coroutines.Continuation
@@ -478,64 +479,62 @@ class MediaStoreImageProvider : ImageProvider() {
"success" to false,
)
- if (sourcePath != null) {
- // on API 30 we cannot get access granted directly to a volume root from its document tree,
- // but it is still less constraining to use tree document files than to rely on the Media Store
- //
- // Relying on `DocumentFile`, we can create an item via `DocumentFile.createFile()`, but:
- // - we need to scan the file to get the Media Store content URI
- // - the underlying document provider controls the new file name
- //
- // Relying on the Media Store, we can create an item via `ContentResolver.insert()`
- // with a path, and retrieve its content URI, but:
- // - the Media Store isolates content by storage volume (e.g. `MediaStore.Images.Media.getContentUri(volumeName)`)
- // - the volume name should be lower case, not exactly as the `StorageVolume` UUID
- // cf new method in API 30 `StorageVolume.getMediaStoreVolumeName()`
- // - inserting on a removable volume works on API 29, but not on API 25 nor 26 (on which API/devices does it work?)
- // - there is no documentation regarding support for usage with removable storage
- // - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage
- try {
- val appDir = when {
- toBin -> StorageUtils.trashDirFor(activity, sourcePath)
- toVault -> File(targetDir)
- else -> null
- }
- if (appDir != null) {
- effectiveTargetDir = ensureTrailingSeparator(appDir.path)
- targetDirDocFile = DocumentFileCompat.fromFile(appDir)
+ // on API 30 we cannot get access granted directly to a volume root from its document tree,
+ // but it is still less constraining to use tree document files than to rely on the Media Store
+ //
+ // Relying on `DocumentFile`, we can create an item via `DocumentFile.createFile()`, but:
+ // - we need to scan the file to get the Media Store content URI
+ // - the underlying document provider controls the new file name
+ //
+ // Relying on the Media Store, we can create an item via `ContentResolver.insert()`
+ // with a path, and retrieve its content URI, but:
+ // - the Media Store isolates content by storage volume (e.g. `MediaStore.Images.Media.getContentUri(volumeName)`)
+ // - the volume name should be lower case, not exactly as the `StorageVolume` UUID
+ // cf new method in API 30 `StorageVolume.getMediaStoreVolumeName()`
+ // - inserting on a removable volume works on API 29, but not on API 25 nor 26 (on which API/devices does it work?)
+ // - there is no documentation regarding support for usage with removable storage
+ // - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage
+ try {
+ val appDir = when {
+ toBin -> StorageUtils.trashDirFor(activity, sourcePath ?: StorageUtils.getPrimaryVolumePath(activity))
+ toVault -> File(targetDir)
+ else -> null
+ }
+ if (appDir != null) {
+ effectiveTargetDir = ensureTrailingSeparator(appDir.path)
+ targetDirDocFile = DocumentFileCompat.fromFile(appDir)
- if (toVault) {
- appDir.mkdirs()
- }
+ if (toVault) {
+ appDir.mkdirs()
}
+ }
- if (effectiveTargetDir != null) {
- val newFields = if (isCancelledOp()) skippedFieldMap else {
- val sourceFile = File(sourcePath)
- if (!sourceFile.exists() && toBin) {
- delete(activity, sourceUri, sourcePath, mimeType = mimeType)
- deletedFieldMap
- } else {
- moveSingle(
- activity = activity,
- sourceFile = sourceFile,
- sourceUri = sourceUri,
- targetDir = effectiveTargetDir,
- targetDirDocFile = targetDirDocFile,
- desiredName = desiredName ?: sourceFile.name,
- nameConflictStrategy = nameConflictStrategy,
- mimeType = mimeType,
- copy = copy,
- toBin = toBin,
- )
- }
+ if (effectiveTargetDir != null) {
+ val newFields = if (isCancelledOp()) skippedFieldMap else {
+ val sourceFile = if (sourcePath != null) File(sourcePath) else null
+ if (sourceFile != null && !sourceFile.exists() && toBin) {
+ delete(activity, sourceUri, sourcePath, mimeType = mimeType)
+ deletedFieldMap
+ } else {
+ moveSingle(
+ activity = activity,
+ sourceFile = sourceFile,
+ sourceUri = sourceUri,
+ targetDir = effectiveTargetDir,
+ targetDirDocFile = targetDirDocFile,
+ desiredName = desiredName ?: sourceFile?.name ?: sourceUri.lastPathSegment ?: createTimeStampFileName(),
+ nameConflictStrategy = nameConflictStrategy,
+ mimeType = mimeType,
+ copy = copy,
+ toBin = toBin,
+ )
}
- result["newFields"] = newFields
- result["success"] = true
}
- } catch (e: Exception) {
- Log.w(LOG_TAG, "failed to move to targetDir=$targetDir entry with sourcePath=$sourcePath", e)
+ result["newFields"] = newFields
+ result["success"] = true
}
+ } catch (e: Exception) {
+ Log.w(LOG_TAG, "failed to move to targetDir=$targetDir entry with sourcePath=$sourcePath", e)
}
callback.onSuccess(result)
}
@@ -544,7 +543,7 @@ class MediaStoreImageProvider : ImageProvider() {
private suspend fun moveSingle(
activity: Activity,
- sourceFile: File,
+ sourceFile: File?,
sourceUri: Uri,
targetDir: String,
targetDirDocFile: DocumentFileCompat?,
@@ -554,8 +553,8 @@ class MediaStoreImageProvider : ImageProvider() {
copy: Boolean,
toBin: Boolean,
): FieldMap {
- val sourcePath = sourceFile.path
- val sourceDir = sourceFile.parent?.let { ensureTrailingSeparator(it) }
+ val sourcePath = sourceFile?.path
+ val sourceDir = sourceFile?.parent?.let { ensureTrailingSeparator(it) }
if (sourceDir == targetDir && !(copy && nameConflictStrategy == NameConflictStrategy.RENAME)) {
// nothing to do unless it's a renamed copy
return skippedFieldMap
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/ExceptionUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/ExceptionUtils.kt
new file mode 100644
index 000000000..f164c9760
--- /dev/null
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/ExceptionUtils.kt
@@ -0,0 +1,10 @@
+package deckers.thibault.aves.utils
+
+inline fun Exception.anyCauseIs(): Boolean {
+ var cause: Throwable? = this
+ while (cause != null) {
+ if (cause is T) return true
+ cause = cause.cause
+ }
+ return false
+}
diff --git a/android/app/src/main/res/values-ar/strings.xml b/android/app/src/main/res/values-ar/strings.xml
index 958d8d391..478e4f4a8 100644
--- a/android/app/src/main/res/values-ar/strings.xml
+++ b/android/app/src/main/res/values-ar/strings.xml
@@ -8,4 +8,5 @@
يتم فحص الوسائط
إيقاف
Aves
+ خريطة
\ No newline at end of file
diff --git a/android/app/src/main/res/values-b+en+Shaw/strings.xml b/android/app/src/main/res/values-b+en+Shaw/strings.xml
new file mode 100644
index 000000000..f7bb920c6
--- /dev/null
+++ b/android/app/src/main/res/values-b+en+Shaw/strings.xml
@@ -0,0 +1,12 @@
+
+
+ 𐑱𐑝𐑰𐑟
+ 𐑓𐑴𐑑𐑴 𐑓𐑮𐑱𐑥
+ 𐑢𐑷𐑤𐑐𐑱𐑐𐑼
+ 𐑥𐑨𐑐
+ 𐑕𐑻𐑗
+ 𐑝𐑦𐑛𐑦𐑴𐑟
+ 𐑥𐑰𐑛𐑾 𐑕𐑒𐑨𐑯
+ 𐑕𐑒𐑨𐑯𐑦𐑙 𐑥𐑰𐑛𐑾
+ 𐑕𐑑𐑪𐑐
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml
index f2f20076c..2c7396d91 100644
--- a/android/app/src/main/res/values-de/strings.xml
+++ b/android/app/src/main/res/values-de/strings.xml
@@ -8,4 +8,5 @@
Analyse von Medien
Medien scannen
Abbrechen
+ Karte
\ No newline at end of file
diff --git a/android/app/src/main/res/values-fi/strings.xml b/android/app/src/main/res/values-fi/strings.xml
index 27295a07f..c9aaf8a47 100644
--- a/android/app/src/main/res/values-fi/strings.xml
+++ b/android/app/src/main/res/values-fi/strings.xml
@@ -8,4 +8,5 @@
Aves
Median skannaus
Hae
+ Kartta
\ No newline at end of file
diff --git a/android/app/src/main/res/values-pt/strings.xml b/android/app/src/main/res/values-pt/strings.xml
index 0e887b1bb..a793c012d 100644
--- a/android/app/src/main/res/values-pt/strings.xml
+++ b/android/app/src/main/res/values-pt/strings.xml
@@ -8,4 +8,5 @@
Digitalização de mídia
Digitalizando mídia
Pare
+ Mapa
\ No newline at end of file
diff --git a/android/app/src/main/res/values-sk/strings.xml b/android/app/src/main/res/values-sk/strings.xml
index 9a8ac434a..a6c505626 100644
--- a/android/app/src/main/res/values-sk/strings.xml
+++ b/android/app/src/main/res/values-sk/strings.xml
@@ -8,4 +8,5 @@
Zastaviť
Skenovanie médií
Skenovanie média
+ Mapa
\ No newline at end of file
diff --git a/android/app/src/main/res/values-tr/strings.xml b/android/app/src/main/res/values-tr/strings.xml
index e516cc30b..c115ff9dc 100644
--- a/android/app/src/main/res/values-tr/strings.xml
+++ b/android/app/src/main/res/values-tr/strings.xml
@@ -8,4 +8,5 @@
Medya tarama
Medya taranıyor
Durdur
+ Harita
\ No newline at end of file
diff --git a/android/app/src/main/res/values-uk/strings.xml b/android/app/src/main/res/values-uk/strings.xml
index 5d33f2d3b..60717a3ee 100644
--- a/android/app/src/main/res/values-uk/strings.xml
+++ b/android/app/src/main/res/values-uk/strings.xml
@@ -8,4 +8,5 @@
Стоп
Фоторамка
Сканування медіа
+ Мапа
\ No newline at end of file
diff --git a/android/app/src/main/res/values-vi/strings.xml b/android/app/src/main/res/values-vi/strings.xml
index 367ab8dc2..96db6a481 100644
--- a/android/app/src/main/res/values-vi/strings.xml
+++ b/android/app/src/main/res/values-vi/strings.xml
@@ -8,4 +8,5 @@
Aves
Quét phương tiện
Tìm kiếm
+ Bản đồ
\ No newline at end of file
diff --git a/android/exifinterface/build.gradle b/android/exifinterface/build.gradle
index d2e62e0d5..46bfa54d8 100644
--- a/android/exifinterface/build.gradle
+++ b/android/exifinterface/build.gradle
@@ -20,8 +20,8 @@ android {
}
}
compileOptions {
- sourceCompatibility JavaVersion.VERSION_21
- targetCompatibility JavaVersion.VERSION_21
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
}
}
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
index 45181329e..81a4301fc 100644
--- a/android/gradle/wrapper/gradle-wrapper.properties
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
diff --git a/android/settings.gradle b/android/settings.gradle
index e262ed8f1..76ed740ec 100644
--- a/android/settings.gradle
+++ b/android/settings.gradle
@@ -10,7 +10,7 @@ pluginManagement {
settings.ext.kotlin_version = '1.9.24'
settings.ext.ksp_version = "$kotlin_version-1.0.20"
- settings.ext.agp_version = '8.6.1'
+ settings.ext.agp_version = '8.7.0'
includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle")
diff --git a/fastlane/metadata/android/en-Shaw/full_description.txt b/fastlane/metadata/android/en-Shaw/full_description.txt
new file mode 100644
index 000000000..443fb2726
--- /dev/null
+++ b/fastlane/metadata/android/en-Shaw/full_description.txt
@@ -0,0 +1,5 @@
+¡·𐑱𐑝𐑰𐑟 𐑒𐑨𐑯 𐑣𐑨𐑯𐑛𐑩𐑤 𐑷𐑤 𐑕𐑹𐑑𐑕 𐑝 𐑦𐑥𐑦𐑡𐑩𐑟 𐑯 𐑝𐑦𐑛𐑦𐑴𐑟, 𐑦𐑯𐑒𐑤𐑵𐑛𐑦𐑙 𐑘𐑹 𐑑𐑦𐑐𐑦𐑒𐑩𐑤 ⸰𐑡𐑓𐑧𐑜'𐑟 𐑯 ⸰𐑥𐑐4'𐑟, 𐑚𐑳𐑑 𐑷𐑤𐑕𐑴 𐑥𐑹 𐑦𐑜𐑟𐑪𐑑𐑦𐑒 𐑔𐑦𐑙𐑟 𐑤𐑲𐑒 𐑥𐑳𐑤𐑑𐑦-𐑐𐑱𐑡 ⸰𐑑𐑦𐑓𐑓'𐑕, ⸰𐑕𐑝𐑜'𐑟, 𐑴𐑤𐑛 ⸰𐑷𐑝𐑦'𐑟 𐑯 𐑥𐑹! 𐑦𐑑 𐑕𐑒𐑨𐑯𐑟 𐑘𐑹 𐑥𐑰𐑛𐑾 𐑒𐑩𐑤𐑧𐑒𐑖𐑩𐑯 𐑑 𐑲𐑛𐑧𐑯𐑑𐑦𐑓𐑲 𐑥𐑴𐑖𐑩𐑯 𐑓𐑴𐑑𐑴𐑟, 𐑐𐑨𐑯𐑼𐑭𐑥𐑩𐑟 (⸰𐑷𐑯𐑨 𐑓𐑴𐑑𐑴 𐑕𐑓𐑽𐑟), 360° 𐑝𐑦𐑛𐑦𐑴𐑟, 𐑨𐑟 𐑢𐑧𐑤 𐑨𐑟 ⸰𐑡𐑰𐑴𐑑𐑦𐑓𐑓 𐑓𐑲𐑤𐑟.
+
+𐑯𐑨𐑝𐑦𐑜𐑱𐑖𐑩𐑯 𐑯 𐑕𐑻𐑗 𐑦𐑟 𐑩𐑯 𐑦𐑥𐑐𐑹𐑑𐑩𐑯𐑑 𐑐𐑸𐑑 𐑝 ·𐑱𐑝𐑰𐑟. 𐑞 𐑜𐑴𐑤 𐑦𐑟 𐑓 𐑿𐑟𐑼𐑟 𐑑 𐑰𐑟𐑦𐑤𐑦 𐑓𐑤𐑴 𐑓𐑮𐑪𐑥 𐑨𐑤𐑚𐑩𐑥𐑟 𐑑 𐑓𐑴𐑑𐑴𐑟 𐑑 𐑑𐑨𐑜𐑟 𐑑 𐑥𐑨𐑐𐑕, 𐑯𐑯𐑯.
+
+·𐑱𐑝𐑰𐑟 𐑦𐑯𐑑𐑦𐑜𐑮𐑱𐑑𐑕 𐑢𐑦𐑞 ·𐑨𐑯𐑛𐑮𐑶𐑛 (𐑓𐑮𐑪𐑥 ·𐑒𐑦𐑑𐑒𐑨𐑑 𐑑 ·𐑨𐑯𐑛𐑮𐑶𐑛 14, 𐑦𐑯𐑒𐑤𐑵𐑛𐑦𐑙 ·𐑨𐑯𐑛𐑮𐑶𐑛 ⸰𐑑𐑝) 𐑢𐑦𐑞 𐑓𐑰𐑗𐑼𐑟 𐑕𐑳𐑗 𐑨𐑟 𐑢𐑦𐑡𐑩𐑑𐑕, 𐑨𐑐 𐑖𐑹𐑑𐑒𐑳𐑑𐑕, 𐑕𐑒𐑮𐑰𐑯 𐑕𐑱𐑝𐑼 𐑯 𐑜𐑤𐑴𐑚𐑩𐑤 𐑕𐑻𐑗 𐑣𐑨𐑯𐑛𐑤𐑦𐑙. 𐑦𐑑 𐑷𐑤𐑕𐑴 𐑢𐑻𐑒𐑕 𐑨𐑟 𐑩 𐑥𐑰𐑛𐑾 𐑝𐑿𐑼 𐑯 𐑐𐑦𐑒𐑼.
diff --git a/fastlane/metadata/android/en-Shaw/images/featureGraphic.png b/fastlane/metadata/android/en-Shaw/images/featureGraphic.png
new file mode 100644
index 000000000..c12acf8f6
Binary files /dev/null and b/fastlane/metadata/android/en-Shaw/images/featureGraphic.png differ
diff --git a/fastlane/metadata/android/en-Shaw/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-Shaw/images/phoneScreenshots/1.png
new file mode 100644
index 000000000..4970dc5fa
Binary files /dev/null and b/fastlane/metadata/android/en-Shaw/images/phoneScreenshots/1.png differ
diff --git a/fastlane/metadata/android/en-Shaw/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-Shaw/images/phoneScreenshots/2.png
new file mode 100644
index 000000000..4a1e4b281
Binary files /dev/null and b/fastlane/metadata/android/en-Shaw/images/phoneScreenshots/2.png differ
diff --git a/fastlane/metadata/android/en-Shaw/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-Shaw/images/phoneScreenshots/3.png
new file mode 100644
index 000000000..942667170
Binary files /dev/null and b/fastlane/metadata/android/en-Shaw/images/phoneScreenshots/3.png differ
diff --git a/fastlane/metadata/android/en-Shaw/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-Shaw/images/phoneScreenshots/4.png
new file mode 100644
index 000000000..d8a82ad50
Binary files /dev/null and b/fastlane/metadata/android/en-Shaw/images/phoneScreenshots/4.png differ
diff --git a/fastlane/metadata/android/en-Shaw/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-Shaw/images/phoneScreenshots/5.png
new file mode 100644
index 000000000..f4c597fca
Binary files /dev/null and b/fastlane/metadata/android/en-Shaw/images/phoneScreenshots/5.png differ
diff --git a/fastlane/metadata/android/en-Shaw/images/phoneScreenshots/6.png b/fastlane/metadata/android/en-Shaw/images/phoneScreenshots/6.png
new file mode 100644
index 000000000..7db91fac3
Binary files /dev/null and b/fastlane/metadata/android/en-Shaw/images/phoneScreenshots/6.png differ
diff --git a/fastlane/metadata/android/en-Shaw/images/phoneScreenshots/7.png b/fastlane/metadata/android/en-Shaw/images/phoneScreenshots/7.png
new file mode 100644
index 000000000..636ae49e7
Binary files /dev/null and b/fastlane/metadata/android/en-Shaw/images/phoneScreenshots/7.png differ
diff --git a/fastlane/metadata/android/en-Shaw/short_description.txt b/fastlane/metadata/android/en-Shaw/short_description.txt
new file mode 100644
index 000000000..9c2b68f1a
--- /dev/null
+++ b/fastlane/metadata/android/en-Shaw/short_description.txt
@@ -0,0 +1 @@
+𐑜𐑨𐑤𐑼𐑦 𐑯 𐑥𐑧𐑑𐑩𐑛𐑱𐑑𐑩 𐑦𐑒𐑕𐑐𐑤𐑹𐑼
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/changelogs/136.txt b/fastlane/metadata/android/en-US/changelogs/136.txt
new file mode 100644
index 000000000..10f3ff7c4
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/136.txt
@@ -0,0 +1,5 @@
+In v1.11.17:
+- peruse your videos frame by frame
+- create map shortcuts to filtered collections
+- enjoy the app in Shavian
+Full changelog available on GitHub
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/changelogs/13601.txt b/fastlane/metadata/android/en-US/changelogs/13601.txt
new file mode 100644
index 000000000..10f3ff7c4
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/13601.txt
@@ -0,0 +1,5 @@
+In v1.11.17:
+- peruse your videos frame by frame
+- create map shortcuts to filtered collections
+- enjoy the app in Shavian
+Full changelog available on GitHub
\ No newline at end of file
diff --git a/lib/geo/countries.dart b/lib/geo/countries.dart
index f2b0c32b6..eeb1db079 100644
--- a/lib/geo/countries.dart
+++ b/lib/geo/countries.dart
@@ -41,7 +41,7 @@ class CountryTopology {
return Map.fromEntries(numericMap.entries.map((kv) {
final code = _countryOfNumeric(kv.key);
return code != null ? MapEntry(code, kv.value) : null;
- }).whereNotNull());
+ }).nonNulls);
}
// returns a map of the given positions by the ISO 3166-1 numeric code of the country containing them
diff --git a/lib/geo/topojson.dart b/lib/geo/topojson.dart
index c03f7d553..94bf6bb9a 100644
--- a/lib/geo/topojson.dart
+++ b/lib/geo/topojson.dart
@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:convert';
import 'dart:isolate';
-import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
// cf https://github.com/topojson/topojson-specification
@@ -60,7 +59,7 @@ class Topology extends TopologyJsonObject {
final name = kv.key;
final geometry = Geometry.build(kv.value);
return geometry != null ? MapEntry(name, geometry) : null;
- }).whereNotNull()),
+ }).nonNulls),
arcs = (data['arcs'] as List).cast().map((arc) => arc.cast().map((position) => position.cast()).toList()).toList(),
transform = data.containsKey('transform') ? Transform.parse((data['transform'] as Map).cast()) : null,
super.parse();
@@ -238,7 +237,7 @@ class GeometryCollection extends Geometry {
final List geometries;
GeometryCollection.parse(super.data)
- : geometries = (data['geometries'] as List).cast