Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2021-02-26 14:57:42 +09:00
commit 9b74dd289e
139 changed files with 2320 additions and 984 deletions

View file

@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
## [v1.3.5] - 2021-02-26
### Added
- support Android KitKat, Lollipop & Marshmallow (API 19 ~ 23)
- quick country reverse geocoding without Play Services
- menu option to hide any filter
- menu option to navigate to the album / country / tag page from filter
### Changed
- analytics are opt-in
### Removed
- removed custom font used in titles and info page
## [v1.3.4] - 2021-02-10
### Added
- hide album / country / tag from collection

View file

@ -21,7 +21,7 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
- search and filter by country, place, XMP tag, type (animated, raster, vector…)
- favorites
- statistics
- support Android API 24 ~ 30 (Nougat ~ R)
- support Android API 19 ~ 30 (KitKat ~ R)
- Android integration (app shortcuts, handle view/pick intents)
## Known Issues

View file

@ -53,8 +53,7 @@ android {
defaultConfig {
applicationId "deckers.thibault.aves"
// TODO TLAD try minSdkVersion 23
minSdkVersion 24
minSdkVersion 19
targetSdkVersion 30 // same as compileSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName

View file

@ -14,10 +14,6 @@
https://developer.android.com/preview/privacy/storage#media-file-access
- raw path access:
https://developer.android.com/preview/privacy/storage#media-files-raw-paths
Android R issues:
- users cannot grant directory access to the root Downloads directory,
- users cannot grant directory access to the root directory of each reliable SD card volume
-->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
@ -31,9 +27,7 @@
<!-- to access media with unredacted metadata with scoped storage (Android Q+) -->
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<!-- TODO TLAD remove this permission once this issue is fixed:
https://github.com/flutter/flutter/issues/42451
-->
<!-- TODO TLAD remove this permission when this is fixed: https://github.com/flutter/flutter/issues/42451 -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- from Android R, we should define <queries> to make other apps visible to this app -->
@ -48,6 +42,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:requestLegacyExternalStorage="true">
<!-- TODO TLAD Android 12 https://developer.android.com/about/versions/12/behavior-changes-12#exported -->
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"

View file

@ -32,11 +32,13 @@ class MainActivity : FlutterActivity() {
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
MethodChannel(messenger, AppShortcutHandler.CHANNEL).setMethodCallHandler(AppShortcutHandler(this))
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(MetadataHandler(this))
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
MethodChannel(messenger, TimeHandler.CHANNEL).setMethodCallHandler(TimeHandler())
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(WindowHandler(this))
StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) }
StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageOpStreamHandler(this, args) }

View file

@ -4,7 +4,6 @@ import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.net.Uri
import android.util.Log
@ -89,7 +88,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
@Suppress("DEPRECATION")
resources.updateConfiguration(englishConfig, resources.displayMetrics)
englishLabel = resources.getString(labelRes)
} catch (e: PackageManager.NameNotFoundException) {
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get app label in English for packageName=$packageName", e)
}
englishLabel
@ -145,7 +144,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
Log.w(LOG_TAG, "failed to decode app icon for packageName=$packageName", e)
}
Glide.with(context).clear(target)
} catch (e: PackageManager.NameNotFoundException) {
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get app info for packageName=$packageName", e)
return
}

View file

@ -50,14 +50,23 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
}
private fun getContextDirs() = hashMapOf(
"dataDir" to context.dataDir,
"cacheDir" to context.cacheDir,
"codeCacheDir" to context.codeCacheDir,
"filesDir" to context.filesDir,
"noBackupFilesDir" to context.noBackupFilesDir,
"obbDir" to context.obbDir,
"externalCacheDir" to context.externalCacheDir,
).mapValues { it.value?.path }
).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 }
private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }

View file

@ -125,7 +125,8 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
pageId = pageId,
sampleSize = sampleSize,
regionRect = regionRect,
imageSize = Size(imageWidth, imageHeight),
imageWidth = imageWidth,
imageHeight = imageHeight,
result = result,
)
}

View file

@ -119,7 +119,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
// optional parent to distinguish child directories of the same type
dir.parent?.name?.let { dirName = "$it/$dirName" }
val dirMap = metadataMap.getOrDefault(dirName, HashMap())
val dirMap = metadataMap[dirName] ?: HashMap()
metadataMap[dirName] = dirMap
// tags
@ -325,7 +325,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)) {
val count = xmpMeta.countArrayItems(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)
val values = (1 until count + 1).map { xmpMeta.getArrayItem(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME, it).value }
metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(separator = XMP_SUBJECTS_SEPARATOR)
metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(XMP_SUBJECTS_SEPARATOR)
}
xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.TITLE_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it }
if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) {
@ -350,7 +350,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
// XMP fallback to IPTC
if (!metadataMap.containsKey(KEY_XMP_SUBJECTS)) {
for (dir in metadata.getDirectoriesOfType(IptcDirectory::class.java)) {
dir.keywords?.let { metadataMap[KEY_XMP_SUBJECTS] = it.joinToString(separator = XMP_SUBJECTS_SEPARATOR) }
dir.keywords?.let { metadataMap[KEY_XMP_SUBJECTS] = it.joinToString(XMP_SUBJECTS_SEPARATOR) }
}
}
@ -594,7 +594,9 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
KEY_MIME_TYPE to trackMime,
)
format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { page[KEY_IS_DEFAULT] = it != 0 }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
format.getSafeInt(MediaFormat.KEY_TRACK_ID) { page[KEY_TRACK_ID] = it }
}
format.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it }
format.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it }
if (isVideo(trackMime)) {
@ -677,8 +679,20 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
val projection = arrayOf(prop)
val cursor = context.contentResolver.query(contentUri, projection, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
val cursor: Cursor?
try {
cursor = context.contentResolver.query(contentUri, projection, null, null, null)
} catch (e: Exception) {
// throws SQLiteException when the requested prop is not a known column
result.error("getContentResolverProp-query", "failed to query for contentUri=$contentUri", e.message)
return
}
if (cursor == null || !cursor.moveToFirst()) {
result.error("getContentResolverProp-cursor", "failed to get cursor for contentUri=$contentUri", null)
return
}
var value: Any? = null
try {
value = when (cursor.getType(0)) {
@ -694,9 +708,6 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
cursor.close()
result.success(value?.toString())
} else {
result.error("getContentResolverProp-null", "failed to get cursor for contentUri=$contentUri", null)
}
}
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {

View file

@ -4,9 +4,12 @@ import android.content.Context
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager
import androidx.core.os.EnvironmentCompat
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.utils.PermissionManager
import deckers.thibault.aves.utils.StorageUtils.getPrimaryVolumePath
import deckers.thibault.aves.utils.StorageUtils.getVolumePaths
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
@ -24,6 +27,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
"getFreeSpace" -> safe(call, result, ::getFreeSpace)
"getGrantedDirectories" -> safe(call, result, ::getGrantedDirectories)
"getInaccessibleDirectories" -> safe(call, result, ::getInaccessibleDirectories)
"getRestrictedDirectories" -> safe(call, result, ::getRestrictedDirectories)
"revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess)
"scanFile" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::scanFile) }
else -> result.notImplemented()
@ -31,31 +35,52 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
}
private fun getStorageVolumes(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
val volumes: List<Map<String, Any>> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val volumes = ArrayList<Map<String, Any>>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val sm = context.getSystemService(StorageManager::class.java)
if (sm != null) {
for (volumePath in getVolumePaths(context)) {
try {
sm.getStorageVolume(File(volumePath))?.let {
val volumeMap = HashMap<String, Any>()
volumeMap["path"] = volumePath
volumeMap["description"] = it.getDescription(context)
volumeMap["isPrimary"] = it.isPrimary
volumeMap["isRemovable"] = it.isRemovable
volumeMap["isEmulated"] = it.isEmulated
volumeMap["state"] = it.state
volumes.add(volumeMap)
volumes.add(
hashMapOf(
"path" to volumePath,
"description" to it.getDescription(context),
"isPrimary" to it.isPrimary,
"isRemovable" to it.isRemovable,
"state" to it.state,
)
)
}
} catch (e: IllegalArgumentException) {
// ignore
}
}
}
volumes
} else {
// TODO TLAD find alternative for Android <N
emptyList()
val primaryVolumePath = getPrimaryVolumePath(context)
for (volumePath in getVolumePaths(context)) {
val volumeFile = File(volumePath)
try {
val isPrimary = volumePath == primaryVolumePath
val isRemovable = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
Environment.isExternalStorageRemovable(volumeFile)
} else {
// random guess
!isPrimary
}
volumes.add(
hashMapOf(
"path" to volumePath,
"isPrimary" to isPrimary,
"isRemovable" to isRemovable,
"state" to EnvironmentCompat.getStorageState(volumeFile)
)
)
} catch (e: IllegalArgumentException) {
// ignore
}
}
}
result.success(volumes)
}
@ -67,21 +92,9 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
return
}
val sm = context.getSystemService(StorageManager::class.java)
if (sm == null) {
result.error("getFreeSpace-sm", "failed because of missing Storage Manager", null)
return
}
val file = File(path)
val volume = sm.getStorageVolume(file)
if (volume == null) {
result.error("getFreeSpace-volume", "failed because of missing volume for path=$path", null)
return
}
// `StorageStatsManager` `getFreeBytes()` is only available from API 26,
// and non-primary volume UUIDs cannot be used with it
val file = File(path)
try {
result.success(file.freeSpace)
} catch (e: SecurityException) {
@ -100,8 +113,11 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
return
}
val dirs = PermissionManager.getInaccessibleDirectories(context, dirPaths)
result.success(dirs)
result.success(PermissionManager.getInaccessibleDirectories(context, dirPaths))
}
private fun getRestrictedDirectories(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
result.success(PermissionManager.getRestrictedDirectories(context))
}
private fun revokeDirectoryAccess(call: MethodCall, result: MethodChannel.Result) {
@ -111,6 +127,11 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
return
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
result.error("revokeDirectoryAccess-unsupported", "volume access is not allowed before Android Lollipop", null)
return
}
val success = PermissionManager.revokeDirectoryAccess(context, path)
result.success(success)
}

View file

@ -0,0 +1,19 @@
package deckers.thibault.aves.channel.calls
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import java.util.*
class TimeHandler : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getDefaultTimeZone" -> result.success(TimeZone.getDefault().id)
else -> result.notImplemented()
}
}
companion object {
const val CHANNEL = "deckers.thibault/aves/time"
}
}

View file

@ -0,0 +1,38 @@
package deckers.thibault.aves.channel.calls
import android.app.Activity
import android.view.WindowManager
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
class WindowHandler(private val activity: Activity) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"keepScreenOn" -> safe(call, result, ::keepScreenOn)
else -> result.notImplemented()
}
}
private fun keepScreenOn(call: MethodCall, result: MethodChannel.Result) {
val on = call.argument<Boolean>("on")
if (on == null) {
result.error("keepOn-args", "failed because of missing arguments", null)
return
}
val window = activity.window
val flag = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
if (on) {
window.addFlags(flag)
} else {
window.clearFlags(flag)
}
result.success(null)
}
companion object {
const val CHANNEL = "deckers.thibault/aves/window"
}
}

View file

@ -6,7 +6,6 @@ import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.graphics.Rect
import android.net.Uri
import android.util.Size
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy
@ -37,7 +36,8 @@ class RegionFetcher internal constructor(
pageId: Int?,
sampleSize: Int,
regionRect: Rect,
imageSize: Size,
imageWidth: Int,
imageHeight: Int,
result: MethodChannel.Result,
) {
if (MimeTypes.isHeifLike(mimeType) && pageId != null) {
@ -48,7 +48,8 @@ class RegionFetcher internal constructor(
pageId = null,
sampleSize = sampleSize,
regionRect = regionRect,
imageSize = imageSize,
imageWidth = imageWidth,
imageHeight = imageHeight,
result = result,
)
return
@ -79,9 +80,9 @@ class RegionFetcher internal constructor(
// with raw images, the known image size may not match the decoded image size
// so we scale the requested region accordingly
val effectiveRect = if (imageSize.width != decoder.width || imageSize.height != decoder.height) {
val xf = decoder.width.toDouble() / imageSize.width
val yf = decoder.height.toDouble() / imageSize.height
val effectiveRect = if (imageWidth != decoder.width || imageHeight != decoder.height) {
val xf = decoder.width.toDouble() / imageWidth
val yf = decoder.height.toDouble() / imageHeight
Rect(
(regionRect.left * xf).roundToInt(),
(regionRect.top * yf).roundToInt(),

View file

@ -47,7 +47,6 @@ class ThumbnailFetcher internal constructor(
fun fetch() {
var bitmap: Bitmap? = null
var recycle = true
var exception: Exception? = null
try {
@ -66,14 +65,13 @@ class ThumbnailFetcher internal constructor(
if (bitmap == null) {
try {
bitmap = getByGlide()
recycle = false
} catch (e: Exception) {
exception = e
}
}
if (bitmap != null) {
result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = recycle))
result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false))
} else {
var errorDetails: String? = exception?.message
if (errorDetails?.isNotEmpty() == true) {

View file

@ -55,7 +55,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
}
}
private suspend fun fetchAll() {
private fun fetchAll() {
MediaStoreImageProvider().fetchAll(context, knownEntries ?: emptyMap()) { success(it) }
endOfStream()
}

View file

@ -1,6 +1,7 @@
package deckers.thibault.aves.channel.streams
import android.app.Activity
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
@ -26,6 +27,16 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
this.eventSink = eventSink
handler = Handler(Looper.getMainLooper())
if (path == null) {
error("requestVolumeAccess-args", "failed because of missing arguments", null)
return
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
error("requestVolumeAccess-unsupported", "volume access is not allowed before Android Lollipop", null)
return
}
requestVolumeAccess(activity, path!!, { success(true) }, { success(false) })
}
@ -42,6 +53,17 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
endOfStream()
}
@Suppress("SameParameterValue")
private fun error(errorCode: String, errorMessage: String, errorDetails: Any?) {
handler.post {
try {
eventSink.error(errorCode, errorMessage, errorDetails)
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to use event sink", e)
}
}
}
private fun endOfStream() {
handler.post {
try {

View file

@ -9,6 +9,7 @@ import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.ImageHeaderParser
import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser
import com.bumptech.glide.module.AppGlideModule
import deckers.thibault.aves.utils.compatRemoveIf
@GlideModule
class AvesAppGlideModule : AppGlideModule() {
@ -20,7 +21,7 @@ class AvesAppGlideModule : AppGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
// prevent ExifInterface error logs
// cf https://github.com/bumptech/glide/issues/3383
glide.registry.imageHeaderParsers.removeIf { parser: ImageHeaderParser? -> parser is ExifInterfaceImageHeaderParser }
glide.registry.imageHeaderParsers.compatRemoveIf { parser: ImageHeaderParser? -> parser is ExifInterfaceImageHeaderParser }
}
override fun isManifestParsingEnabled(): Boolean = false

View file

@ -9,6 +9,8 @@ import com.drew.metadata.exif.makernotes.OlympusCameraSettingsMakernoteDirectory
import com.drew.metadata.exif.makernotes.OlympusImageProcessingMakernoteDirectory
import com.drew.metadata.exif.makernotes.OlympusMakernoteDirectory
import deckers.thibault.aves.utils.LogUtils
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.abs
import kotlin.math.floor
@ -16,6 +18,7 @@ import kotlin.math.roundToLong
object ExifInterfaceHelper {
private val LOG_TAG = LogUtils.createTag(ExifInterfaceHelper::class.java)
private val DATETIME_FORMAT = SimpleDateFormat("yyyy:MM:dd hh:mm:ss", Locale.ROOT)
private const val precisionErrorTolerance = 1e-10
@ -358,11 +361,15 @@ object ExifInterfaceHelper {
fun ExifInterface.getSafeDateMillis(tag: String, save: (value: Long) -> Unit) {
if (this.hasAttribute(tag)) {
// TODO TLAD parse date with "yyyy:MM:dd HH:mm:ss" or find the original long
val formattedDate = this.getAttribute(tag)
val value = formattedDate?.toLongOrNull()
if (value != null && value > 0) {
save(value)
val dateString = this.getAttribute(tag)
if (dateString != null) {
try {
DATETIME_FORMAT.parse(dateString)?.let { date ->
save(date.time)
}
} catch (e: ParseException) {
Log.w(LOG_TAG, "failed to parse date=$dateString", e)
}
}
}
}

View file

@ -15,11 +15,7 @@ object MediaMetadataRetrieverHelper {
MediaMetadataRetriever.METADATA_KEY_ARTIST to "Artist",
MediaMetadataRetriever.METADATA_KEY_AUTHOR to "Author",
MediaMetadataRetriever.METADATA_KEY_BITRATE to "Bitrate",
MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE to "Capture Framerate",
MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER to "CD Track Number",
MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE to "Color Range",
MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD to "Color Standard",
MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER to "Color Transfer",
MediaMetadataRetriever.METADATA_KEY_COMPILATION to "Compilation",
MediaMetadataRetriever.METADATA_KEY_COMPOSER to "Composer",
MediaMetadataRetriever.METADATA_KEY_DATE to "Date",
@ -38,6 +34,9 @@ object MediaMetadataRetrieverHelper {
MediaMetadataRetriever.METADATA_KEY_WRITER to "Writer",
MediaMetadataRetriever.METADATA_KEY_YEAR to "Year",
).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
put(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE, "Capture Framerate")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
putAll(
hashMapOf(
@ -59,6 +58,15 @@ object MediaMetadataRetrieverHelper {
)
)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
putAll(
hashMapOf(
MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE to "Color Range",
MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD to "Color Standard",
MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER to "Color Transfer",
)
)
}
}
private val durationFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") }

View file

@ -95,7 +95,7 @@ object Metadata {
// opening large TIFF files yields an OOM (both with `metadata-extractor` v2.15.0 and `ExifInterface` v1.3.1),
// so we define an arbitrary threshold to avoid a crash on launch.
// It is not clear whether it is because of the file itself or its metadata.
const val tiffSizeBytesMax = 100 * (1 shl 20) // MB
private const val tiffSizeBytesMax = 100 * (1 shl 20) // MB
// we try and read metadata from large files by copying an arbitrary amount from its beginning
// to a temporary file, and reusing that preview file for all metadata reading purposes

View file

@ -12,10 +12,6 @@ object MetadataExtractorHelper {
// extensions
fun Directory.getSafeDescription(tag: Int, save: (value: String) -> Unit) {
if (this.containsTag(tag)) save(this.getDescription(tag))
}
fun Directory.getSafeString(tag: Int, save: (value: String) -> Unit) {
if (this.containsTag(tag)) save(this.getString(tag))
}

View file

@ -8,22 +8,22 @@ import java.io.ByteArrayInputStream
// `xmlBytes`: bytes representing the XML embedded in a MP4 `uuid` box, according to Spherical Video V1 spec
class GSpherical(xmlBytes: ByteArray) {
var spherical: Boolean = false
var stitched: Boolean = false
var stitchingSoftware: String = ""
var projectionType: String = ""
var stereoMode: String? = null
var sourceCount: Int? = null
var initialViewHeadingDegrees: Int? = null
var initialViewPitchDegrees: Int? = null
var initialViewRollDegrees: Int? = null
var timestamp: Int? = null
var fullPanoWidthPixels: Int? = null
var fullPanoHeightPixels: Int? = null
var croppedAreaImageWidthPixels: Int? = null
var croppedAreaImageHeightPixels: Int? = null
var croppedAreaLeftPixels: Int? = null
var croppedAreaTopPixels: Int? = null
private var spherical: Boolean = false
private var stitched: Boolean = false
private var stitchingSoftware: String = ""
private var projectionType: String = ""
private var stereoMode: String? = null
private var sourceCount: Int? = null
private var initialViewHeadingDegrees: Int? = null
private var initialViewPitchDegrees: Int? = null
private var initialViewRollDegrees: Int? = null
private var timestamp: Int? = null
private var fullPanoWidthPixels: Int? = null
private var fullPanoHeightPixels: Int? = null
private var croppedAreaImageWidthPixels: Int? = null
private var croppedAreaImageHeightPixels: Int? = null
private var croppedAreaLeftPixels: Int? = null
private var croppedAreaTopPixels: Int? = null
init {
try {

View file

@ -40,7 +40,7 @@ object TiffTags {
// Matteing
// Tag = 32995 (80E3.H)
// obsoleted by the 6.0 ExtraSamples (338)
val TAG_MATTEING = 0x80e3
const val TAG_MATTEING = 0x80e3
/*
GeoTIFF
@ -80,7 +80,7 @@ object TiffTags {
// Tag = 34737 (87B1.H)
// Type = ASCII
// Count = variable
val TAG_GEO_ASCII_PARAMS = 0x87b1
const val TAG_GEO_ASCII_PARAMS = 0x87b1
/*
Photoshop
@ -91,7 +91,7 @@ object TiffTags {
// ImageSourceData
// Tag = 37724 (935C.H)
// Type = UNDEFINED
val TAG_IMAGE_SOURCE_DATA = 0x935c
const val TAG_IMAGE_SOURCE_DATA = 0x935c
/*
DNG
@ -102,13 +102,13 @@ object TiffTags {
// Tag = 50735 (C62F.H)
// Type = ASCII
// Count = variable
val TAG_CAMERA_SERIAL_NUMBER = 0xc62f
const val TAG_CAMERA_SERIAL_NUMBER = 0xc62f
// OriginalRawFileName (optional)
// Tag = 50827 (C68B.H)
// Type = ASCII or BYTE
// Count = variable
val TAG_ORIGINAL_RAW_FILE_NAME = 0xc68b
const val TAG_ORIGINAL_RAW_FILE_NAME = 0xc68b
private val tagNameMap = hashMapOf(
TAG_X_POSITION to "X Position",

View file

@ -1,7 +1,6 @@
package deckers.thibault.aves.model
import android.net.Uri
import deckers.thibault.aves.model.FieldMap
class AvesEntry(map: FieldMap) {
val uri: Uri = Uri.parse(map["uri"] as String) // content or file URI

View file

@ -6,7 +6,7 @@ import android.provider.MediaStore
import deckers.thibault.aves.model.SourceEntry
internal class ContentImageProvider : ImageProvider() {
override suspend fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) {
override fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) {
if (mimeType == null) {
callback.onFailure(Exception("MIME type is null for uri=$uri"))
return

View file

@ -6,7 +6,7 @@ import deckers.thibault.aves.model.SourceEntry
import java.io.File
internal class FileImageProvider : ImageProvider() {
override suspend fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) {
override fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) {
if (mimeType == null) {
callback.onFailure(Exception("MIME type is null for uri=$uri"))
return

View file

@ -35,7 +35,7 @@ import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
abstract class ImageProvider {
open suspend fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) {
open fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) {
callback.onFailure(UnsupportedOperationException())
}

View file

@ -20,13 +20,12 @@ import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
import deckers.thibault.aves.utils.StorageUtils.requireAccessPermission
import deckers.thibault.aves.utils.UriUtils.tryParseId
import kotlinx.coroutines.delay
import java.io.File
import java.util.*
import kotlin.collections.ArrayList
class MediaStoreImageProvider : ImageProvider() {
suspend fun fetchAll(context: Context, knownEntries: Map<Int, Int?>, handleNewEntry: NewEntryHandler) {
fun fetchAll(context: Context, knownEntries: Map<Int, Int?>, handleNewEntry: NewEntryHandler) {
val isModified = fun(contentId: Int, dateModifiedSecs: Int): Boolean {
val knownDate = knownEntries[contentId]
return knownDate == null || knownDate < dateModifiedSecs
@ -35,7 +34,7 @@ class MediaStoreImageProvider : ImageProvider() {
fetchFrom(context, isModified, handleNewEntry, VIDEO_CONTENT_URI, VIDEO_PROJECTION)
}
override suspend fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) {
override fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) {
val id = uri.tryParseId()
val onSuccess = fun(entry: FieldMap) {
entry["uri"] = uri.toString()
@ -45,17 +44,17 @@ class MediaStoreImageProvider : ImageProvider() {
if (id != null) {
if (mimeType == null || isImage(mimeType)) {
val contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, id)
if (fetchFrom(context, alwaysValid, onSuccess, contentUri, IMAGE_PROJECTION) > 0) return
if (fetchFrom(context, alwaysValid, onSuccess, contentUri, IMAGE_PROJECTION)) return
}
if (mimeType == null || isVideo(mimeType)) {
val contentUri = ContentUris.withAppendedId(VIDEO_CONTENT_URI, id)
if (fetchFrom(context, alwaysValid, onSuccess, contentUri, VIDEO_PROJECTION) > 0) return
if (fetchFrom(context, alwaysValid, onSuccess, contentUri, VIDEO_PROJECTION)) return
}
}
// the uri can be a file media URI (e.g. "content://0@media/external/file/30050")
// without an equivalent image/video if it is shared from a file browser
// but the file is not publicly visible
if (fetchFrom(context, alwaysValid, onSuccess, uri, BASE_PROJECTION, fileMimeType = mimeType) > 0) return
if (fetchFrom(context, alwaysValid, onSuccess, uri, BASE_PROJECTION, fileMimeType = mimeType)) return
callback.onFailure(Exception("failed to fetch entry at uri=$uri"))
}
@ -109,15 +108,15 @@ class MediaStoreImageProvider : ImageProvider() {
return obsoleteIds
}
private suspend fun fetchFrom(
private fun fetchFrom(
context: Context,
isValidEntry: NewEntryChecker,
handleNewEntry: NewEntryHandler,
contentUri: Uri,
projection: Array<String>,
fileMimeType: String? = null,
): Int {
var newEntryCount = 0
): Boolean {
var found = false
val orderBy = "${MediaStore.MediaColumns.DATE_MODIFIED} DESC"
try {
val cursor = context.contentResolver.query(contentUri, projection, null, null, orderBy)
@ -191,11 +190,7 @@ class MediaStoreImageProvider : ImageProvider() {
}
handleNewEntry(entryMap)
// TODO TLAD is this necessary?
if (newEntryCount % 30 == 0) {
delay(10)
}
newEntryCount++
found = true
}
}
}
@ -204,7 +199,7 @@ class MediaStoreImageProvider : ImageProvider() {
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to get entries", e)
}
return newEntryCount
return found
}
private fun needSize(mimeType: String) = MimeTypes.SVG != mimeType

View file

@ -0,0 +1,20 @@
package deckers.thibault.aves.utils
import android.os.Build
// compatibility extension for `removeIf` for API < N
fun <E> MutableList<E>.compatRemoveIf(filter: (t: E) -> Boolean): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
this.removeIf(filter)
} else {
var removed = false
val each = this.iterator()
while (each.hasNext()) {
if (filter(each.next())) {
each.remove()
removed = true
}
}
return removed
}
}

View file

@ -7,6 +7,7 @@ object MimeTypes {
// generic raster
private const val BMP = "image/bmp"
private const val DJVU = "image/vnd.djvu"
const val GIF = "image/gif"
const val HEIC = "image/heic"
private const val HEIF = "image/heif"
@ -35,6 +36,7 @@ object MimeTypes {
private const val VIDEO = "video"
private const val MP2T = "video/mp2t"
private const val MP2TS = "video/mp2ts"
private const val WEBM = "video/webm"
fun isImage(mimeType: String?) = mimeType != null && mimeType.startsWith(IMAGE)
@ -68,7 +70,7 @@ object MimeTypes {
// as of `metadata-extractor` v2.14.0
fun isSupportedByMetadataExtractor(mimeType: String) = when (mimeType) {
WBMP, MP2T, WEBM -> false
DJVU, WBMP, MP2T, MP2TS, WEBM -> false
else -> true
}

View file

@ -5,12 +5,15 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager
import android.util.Log
import androidx.annotation.RequiresApi
import deckers.thibault.aves.utils.StorageUtils.PathSegments
import java.io.File
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import kotlin.collections.ArrayList
object PermissionManager {
private val LOG_TAG = LogUtils.createTag(PermissionManager::class.java)
@ -20,6 +23,7 @@ object PermissionManager {
// permission request code to pending runnable
private val pendingPermissionMap = ConcurrentHashMap<Int, PendingPermissionHandler>()
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
fun requestVolumeAccess(activity: Activity, path: String, onGranted: () -> Unit, onDenied: () -> Unit) {
Log.i(LOG_TAG, "request user to select and grant access permission to volume=$path")
@ -63,13 +67,24 @@ object PermissionManager {
// inaccessible dirs
val segments = PathSegments(context, dirPath)
segments.volumePath?.let { volumePath ->
val dirSet = dirsPerVolume.getOrDefault(volumePath, HashSet())
val dirSet = dirsPerVolume[volumePath] ?: HashSet()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// request primary directory on volume from Android R
segments.relativeDir?.apply {
val primaryDir = split(File.separator).firstOrNull { it.isNotEmpty() }
val relativeDir = segments.relativeDir
if (relativeDir != null) {
val dirSegments = relativeDir.split(File.separator).takeWhile { it.isNotEmpty() }
val primaryDir = dirSegments.firstOrNull()
if (primaryDir == Environment.DIRECTORY_DOWNLOADS && dirSegments.size > 1) {
// request secondary directory (if any) for restricted primary directory
dirSet.add(dirSegments.take(2).joinToString(File.separator))
} else {
primaryDir?.let { dirSet.add(it) }
}
} else {
// the requested path is the volume root itself
// which cannot be granted, due to Android R restrictions
dirSet.add("")
}
} else {
// request volume root until Android Q
dirSet.add("")
@ -80,28 +95,52 @@ object PermissionManager {
}
// format for easier handling on Flutter
val inaccessibleDirs = ArrayList<Map<String, String>>()
val sm = context.getSystemService(StorageManager::class.java)
if (sm != null) {
for ((volumePath, relativeDirs) in dirsPerVolume) {
var volumeDescription: String? = null
try {
volumeDescription = sm.getStorageVolume(File(volumePath))?.getDescription(context)
} catch (e: IllegalArgumentException) {
// ignore
return ArrayList<Map<String, String>>().apply {
addAll(dirsPerVolume.flatMap { (volumePath, relativeDirs) ->
relativeDirs.map { relativeDir ->
hashMapOf(
"volumePath" to volumePath,
"relativeDir" to relativeDir,
)
}
for (relativeDir in relativeDirs) {
val dirMap = HashMap<String, String>()
dirMap["volumePath"] = volumePath
dirMap["volumeDescription"] = volumeDescription ?: ""
dirMap["relativeDir"] = relativeDir
inaccessibleDirs.add(dirMap)
})
}
}
}
return inaccessibleDirs
}
fun getRestrictedDirectories(context: Context): List<Map<String, String>> {
val dirs = ArrayList<Map<String, String>>()
val sdkInt = Build.VERSION.SDK_INT
if (sdkInt >= Build.VERSION_CODES.R) {
// cf https://developer.android.com/about/versions/11/privacy/storage#directory-access
val volumePaths = StorageUtils.getVolumePaths(context)
dirs.addAll(volumePaths.map {
hashMapOf(
"volumePath" to it,
"relativeDir" to "",
)
})
dirs.addAll(volumePaths.map {
hashMapOf(
"volumePath" to it,
"relativeDir" to Environment.DIRECTORY_DOWNLOADS,
)
})
} else if (sdkInt == Build.VERSION_CODES.KITKAT || sdkInt == Build.VERSION_CODES.KITKAT_WATCH) {
// no SD card volume access on KitKat
val primaryVolume = StorageUtils.getPrimaryVolumePath(context)
val nonPrimaryVolumes = StorageUtils.getVolumePaths(context).filter { it != primaryVolume }
dirs.addAll(nonPrimaryVolumes.map {
hashMapOf(
"volumePath" to it,
"relativeDir" to "",
)
})
}
return dirs
}
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
fun revokeDirectoryAccess(context: Context, path: String): Boolean {
return StorageUtils.convertDirPathToTreeUri(context, path)?.let {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION

View file

@ -12,6 +12,7 @@ import android.provider.MediaStore
import android.text.TextUtils
import android.util.Log
import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi
import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath
import java.io.File
@ -148,7 +149,7 @@ object StorageUtils {
return paths.map { ensureTrailingSeparator(it) }.toTypedArray()
}
// return physicalPaths based on phone model
// returns physicalPaths based on phone model
@SuppressLint("SdCardPath")
private val physicalPaths = arrayOf(
"/storage/sdcard0",
@ -177,30 +178,47 @@ object StorageUtils {
* Volume tree URIs
*/
// e.g.
// /storage/emulated/0/ -> primary
// /storage/10F9-3F13/Pictures/ -> 10F9-3F13
private fun getVolumeUuidForTreeUri(context: Context, anyPath: String): String? {
val sm = context.getSystemService(StorageManager::class.java)
if (sm != null) {
val volume = sm.getStorageVolume(File(anyPath))
if (volume != null) {
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"
}
val uuid = volume.uuid
if (uuid != null) {
volume.uuid?.let { uuid ->
return uuid.toUpperCase(Locale.ROOT)
}
}
}
}
// fallback for <N
getVolumePath(context, anyPath)?.let { volumePath ->
if (volumePath == getPrimaryVolumePath(context)) {
return "primary"
}
volumePath.split(File.separator).lastOrNull { it.isNotEmpty() }?.let { uuid ->
return uuid.toUpperCase(Locale.ROOT)
}
}
Log.e(LOG_TAG, "failed to find volume UUID for anyPath=$anyPath")
return null
}
// e.g.
// primary -> /storage/emulated/0/
// 10F9-3F13 -> /storage/10F9-3F13/
private fun getVolumePathFromTreeUriUuid(context: Context, uuid: String): String? {
if (uuid == "primary") {
return getPrimaryVolumePath(context)
}
val sm = context.getSystemService(StorageManager::class.java)
if (sm != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
context.getSystemService(StorageManager::class.java)?.let { sm ->
for (volumePath in getVolumePaths(context)) {
try {
val volume = sm.getStorageVolume(File(volumePath))
@ -212,6 +230,16 @@ object StorageUtils {
}
}
}
}
// fallback for <N
for (volumePath in getVolumePaths(context)) {
val volumeUuid = volumePath.split(File.separator).lastOrNull { it.isNotEmpty() }
if (uuid.equals(volumeUuid, ignoreCase = true)) {
return volumePath
}
}
Log.e(LOG_TAG, "failed to find volume path for UUID=$uuid")
return null
}
@ -219,6 +247,7 @@ object StorageUtils {
// e.g.
// /storage/emulated/0/ -> content://com.android.externalstorage.documents/tree/primary%3A
// /storage/10F9-3F13/Pictures/ -> content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
fun convertDirPathToTreeUri(context: Context, dirPath: String): Uri? {
val uuid = getVolumeUuidForTreeUri(context, dirPath)
if (uuid != null) {
@ -260,7 +289,7 @@ object StorageUtils {
fun getDocumentFile(context: Context, anyPath: String, mediaUri: Uri): DocumentFileCompat? {
try {
if (requireAccessPermission(context, anyPath)) {
if (requireAccessPermission(context, anyPath) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
// need a document URI (not a media content URI) to open a `DocumentFile` output stream
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isMediaStoreContentUri(mediaUri)) {
// cleanest API to get it
@ -284,7 +313,7 @@ object StorageUtils {
// returns null if directory does not exist and could not be created
fun createDirectoryIfAbsent(context: Context, dirPath: String): DocumentFileCompat? {
val cleanDirPath = ensureTrailingSeparator(dirPath)
return if (requireAccessPermission(context, cleanDirPath)) {
return if (requireAccessPermission(context, cleanDirPath) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val grantedDir = getGrantedDirForPath(context, cleanDirPath) ?: return null
val rootTreeUri = convertDirPathToTreeUri(context, grantedDir) ?: return null
var parentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: return null

View file

@ -1,22 +1,26 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.4.21'
ext.kotlin_version = '1.4.30'
repositories {
google()
mavenCentral()
// TODO TLAD remove jcenter (migrating to mavenCentral) when this is fixed: https://youtrack.jetbrains.com/issue/IDEA-261387
jcenter()
}
dependencies {
// TODO TLAD upgrade AGP to 4+ when this lands on stable: https://github.com/flutter/flutter/pull/70808
// TODO TLAD upgrade AGP to 4+ when this lands on stable: https://github.com/flutter/flutter/commit/8dd0de7f580972079f610a56a689b0a9c414f81e
classpath 'com.android.tools.build:gradle:3.6.4'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.gms:google-services:4.3.5'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.4.1'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.5.0'
}
}
allprojects {
repositories {
google()
mavenCentral()
// TODO TLAD remove jcenter (migrating to mavenCentral) when this is fixed: https://youtrack.jetbrains.com/issue/IDEA-261387
jcenter()
}
// gradle.projectsEvaluated {

File diff suppressed because one or more lines are too long

View file

@ -4,7 +4,7 @@ Aves is an open-source gallery and metadata explorer app allowing you to access
You must use the app for legal, authorized and acceptable purposes.
# Disclaimer
This app is released "as-is", without any warranty, responsibility or liability. Use of the app is at your own risk.
This app is released “as-is”, without any warranty, responsibility or liability. Use of the app is at your own risk.
# Privacy policy
Aves does not collect any personal data in its standard use. We never have access to your photos and videos. This also means that we cannot get them back for you if you delete them without backing them up.

Binary file not shown.

Binary file not shown.

103
lib/geo/countries.dart Normal file
View file

@ -0,0 +1,103 @@
import 'dart:async';
import 'package:aves/geo/topojson.dart';
import 'package:country_code/country_code.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:latlong/latlong.dart';
final CountryTopology countryTopology = CountryTopology._private();
class CountryTopology {
static const topoJsonAsset = 'assets/countries-50m.json';
CountryTopology._private();
Topology _topology;
Future<Topology> getTopology() => _topology != null ? SynchronousFuture(_topology) : rootBundle.loadString(topoJsonAsset).then(TopoJson().parse);
// returns the country containing given coordinates
Future<CountryCode> countryCode(LatLng position) async {
return _countryOfNumeric(await numericCode(position));
}
// returns the ISO 3166-1 numeric code of the country containing given coordinates
Future<int> numericCode(LatLng position) async {
final topology = await getTopology();
if (topology == null) return null;
final countries = (topology.objects['countries'] as GeometryCollection).geometries;
return _getNumeric(topology, countries, position);
}
// returns a map of the given positions by country
Future<Map<CountryCode, Set<LatLng>>> countryCodeMap(Set<LatLng> positions) async {
final numericMap = await numericCodeMap(positions);
numericMap.remove(null);
final codeMap = numericMap.map((key, value) {
final code = _countryOfNumeric(key);
return code == null ? null : MapEntry(code, value);
});
codeMap.remove(null);
return codeMap;
}
// returns a map of the given positions by the ISO 3166-1 numeric code of the country containing them
Future<Map<int, Set<LatLng>>> numericCodeMap(Set<LatLng> positions) async {
final topology = await getTopology();
if (topology == null) return null;
return compute(_isoNumericCodeMap, _IsoNumericCodeMapData(topology, positions));
}
static Future<Map<int, Set<LatLng>>> _isoNumericCodeMap(_IsoNumericCodeMapData data) async {
try {
final topology = data.topology;
final countries = (topology.objects['countries'] as GeometryCollection).geometries;
final byCode = <int, Set<LatLng>>{};
for (final position in data.positions) {
final code = _getNumeric(topology, countries, position);
byCode[code] = (byCode[code] ?? {})..add(position);
}
return byCode;
} catch (error, stack) {
// an unhandled error in a spawn isolate would make the app crash
debugPrint('failed to get country codes with error=$error\n$stack');
}
return null;
}
static int _getNumeric(Topology topology, List<Geometry> mruCountries, LatLng position) {
final point = [position.longitude, position.latitude];
final hit = mruCountries.firstWhere((country) => country.containsPoint(topology, point), orElse: () => null);
if (hit == null) return null;
// promote hit countries, assuming given positions are likely to come from the same countries
if (mruCountries.first != hit) {
mruCountries.remove(hit);
mruCountries.insert(0, hit);
}
final idString = (hit.id as String);
final code = idString == null ? null : int.tryParse(idString);
return code;
}
static CountryCode _countryOfNumeric(int numeric) {
if (numeric == null) return null;
try {
return CountryCode.ofNumeric(numeric);
} catch (error) {
debugPrint('failed to find country for numeric=$numeric with error=$error');
}
return null;
}
}
class _IsoNumericCodeMapData {
Topology topology;
Set<LatLng> positions;
_IsoNumericCodeMapData(this.topology, this.positions);
}

View file

@ -21,7 +21,7 @@ String _decimal2sexagesimal(final double degDecimal) {
return '$deg° $min ${roundToPrecision(sec, decimals: 2).toStringAsFixed(2)}';
}
// return coordinates formatted as DMS, e.g. ['41° 24 12.2″ N', '2° 10 26.5″ E']
// returns coordinates formatted as DMS, e.g. ['41° 24 12.2″ N', '2° 10 26.5″ E']
List<String> toDMS(LatLng latLng) {
if (latLng == null) return [];
final lat = latLng.latitude;

245
lib/geo/topojson.dart Normal file
View file

@ -0,0 +1,245 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
// cf https://github.com/topojson/topojson-specification
class TopoJson {
Future<Topology> parse(String data) async {
return compute(_isoParse, data);
}
static Topology _isoParse(String jsonData) {
try {
final data = json.decode(jsonData) as Map<String, dynamic>;
return Topology.parse(data);
} catch (error, stack) {
// an unhandled error in a spawn isolate would make the app crash
debugPrint('failed to parse TopoJSON with error=$error\n$stack');
}
return null;
}
}
enum TopoJsonObjectType { topology, point, multipoint, linestring, multilinestring, polygon, multipolygon, geometrycollection }
TopoJsonObjectType _parseTopoJsonObjectType(String data) {
switch (data) {
case 'Topology':
return TopoJsonObjectType.topology;
case 'Point':
return TopoJsonObjectType.point;
case 'MultiPoint':
return TopoJsonObjectType.multipoint;
case 'LineString':
return TopoJsonObjectType.linestring;
case 'MultiLineString':
return TopoJsonObjectType.multilinestring;
case 'Polygon':
return TopoJsonObjectType.polygon;
case 'MultiPolygon':
return TopoJsonObjectType.multipolygon;
case 'GeometryCollection':
return TopoJsonObjectType.geometrycollection;
}
return null;
}
class TopologyJsonObject {
final List<num> bbox;
TopologyJsonObject.parse(Map<String, dynamic> data) : bbox = data.containsKey('bbox') ? (data['bbox'] as List).cast<num>().toList() : null;
}
class Topology extends TopologyJsonObject {
final Map<String, Geometry> objects;
final List<List<List<num>>> arcs;
final Transform transform;
Topology.parse(Map<String, dynamic> data)
: objects = (data['objects'] as Map).cast<String, dynamic>().map<String, Geometry>((name, geometry) => MapEntry(name, Geometry.build(geometry))),
arcs = (data['arcs'] as List).cast<List>().map((arc) => arc.cast<List>().map((position) => position.cast<num>()).toList()).toList(),
transform = data.containsKey('transform') ? Transform.parse((data['transform'] as Map).cast<String, dynamic>()) : null,
super.parse(data);
List<List<num>> _arcAt(int index) {
var arc = arcs[index < 0 ? ~index : index];
if (transform != null) {
var x = 0, y = 0;
arc = arc.map((quantized) {
final absolute = List.of(quantized);
absolute[0] = (x += quantized[0]) * transform.scale[0] + transform.translate[0];
absolute[1] = (y += quantized[1]) * transform.scale[1] + transform.translate[1];
return absolute;
}).toList();
}
return index < 0 ? arc.reversed.toList() : arc;
}
List<List<num>> _toLine(List<List<List<num>>> arcs) {
return arcs.fold(<List<num>>[], (prev, arc) => [...prev, ...prev.isEmpty ? arc : arc.skip(1)]);
}
List<List<num>> _decodeRingArcs(List<int> ringArcs) {
return _toLine(ringArcs.map(_arcAt).toList());
}
List<List<List<num>>> _decodePolygonArcs(List<List<int>> polyArcs) {
return polyArcs.map(_decodeRingArcs).toList();
}
List<List<List<List<num>>>> _decodeMultiPolygonArcs(List<List<List<int>>> multiPolyArcs) {
return multiPolyArcs.map(_decodePolygonArcs).toList();
}
// cf https://en.wikipedia.org/wiki/Even%E2%80%93odd_rule
bool _pointInRing(List<num> point, List<List<num>> poly) {
final x = point[0];
final y = point[1];
final length = poly.length;
var j = length - 1;
var c = false;
for (var i = 0; i < length; i++) {
if (((poly[i][1] > y) != (poly[j][1] > y)) && (x < poly[i][0] + (poly[j][0] - poly[i][0]) * (y - poly[i][1]) / (poly[j][1] - poly[i][1]))) {
c = !c;
}
j = i;
}
return c;
}
bool _pointInRings(List<num> point, List<List<List<num>>> rings) {
return rings.any((ring) => _pointInRing(point, ring));
}
}
class Transform {
final List<num> scale;
final List<num> translate;
Transform.parse(Map<String, dynamic> data)
: scale = (data['scale'] as List).cast<num>(),
translate = (data['translate'] as List).cast<num>();
}
abstract class Geometry extends TopologyJsonObject {
final dynamic id;
final Map<String, dynamic> properties;
Geometry.parse(Map<String, dynamic> data)
: id = data.containsKey('id') ? data['id'] : null,
properties = data.containsKey('properties') ? data['properties'] as Map<String, dynamic> : null,
super.parse(data);
static Geometry build(Map<String, dynamic> data) {
final type = _parseTopoJsonObjectType(data['type'] as String);
switch (type) {
case TopoJsonObjectType.topology:
return null;
case TopoJsonObjectType.point:
return Point.parse(data);
case TopoJsonObjectType.multipoint:
return MultiPoint.parse(data);
case TopoJsonObjectType.linestring:
return LineString.parse(data);
case TopoJsonObjectType.multilinestring:
return MultiLineString.parse(data);
case TopoJsonObjectType.polygon:
return Polygon.parse(data);
case TopoJsonObjectType.multipolygon:
return MultiPolygon.parse(data);
case TopoJsonObjectType.geometrycollection:
return GeometryCollection.parse(data);
}
return null;
}
bool containsPoint(Topology topology, List<num> point) => false;
}
class Point extends Geometry {
final List<num> coordinates;
Point.parse(Map<String, dynamic> data)
: coordinates = (data['coordinates'] as List).cast<num>(),
super.parse(data);
}
class MultiPoint extends Geometry {
final List<List<num>> coordinates;
MultiPoint.parse(Map<String, dynamic> data)
: coordinates = (data['coordinates'] as List).cast<List>().map((position) => position.cast<num>()).toList(),
super.parse(data);
}
class LineString extends Geometry {
final List<int> arcs;
LineString.parse(Map<String, dynamic> data)
: arcs = (data['arcs'] as List).cast<int>(),
super.parse(data);
}
class MultiLineString extends Geometry {
final List<List<int>> arcs;
MultiLineString.parse(Map<String, dynamic> data)
: arcs = (data['arcs'] as List).cast<List>().map((arc) => arc.cast<int>()).toList(),
super.parse(data);
}
class Polygon extends Geometry {
final List<List<int>> arcs;
Polygon.parse(Map<String, dynamic> data)
: arcs = (data['arcs'] as List).cast<List>().map((arc) => arc.cast<int>()).toList(),
super.parse(data);
List<List<List<num>>> _rings;
List<List<List<num>>> rings(Topology topology) {
_rings ??= topology._decodePolygonArcs(arcs);
return _rings;
}
@override
bool containsPoint(Topology topology, List<num> point) {
return topology._pointInRings(point, rings(topology));
}
}
class MultiPolygon extends Geometry {
final List<List<List<int>>> arcs;
MultiPolygon.parse(Map<String, dynamic> data)
: arcs = (data['arcs'] as List).cast<List>().map((polygon) => polygon.cast<List>().map((arc) => arc.cast<int>()).toList()).toList(),
super.parse(data);
List<List<List<List<num>>>> _polygons;
List<List<List<List<num>>>> polygons(Topology topology) {
_polygons ??= topology._decodeMultiPolygonArcs(arcs);
return _polygons;
}
@override
bool containsPoint(Topology topology, List<num> point) {
return polygons(topology).any((polygon) => topology._pointInRings(point, polygon));
}
}
class GeometryCollection extends Geometry {
final List<Geometry> geometries;
GeometryCollection.parse(Map<String, dynamic> data)
: geometries = (data['geometries'] as List).cast<Map<String, dynamic>>().map(Geometry.build).toList(),
super.parse(data);
@override
bool containsPoint(Topology topology, List<num> point) {
return geometries.any((geometry) => geometry.containsPoint(topology, point));
}
}

View file

@ -9,6 +9,7 @@ import 'package:aves/theme/icons.dart';
import 'package:aves/utils/debouncer.dart';
import 'package:aves/widgets/common/behaviour/route_tracker.dart';
import 'package:aves/widgets/common/behaviour/routes.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:firebase_analytics/firebase_analytics.dart';
@ -74,8 +75,8 @@ class _AvesAppState extends State<AvesApp> {
textTheme: TextTheme(
headline6: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
fontFamily: 'Concourse Caps',
fontWeight: FontWeight.normal,
fontFeatures: [FontFeature.enable('smcp')],
),
),
),
@ -114,6 +115,7 @@ class _AvesAppState extends State<AvesApp> {
value: settings,
child: Provider<CollectionSource>.value(
value: _mediaStoreSource,
child: HighlightInfoProvider(
child: OverlaySupport(
child: FutureBuilder<void>(
future: _appSetup,
@ -135,6 +137,7 @@ class _AvesAppState extends State<AvesApp> {
),
),
),
),
);
}

View file

@ -14,6 +14,9 @@ enum ChipAction {
pin,
unpin,
rename,
goToAlbumPage,
goToCountryPage,
goToTagPage,
}
extension ExtraChipAction on ChipAction {
@ -21,6 +24,12 @@ extension ExtraChipAction on ChipAction {
switch (this) {
case ChipAction.delete:
return 'Delete';
case ChipAction.goToAlbumPage:
return 'Show in Albums';
case ChipAction.goToCountryPage:
return 'Show in Countries';
case ChipAction.goToTagPage:
return 'Show in Tags';
case ChipAction.hide:
return 'Hide';
case ChipAction.pin:
@ -37,6 +46,12 @@ extension ExtraChipAction on ChipAction {
switch (this) {
case ChipAction.delete:
return AIcons.delete;
case ChipAction.goToAlbumPage:
return AIcons.album;
case ChipAction.goToCountryPage:
return AIcons.location;
case ChipAction.goToTagPage:
return AIcons.tag;
case ChipAction.hide:
return AIcons.hide;
case ChipAction.pin:

View file

@ -42,8 +42,8 @@ class AvesAvailability {
return _hasPlayServices;
}
// local geolocation with `geocoder` requires Play Services
Future<bool> get canGeolocate => Future.wait<bool>([isConnected, hasPlayServices]).then((results) => results.every((result) => result));
// local geocoding with `geocoder` requires Play Services
Future<bool> get canLocatePlaces => Future.wait<bool>([isConnected, hasPlayServices]).then((results) => results.every((result) => result));
Future<bool> get isNewVersionAvailable async {
if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable);

View file

@ -1,5 +1,7 @@
import 'dart:async';
import 'package:aves/geo/countries.dart';
import 'package:aves/model/availability.dart';
import 'package:aves/model/entry_cache.dart';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/metadata.dart';
@ -13,6 +15,7 @@ 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';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:geocoder/geocoder.dart';
@ -47,7 +50,7 @@ class AvesEntry {
final Future<List<Address>> Function(Coordinates coordinates) _findAddresses = Geocoder.local.findAddressesFromCoordinates;
// TODO TLAD make it dynamic if it depends on OS/lib versions
static const List<String> undecodable = [MimeTypes.crw, MimeTypes.psd];
static const List<String> undecodable = [MimeTypes.crw, MimeTypes.djvu, MimeTypes.psd];
AvesEntry({
this.uri,
@ -286,6 +289,8 @@ class AvesEntry {
static const ratioSeparator = '\u2236';
static const resolutionSeparator = ' \u00D7 ';
bool get isSized => (width ?? 0) > 0 && (height ?? 0) > 0;
String get resolutionText {
final ws = width ?? '?';
final hs = height ?? '?';
@ -366,9 +371,15 @@ class AvesEntry {
return _durationText;
}
bool get hasGps => isCatalogued && _catalogMetadata.latitude != null;
// returns whether this entry has GPS coordinates
// (0, 0) coordinates are considered invalid, as it is likely a default value
bool get hasGps => _catalogMetadata != null && _catalogMetadata.latitude != null && _catalogMetadata.longitude != null && (_catalogMetadata.latitude != 0 || _catalogMetadata.longitude != 0);
bool get isLocated => _addressDetails != null;
bool get hasAddress => _addressDetails != null;
// has a place, or at least the full country name
// derived from Google reverse geocoding addresses
bool get hasFineAddress => _addressDetails != null && (_addressDetails.place?.isNotEmpty == true || (_addressDetails.countryName?.length ?? 0) > 3);
LatLng get latLng => hasGps ? LatLng(_catalogMetadata.latitude, _catalogMetadata.longitude) : null;
@ -389,7 +400,7 @@ class AvesEntry {
String _bestTitle;
String get bestTitle {
_bestTitle ??= (isCatalogued && _catalogMetadata.xmpTitleDescription?.isNotEmpty == true) ? _catalogMetadata.xmpTitleDescription : sourceTitle;
_bestTitle ??= _catalogMetadata?.xmpTitleDescription?.isNotEmpty == true ? _catalogMetadata.xmpTitleDescription : sourceTitle;
return _bestTitle;
}
@ -444,17 +455,36 @@ class AvesEntry {
addressChangeNotifier.notifyListeners();
}
Future<void> locate({bool background = false}) async {
if (isLocated) return;
Future<void> locate({@required bool background}) async {
if (!hasGps) return;
await _locateCountry();
if (await availability.canLocatePlaces) {
await locatePlace(background: background);
}
}
await catalog(background: background);
final latitude = _catalogMetadata?.latitude;
final longitude = _catalogMetadata?.longitude;
if (latitude == null || longitude == null || (latitude == 0 && longitude == 0)) return;
// quick reverse geocoding to find the country, using an offline asset
Future<void> _locateCountry() async {
if (!hasGps || hasAddress) return;
final countryCode = await countryTopology.countryCode(latLng);
setCountry(countryCode);
}
final coordinates = Coordinates(latitude, longitude);
void setCountry(CountryCode countryCode) {
if (hasFineAddress || countryCode == null) return;
addressDetails = AddressDetails(
contentId: contentId,
countryCode: countryCode.alpha2,
countryName: countryCode.alpha3,
);
}
// full reverse geocoding, requiring Play Services and some connectivity
Future<void> locatePlace({@required bool background}) async {
if (!hasGps || hasFineAddress) return;
final coordinates = latLng;
try {
Future<List<Address>> call() => _findAddresses(coordinates);
Future<List<Address>> call() => _findAddresses(Coordinates(coordinates.latitude, coordinates.longitude));
final addresses = await (background
? servicePolicy.call(
call,
@ -476,38 +506,34 @@ class AvesEntry {
locality: address.locality ?? (cc == null && cn == null && aa == null ? address.addressLine : null),
);
}
} catch (error, stackTrace) {
debugPrint('$runtimeType locate failed with path=$path coordinates=$coordinates error=$error\n$stackTrace');
} catch (error, stack) {
debugPrint('$runtimeType locate failed with path=$path coordinates=$coordinates error=$error\n$stack');
}
}
Future<String> findAddressLine() async {
final latitude = _catalogMetadata?.latitude;
final longitude = _catalogMetadata?.longitude;
if (latitude == null || longitude == null || (latitude == 0 && longitude == 0)) return null;
if (!hasGps) return null;
final coordinates = Coordinates(latitude, longitude);
final coordinates = latLng;
try {
final addresses = await _findAddresses(coordinates);
final addresses = await _findAddresses(Coordinates(coordinates.latitude, coordinates.longitude));
if (addresses != null && addresses.isNotEmpty) {
final address = addresses.first;
return address.addressLine;
}
} catch (error, stackTrace) {
debugPrint('$runtimeType findAddressLine failed with path=$path coordinates=$coordinates error=$error\n$stackTrace');
} catch (error, stack) {
debugPrint('$runtimeType findAddressLine failed with path=$path coordinates=$coordinates error=$error\n$stack');
}
return null;
}
String get shortAddress {
if (!isLocated) return '';
// `admin area` examples: Seoul, Geneva, null
// `locality` examples: Mapo-gu, Geneva, Annecy
return {
_addressDetails.countryName,
_addressDetails.adminArea,
_addressDetails.locality,
_addressDetails?.countryName,
_addressDetails?.adminArea,
_addressDetails?.locality,
}.where((part) => part != null && part.isNotEmpty).join(', ');
}

View file

@ -7,6 +7,7 @@ import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/query.dart';
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:flutter/foundation.dart';
@ -17,6 +18,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
QueryFilter.type,
FavouriteFilter.type,
MimeFilter.type,
TypeFilter.type,
AlbumFilter.type,
LocationFilter.type,
TagFilter.type,
@ -32,6 +34,8 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
return FavouriteFilter();
case LocationFilter.type:
return LocationFilter.fromMap(jsonMap);
case TypeFilter.type:
return TypeFilter.fromMap(jsonMap);
case MimeFilter.type:
return MimeFilter.fromMap(jsonMap);
case QueryFilter.type:

View file

@ -19,7 +19,7 @@ class LocationFilter extends CollectionFilter {
if (split.length > 1) _countryCode = split[1];
if (_location.isEmpty) {
_test = (entry) => !entry.isLocated;
_test = (entry) => !entry.hasGps;
} else if (level == LocationLevel.country) {
_test = (entry) => entry.addressDetails?.countryCode == _countryCode;
} else if (level == LocationLevel.place) {
@ -55,7 +55,13 @@ class LocationFilter extends CollectionFilter {
final flag = countryCodeToFlag(_countryCode);
// as of Flutter v1.22.3, emoji shadows are rendered as colorful duplicates,
// not filled with the shadow color as expected, so we remove them
if (flag != null) return Text(flag, style: TextStyle(fontSize: size, shadows: []));
if (flag != null) {
return Text(
flag,
style: TextStyle(fontSize: size, shadows: []),
textScaleFactor: 1.0,
);
}
return Icon(_location.isEmpty ? AIcons.locationOff : AIcons.location, size: size);
}

View file

@ -7,12 +7,6 @@ import 'package:flutter/widgets.dart';
class MimeFilter extends CollectionFilter {
static const type = 'mime';
// fake mime type
static const animated = 'aves/animated'; // subset of `image/gif` and `image/webp`
static const panorama = 'aves/panorama'; // subset of images
static const sphericalVideo = 'aves/spherical_video'; // subset of videos
static const geotiff = 'aves/geotiff'; // subset of `image/tiff`
final String mime;
EntryFilter _test;
String _label;
@ -20,23 +14,7 @@ class MimeFilter extends CollectionFilter {
MimeFilter(this.mime) {
var lowMime = mime.toLowerCase();
if (mime == animated) {
_test = (entry) => entry.isAnimated;
_label = 'Animated';
_icon = AIcons.animated;
} else if (mime == panorama) {
_test = (entry) => entry.isImage && entry.is360;
_label = 'Panorama';
_icon = AIcons.threesixty;
} else if (mime == sphericalVideo) {
_test = (entry) => entry.isVideo && entry.is360;
_label = '360° Video';
_icon = AIcons.threesixty;
} else if (mime == geotiff) {
_test = (entry) => entry.isGeotiff;
_label = 'GeoTIFF';
_icon = AIcons.geo;
} else if (lowMime.endsWith('/*')) {
if (lowMime.endsWith('/*')) {
lowMime = lowMime.substring(0, lowMime.length - 2);
_test = (entry) => entry.mimeType.startsWith(lowMime);
if (lowMime == 'video') {

View file

@ -0,0 +1,73 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
class TypeFilter extends CollectionFilter {
static const type = 'type';
static const animated = 'animated'; // subset of `image/gif` and `image/webp`
static const geotiff = 'geotiff'; // subset of `image/tiff`
static const panorama = 'panorama'; // subset of images
static const sphericalVideo = 'spherical_video'; // subset of videos
final String itemType;
EntryFilter _test;
String _label;
IconData _icon;
TypeFilter(this.itemType) {
if (itemType == animated) {
_test = (entry) => entry.isAnimated;
_label = 'Animated';
_icon = AIcons.animated;
} else if (itemType == panorama) {
_test = (entry) => entry.isImage && entry.is360;
_label = 'Panorama';
_icon = AIcons.threesixty;
} else if (itemType == sphericalVideo) {
_test = (entry) => entry.isVideo && entry.is360;
_label = '360° Video';
_icon = AIcons.threesixty;
} else if (itemType == geotiff) {
_test = (entry) => entry.isGeotiff;
_label = 'GeoTIFF';
_icon = AIcons.geo;
}
}
TypeFilter.fromMap(Map<String, dynamic> json)
: this(
json['itemType'],
);
@override
Map<String, dynamic> toMap() => {
'type': type,
'itemType': itemType,
};
@override
EntryFilter get test => _test;
@override
String get label => _label;
@override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(_icon, size: size);
@override
String get typeKey => type;
@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

@ -1,24 +1,24 @@
import 'dart:collection';
import 'package:flutter/foundation.dart';
class HighlightInfo extends ChangeNotifier {
final Queue<Object> _items = Queue();
Object _item;
void add(Object item) {
if (_items.contains(item)) return;
_items.addFirst(item);
while (_items.length > 5) {
_items.removeLast();
}
void set(Object item) {
if (_item == item) return;
_item = item;
notifyListeners();
}
void remove(Object item) {
_items.removeWhere((element) => element == item);
Object clear() {
if (_item == null) return null;
final item = _item;
_item = null;
notifyListeners();
return item;
}
bool contains(Object item) => _items.contains(item);
bool contains(Object item) => _item == item;
@override
String toString() => '$runtimeType#${shortHash(this)}{item=$_item}';
}

View file

@ -156,13 +156,14 @@ class OverlayMetadata {
String toString() => '$runtimeType#${shortHash(this)}{aperture=$aperture, exposureTime=$exposureTime, focalLength=$focalLength, iso=$iso}';
}
@immutable
class AddressDetails {
final int contentId;
final String countryCode, countryName, adminArea, locality;
String get place => locality != null && locality.isNotEmpty ? locality : adminArea;
AddressDetails({
const AddressDetails({
this.contentId,
this.countryCode,
this.countryName,

View file

@ -195,8 +195,8 @@ class MetadataDb {
metadataEntries.where((metadata) => metadata != null).forEach((metadata) => _batchInsertMetadata(batch, metadata));
await batch.commit(noResult: true);
debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
} catch (exception, stack) {
debugPrint('$runtimeType failed to save metadata with exception=$exception\n$stack');
} catch (error, stack) {
debugPrint('$runtimeType failed to save metadata with exception=$error\n$stack');
}
}

View file

@ -1,4 +1,4 @@
import 'package:aves/utils/geo_utils.dart';
import 'package:aves/geo/format.dart';
import 'package:latlong/latlong.dart';
enum CoordinateFormat { dms, decimal }

View file

@ -1,4 +1,4 @@
import 'package:screen/screen.dart';
import 'package:aves/services/window_service.dart';
enum KeepScreenOn { never, viewerOnly, always }
@ -17,6 +17,6 @@ extension ExtraKeepScreenOn on KeepScreenOn {
}
void apply() {
Screen.keepOn(this == KeepScreenOn.always);
WindowService.keepScreenOn(this == KeepScreenOn.always);
}
}

View file

@ -92,7 +92,7 @@ class Settings extends ChangeNotifier {
set hasAcceptedTerms(bool newValue) => setAndNotify(hasAcceptedTermsKey, newValue);
bool get isCrashlyticsEnabled => getBoolOrDefault(isCrashlyticsEnabledKey, true);
bool get isCrashlyticsEnabled => getBoolOrDefault(isCrashlyticsEnabledKey, false);
set isCrashlyticsEnabled(bool newValue) {
setAndNotify(isCrashlyticsEnabledKey, newValue);

View file

@ -23,34 +23,36 @@ mixin AlbumMixin on SourceBase {
void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent());
String getUniqueAlbumName(String album) {
final otherAlbums = _directories.where((item) => item != album);
final parts = album.split(separator);
String getUniqueAlbumName(String dirPath) {
String unique(String dirPath, [bool Function(String) test]) {
final otherAlbums = _directories.where(test ?? (_) => true).where((item) => item != dirPath);
final parts = dirPath.split(separator);
var partCount = 0;
String testName;
do {
testName = separator + parts.skip(parts.length - ++partCount).join(separator);
} while (otherAlbums.any((item) => item.endsWith(testName)));
final uniqueName = parts.skip(parts.length - partCount).join(separator);
final volume = androidFileUtils.getStorageVolume(album);
if (volume == null) {
return uniqueName;
}
final volumeRootLength = volume.path.length;
if (album.length < volumeRootLength) {
// `album` is at the root, without trailing '/'
return uniqueName;
}
final dir = VolumeRelativeDirectory.fromPath(dirPath);
if (dir == null) return dirPath;
final albumRelativePath = album.substring(volumeRootLength);
if (uniqueName.length < albumRelativePath.length) {
return uniqueName;
} else if (volume.isPrimary) {
return albumRelativePath;
final uniqueNameInDevice = unique(dirPath);
final relativeDir = dir.relativeDir;
if (relativeDir.isEmpty) return uniqueNameInDevice;
if (uniqueNameInDevice.length < relativeDir.length) {
return uniqueNameInDevice;
} else {
return '$albumRelativePath (${volume.description})';
final uniqueNameInVolume = unique(dirPath, (item) => item.startsWith(dir.volumePath));
final volume = androidFileUtils.getStorageVolume(dirPath);
if (volume.isPrimary) {
return uniqueNameInVolume;
} else {
return '$uniqueNameInVolume (${volume.description})';
}
}
}
@ -119,6 +121,7 @@ mixin AlbumMixin on SourceBase {
directories.forEach(_filterEntryCountMap.remove);
directories.forEach(_filterRecentEntryMap.remove);
}
eventBus.fire(AlbumSummaryInvalidatedEvent(directories));
}
int albumEntryCount(AlbumFilter filter) {
@ -131,3 +134,9 @@ mixin AlbumMixin on SourceBase {
}
class AlbumsChangedEvent {}
class AlbumSummaryInvalidatedEvent {
final Set<String> directories;
const AlbumSummaryInvalidatedEvent(this.directories);
}

View file

@ -45,6 +45,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
_subscriptions.add(source.eventBus.on<EntryAddedEvent>().listen((e) => onEntryAdded(e.entries)));
_subscriptions.add(source.eventBus.on<EntryRemovedEvent>().listen((e) => onEntryRemoved(e.entries)));
_subscriptions.add(source.eventBus.on<EntryMovedEvent>().listen((e) => _refresh()));
_subscriptions.add(source.eventBus.on<FilterVisibilityChangedEvent>().listen((e) => _refresh()));
_subscriptions.add(source.eventBus.on<CatalogMetadataChangedEvent>().listen((e) => _refresh()));
_subscriptions.add(source.eventBus.on<AddressMetadataChangedEvent>().listen((e) {
if (this.filters.any((filter) => filter is LocationFilter)) {

View file

@ -23,6 +23,8 @@ mixin SourceBase {
List<AvesEntry> get sortedEntriesByDate;
ValueNotifier<SourceState> stateNotifier = ValueNotifier(SourceState.ready);
final StreamController<ProgressEvent> _progressStreamController = StreamController.broadcast();
Stream<ProgressEvent> get progressStream => _progressStreamController.stream;
@ -58,8 +60,6 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
return _sortedEntriesByDate;
}
ValueNotifier<SourceState> stateNotifier = ValueNotifier(SourceState.ready);
List<DateMetadata> _savedDates;
Future<void> loadDates() async {
@ -248,6 +248,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
updateLocations();
updateTags();
eventBus.fire(FilterVisibilityChangedEvent(filter, visible));
if (visible) {
refreshMetadata(visibleEntries.where(filter.test).toSet());
}
@ -274,6 +276,13 @@ class EntryMovedEvent {
const EntryMovedEvent(this.entries);
}
class FilterVisibilityChangedEvent {
final CollectionFilter filter;
final bool visible;
const FilterVisibilityChangedEvent(this.filter, this.visible);
}
class ProgressEvent {
final int done, total;

View file

@ -1,5 +1,6 @@
import 'dart:math';
import 'package:aves/geo/countries.dart';
import 'package:aves/model/availability.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/location.dart';
@ -28,10 +29,45 @@ mixin LocationMixin on SourceBase {
}
Future<void> locateEntries() async {
if (!(await availability.canGeolocate)) return;
await _locateCountries();
await _locatePlaces();
}
// final stopwatch = Stopwatch()..start();
final byLocated = groupBy<AvesEntry, bool>(visibleEntries.where((entry) => entry.hasGps), (entry) => entry.isLocated);
// quick reverse geocoding to find the countries, using an offline asset
Future<void> _locateCountries() async {
final todo = visibleEntries.where((entry) => entry.hasGps && !entry.hasAddress).toSet();
if (todo.isEmpty) return;
stateNotifier.value = SourceState.locating;
var progressDone = 0;
final progressTotal = todo.length;
setProgress(done: progressDone, total: progressTotal);
// final stopwatch = Stopwatch()..start();
final countryCodeMap = await countryTopology.countryCodeMap(todo.map((entry) => entry.latLng).toSet());
final newAddresses = <AddressDetails>[];
todo.forEach((entry) {
final position = entry.latLng;
final countryCode = countryCodeMap.entries.firstWhere((kv) => kv.value.contains(position), orElse: () => null)?.key;
entry.setCountry(countryCode);
if (entry.hasAddress) {
newAddresses.add(entry.addressDetails);
}
setProgress(done: ++progressDone, total: progressTotal);
});
if (newAddresses.isNotEmpty) {
await metadataDb.saveAddresses(List.unmodifiable(newAddresses));
onAddressMetadataChanged();
}
// debugPrint('$runtimeType _locateCountries complete in ${stopwatch.elapsed.inMilliseconds}ms');
}
// full reverse geocoding, requiring Play Services and some connectivity
Future<void> _locatePlaces() async {
if (!(await availability.canLocatePlaces)) return;
// final stopwatch = Stopwatch()..start();
final byLocated = groupBy<AvesEntry, bool>(visibleEntries.where((entry) => entry.hasGps), (entry) => entry.hasFineAddress);
final todo = byLocated[false] ?? [];
if (todo.isEmpty) return;
@ -55,6 +91,7 @@ mixin LocationMixin on SourceBase {
final knownLocations = <Tuple2, AddressDetails>{};
byLocated[true]?.forEach((entry) => knownLocations.putIfAbsent(approximateLatLng(entry), () => entry.addressDetails));
stateNotifier.value = SourceState.locating;
var progressDone = 0;
final progressTotal = todo.length;
setProgress(done: progressDone, total: progressTotal);
@ -65,12 +102,12 @@ mixin LocationMixin on SourceBase {
if (knownLocations.containsKey(latLng)) {
entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId);
} else {
await entry.locate(background: true);
await entry.locatePlace(background: true);
// it is intended to insert `null` if the geocoder failed,
// so that we skip geocoding of following entries with the same coordinates
knownLocations[latLng] = entry.addressDetails;
}
if (entry.isLocated) {
if (entry.hasFineAddress) {
newAddresses.add(entry.addressDetails);
if (newAddresses.length >= _commitCountThreshold) {
await metadataDb.saveAddresses(List.unmodifiable(newAddresses));
@ -80,9 +117,11 @@ mixin LocationMixin on SourceBase {
}
setProgress(done: ++progressDone, total: progressTotal);
});
if (newAddresses.isNotEmpty) {
await metadataDb.saveAddresses(List.unmodifiable(newAddresses));
onAddressMetadataChanged();
// debugPrint('$runtimeType locateEntries complete in ${stopwatch.elapsed.inSeconds}s');
}
// debugPrint('$runtimeType _locatePlaces complete in ${stopwatch.elapsed.inSeconds}s');
}
void onAddressMetadataChanged() {
@ -91,17 +130,23 @@ mixin LocationMixin on SourceBase {
}
void updateLocations() {
final locations = visibleEntries.where((entry) => entry.isLocated).map((entry) => entry.addressDetails).toList();
sortedPlaces = List<String>.unmodifiable(locations.map((address) => address.place).where((s) => s != null && s.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase));
final locations = visibleEntries.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails).toList();
final updatedPlaces = locations.map((address) => address.place).where((s) => s != null && s.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase);
if (!listEquals(updatedPlaces, sortedPlaces)) {
sortedPlaces = List.unmodifiable(updatedPlaces);
eventBus.fire(PlacesChangedEvent());
}
// the same country code could be found with different country names
// e.g. if the locale changed between geolocating calls
// so we merge countries by code, keeping only one name for each code
final countriesByCode = Map.fromEntries(locations.map((address) => MapEntry(address.countryCode, address.countryName)).where((kv) => kv.key != null && kv.key.isNotEmpty));
sortedCountries = List<String>.unmodifiable(countriesByCode.entries.map((kv) => '${kv.value}${LocationFilter.locationSeparator}${kv.key}').toList()..sort(compareAsciiUpperCase));
final updatedCountries = countriesByCode.entries.map((kv) => '${kv.value}${LocationFilter.locationSeparator}${kv.key}').toList()..sort(compareAsciiUpperCase);
if (!listEquals(updatedCountries, sortedCountries)) {
sortedCountries = List.unmodifiable(updatedCountries);
invalidateCountryFilterSummary();
eventBus.fire(LocationsChangedEvent());
eventBus.fire(CountriesChangedEvent());
}
}
// filter summary
@ -111,13 +156,16 @@ mixin LocationMixin on SourceBase {
final Map<String, AvesEntry> _filterRecentEntryMap = {};
void invalidateCountryFilterSummary([Set<AvesEntry> entries]) {
Set<String> countryCodes;
if (entries == null) {
_filterEntryCountMap.clear();
_filterRecentEntryMap.clear();
} else {
final countryCodes = entries.where((entry) => entry.isLocated).map((entry) => entry.addressDetails.countryCode).toSet();
countryCodes = entries.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails.countryCode).toSet();
countryCodes.remove(null);
countryCodes.forEach(_filterEntryCountMap.remove);
}
eventBus.fire(CountrySummaryInvalidatedEvent(countryCodes));
}
int countryEntryCount(LocationFilter filter) {
@ -131,4 +179,12 @@ mixin LocationMixin on SourceBase {
class AddressMetadataChangedEvent {}
class LocationsChangedEvent {}
class PlacesChangedEvent {}
class CountriesChangedEvent {}
class CountrySummaryInvalidatedEvent {
final Set<String> countryCodes;
const CountrySummaryInvalidatedEvent(this.countryCodes);
}

View file

@ -8,12 +8,11 @@ import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/media_store_service.dart';
import 'package:aves/services/time_service.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:flutter/material.dart';
import 'package:flutter_native_timezone/flutter_native_timezone.dart';
import 'package:pedantic/pedantic.dart';
class MediaStoreSource extends CollectionSource {
bool _initialized = false;
@ -27,7 +26,7 @@ class MediaStoreSource extends CollectionSource {
stateNotifier.value = SourceState.loading;
await metadataDb.init();
await favourites.init();
final currentTimeZone = await FlutterNativeTimezone.getLocalTimezone();
final currentTimeZone = await TimeService.getDefaultTimeZone();
final catalogTimeZone = settings.catalogTimeZone;
if (currentTimeZone != catalogTimeZone) {
// clear catalog metadata to get correct date/times when moving to a different time zone
@ -103,25 +102,25 @@ class MediaStoreSource extends CollectionSource {
updateDirectories();
}
final analytics = FirebaseAnalytics();
unawaited(analytics.setUserProperty(name: 'local_item_count', value: (ceilBy(allEntries.length, 3)).toString()));
unawaited(analytics.setUserProperty(name: 'album_count', value: (ceilBy(rawAlbums.length, 1)).toString()));
stateNotifier.value = SourceState.cataloguing;
await catalogEntries();
unawaited(analytics.setUserProperty(name: 'tag_count', value: (ceilBy(sortedTags.length, 1)).toString()));
stateNotifier.value = SourceState.locating;
await locateEntries();
unawaited(analytics.setUserProperty(name: 'country_count', value: (ceilBy(sortedCountries.length, 1)).toString()));
stateNotifier.value = SourceState.ready;
_reportCollectionDimensions();
debugPrint('$runtimeType refresh done, elapsed=${stopwatch.elapsed}');
},
onError: (error) => debugPrint('$runtimeType stream error=$error'),
);
}
void _reportCollectionDimensions() {
final analytics = FirebaseAnalytics();
analytics.setUserProperty(name: 'local_item_count', value: (ceilBy(allEntries.length, 3)).toString());
analytics.setUserProperty(name: 'album_count', value: (ceilBy(rawAlbums.length, 1)).toString());
analytics.setUserProperty(name: 'tag_count', value: (ceilBy(sortedTags.length, 1)).toString());
analytics.setUserProperty(name: 'country_count', value: (ceilBy(sortedCountries.length, 1)).toString());
}
// returns URIs to retry later. They could be URIs that are:
// 1) currently being processed during bulk move/deletion
// 2) registered in the Media Store but still being processed by their owner in a temporary location
@ -132,7 +131,10 @@ class MediaStoreSource extends CollectionSource {
final uriByContentId = Map.fromEntries(changedUris.map((uri) {
if (uri == null) return null;
final idString = Uri.parse(uri).pathSegments.last;
final pathSegments = Uri.parse(uri).pathSegments;
// e.g. URI `content://media/` has no path segment
if (pathSegments.isEmpty) return null;
final idString = pathSegments.last;
final contentId = int.tryParse(idString);
if (contentId == null) return null;
return MapEntry(contentId, uri);
@ -175,13 +177,8 @@ class MediaStoreSource extends CollectionSource {
addEntries(newEntries);
await metadataDb.saveEntries(newEntries);
cleanEmptyAlbums(existingDirectories);
stateNotifier.value = SourceState.cataloguing;
await catalogEntries();
stateNotifier.value = SourceState.locating;
await locateEntries();
stateNotifier.value = SourceState.ready;
}

View file

@ -27,6 +27,7 @@ mixin TagMixin on SourceBase {
final todo = visibleEntries.where((entry) => !entry.isCatalogued).toList();
if (todo.isEmpty) return;
stateNotifier.value = SourceState.cataloguing;
var progressDone = 0;
final progressTotal = todo.length;
setProgress(done: progressDone, total: progressTotal);
@ -55,12 +56,13 @@ mixin TagMixin on SourceBase {
}
void updateTags() {
final tags = visibleEntries.expand((entry) => entry.xmpSubjects).toSet().toList()..sort(compareAsciiUpperCase);
sortedTags = List.unmodifiable(tags);
final updatedTags = visibleEntries.expand((entry) => entry.xmpSubjects).toSet().toList()..sort(compareAsciiUpperCase);
if (!listEquals(updatedTags, sortedTags)) {
sortedTags = List.unmodifiable(updatedTags);
invalidateTagFilterSummary();
eventBus.fire(TagsChangedEvent());
}
}
// filter summary
@ -69,13 +71,15 @@ mixin TagMixin on SourceBase {
final Map<String, AvesEntry> _filterRecentEntryMap = {};
void invalidateTagFilterSummary([Set<AvesEntry> entries]) {
Set<String> tags;
if (entries == null) {
_filterEntryCountMap.clear();
_filterRecentEntryMap.clear();
} else {
final tags = entries.where((entry) => entry.isCatalogued).expand((entry) => entry.xmpSubjects).toSet();
tags = entries.where((entry) => entry.isCatalogued).expand((entry) => entry.xmpSubjects).toSet();
tags.forEach(_filterEntryCountMap.remove);
}
eventBus.fire(TagSummaryInvalidatedEvent(tags));
}
int tagEntryCount(TagFilter filter) {
@ -90,3 +94,9 @@ mixin TagMixin on SourceBase {
class CatalogMetadataChangedEvent {}
class TagsChangedEvent {}
class TagSummaryInvalidatedEvent {
final Set<String> tags;
const TagSummaryInvalidatedEvent(this.tags);
}

View file

@ -18,6 +18,7 @@ class MimeTypes {
static const cr2 = 'image/x-canon-cr2';
static const crw = 'image/x-canon-crw';
static const dcr = 'image/x-kodak-dcr';
static const djvu = 'image/vnd.djvu';
static const dng = 'image/x-adobe-dng';
static const erf = 'image/x-epson-erf';
static const k25 = 'image/x-kodak-k25';

View file

@ -28,7 +28,7 @@ class AndroidDebugService {
static Future<Map> getBitmapFactoryInfo(AvesEntry entry) async {
try {
// return map with all data available when decoding image bounds with `BitmapFactory`
// returns map with all data available when decoding image bounds with `BitmapFactory`
final result = await platform.invokeMethod('getBitmapFactoryInfo', <String, dynamic>{
'uri': entry.uri,
}) as Map;
@ -41,7 +41,7 @@ class AndroidDebugService {
static Future<Map> getContentResolverMetadata(AvesEntry entry) async {
try {
// return map with all data available from the content resolver
// returns map with all data available from the content resolver
final result = await platform.invokeMethod('getContentResolverMetadata', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
@ -55,7 +55,7 @@ class AndroidDebugService {
static Future<Map> getExifInterfaceMetadata(AvesEntry entry) async {
try {
// return map with all data available from the `ExifInterface` library
// returns map with all data available from the `ExifInterface` library
final result = await platform.invokeMethod('getExifInterfaceMetadata', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
@ -70,7 +70,7 @@ class AndroidDebugService {
static Future<Map> getMediaMetadataRetrieverMetadata(AvesEntry entry) async {
try {
// return map with all data available from `MediaMetadataRetriever`
// returns map with all data available from `MediaMetadataRetriever`
final result = await platform.invokeMethod('getMediaMetadataRetrieverMetadata', <String, dynamic>{
'uri': entry.uri,
}) as Map;
@ -83,7 +83,7 @@ class AndroidDebugService {
static Future<Map> getMetadataExtractorSummary(AvesEntry entry) async {
try {
// return map with the mime type and tag count for each directory found by `metadata-extractor`
// returns map with the mime type and tag count for each directory found by `metadata-extractor`
final result = await platform.invokeMethod('getMetadataExtractorSummary', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,

View file

@ -52,20 +52,28 @@ class AndroidFileService {
return;
}
// returns a list of directories,
// each directory is a map with "volumePath", "volumeDescription", "relativeDir"
static Future<List<Map>> getInaccessibleDirectories(Iterable<String> dirPaths) async {
static Future<Set<VolumeRelativeDirectory>> getInaccessibleDirectories(Iterable<String> dirPaths) async {
try {
final result = await platform.invokeMethod('getInaccessibleDirectories', <String, dynamic>{
'dirPaths': dirPaths.toList(),
});
return (result as List).cast<Map>();
return (result as List).cast<Map>().map((map) => VolumeRelativeDirectory.fromMap(map)).toSet();
} on PlatformException catch (e) {
debugPrint('getInaccessibleDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
}
return null;
}
static Future<Set<VolumeRelativeDirectory>> getRestrictedDirectories() async {
try {
final result = await platform.invokeMethod('getRestrictedDirectories');
return (result as List).cast<Map>().map((map) => VolumeRelativeDirectory.fromMap(map)).toSet();
} on PlatformException catch (e) {
debugPrint('getRestrictedDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
}
return null;
}
// returns whether user granted access to volume root at `volumePath`
static Future<bool> requestVolumeAccess(String volumePath) async {
try {
@ -87,7 +95,7 @@ class AndroidFileService {
return false;
}
// return media URI
// returns media URI
static Future<Uri> scanFile(String path, String mimeType) async {
debugPrint('scanFile with path=$path, mimeType=$mimeType');
try {

View file

@ -86,8 +86,8 @@ class ImageFileService {
bytesReceived += chunk.length;
try {
onBytesReceived(bytesReceived, expectedContentLength);
} catch (error, stackTrace) {
completer.completeError(error, stackTrace);
} catch (error, stack) {
completer.completeError(error, stack);
return;
}
}
@ -248,7 +248,7 @@ class ImageFileService {
static Future<Map> rename(AvesEntry entry, String newName) async {
try {
// return map with: 'contentId' 'path' 'title' 'uri' (all optional)
// returns map with: 'contentId' 'path' 'title' 'uri' (all optional)
final result = await platform.invokeMethod('rename', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
'newName': newName,
@ -262,7 +262,7 @@ class ImageFileService {
static Future<Map> rotate(AvesEntry entry, {@required bool clockwise}) async {
try {
// return map with: 'rotationDegrees' 'isFlipped'
// returns map with: 'rotationDegrees' 'isFlipped'
final result = await platform.invokeMethod('rotate', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
'clockwise': clockwise,
@ -276,7 +276,7 @@ class ImageFileService {
static Future<Map> flip(AvesEntry entry) async {
try {
// return map with: 'rotationDegrees' 'isFlipped'
// returns map with: 'rotationDegrees' 'isFlipped'
final result = await platform.invokeMethod('flip', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
}) as Map;

View file

@ -11,7 +11,7 @@ import 'package:flutter/services.dart';
class MetadataService {
static const platform = MethodChannel('deckers.thibault/aves/metadata');
// return Map<Map<Key, Value>> (map of directories, each directory being a map of metadata label and value description)
// returns Map<Map<Key, Value>> (map of directories, each directory being a map of metadata label and value description)
static Future<Map> getAllMetadata(AvesEntry entry) async {
if (entry.isSvg) return null;
@ -33,7 +33,7 @@ class MetadataService {
Future<CatalogMetadata> call() async {
try {
// return map with:
// returns map with:
// 'mimeType': MIME type as reported by metadata extractors, not Media Store (string)
// 'dateMillis': date taken in milliseconds since Epoch (long)
// 'isAnimated': animated gif/webp (bool)
@ -69,7 +69,7 @@ class MetadataService {
if (entry.isSvg) return null;
try {
// return map with values for: 'aperture' (double), 'exposureTime' (description), 'focalLength' (double), 'iso' (int)
// returns map with values for: 'aperture' (double), 'exposureTime' (description), 'focalLength' (double), 'iso' (int)
final result = await platform.invokeMethod('getOverlayMetadata', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
@ -98,7 +98,7 @@ class MetadataService {
static Future<PanoramaInfo> getPanoramaInfo(AvesEntry entry) async {
try {
// return map with values for:
// returns map with values for:
// 'croppedAreaLeft' (int), 'croppedAreaTop' (int), 'croppedAreaWidth' (int), 'croppedAreaHeight' (int),
// 'fullPanoWidth' (int), 'fullPanoHeight' (int)
final result = await platform.invokeMethod('getPanoramaInfo', <String, dynamic>{

View file

@ -38,8 +38,8 @@ class ServicePolicy {
() async {
try {
completer.complete(await platformCall());
} catch (error, stackTrace) {
completer.completeError(error, stackTrace);
} catch (error, stack) {
completer.completeError(error, stack);
}
_runningQueue.remove(key);
_pickNext();

View file

@ -42,8 +42,8 @@ class SvgMetadataService {
}
}
}
} catch (exception, stack) {
debugPrint('failed to parse XML from SVG with exception=$exception\n$stack');
} catch (error, stack) {
debugPrint('failed to parse XML from SVG with error=$error\n$stack');
}
return null;
}
@ -78,8 +78,8 @@ class SvgMetadataService {
if (docDir.isNotEmpty) docDirectory: docDir,
if (metadataDir.isNotEmpty) metadataDirectory: metadataDir,
};
} catch (exception, stack) {
debugPrint('failed to parse XML from SVG with exception=$exception\n$stack');
} catch (error, stack) {
debugPrint('failed to parse XML from SVG with error=$error\n$stack');
return null;
}
}

View file

@ -0,0 +1,15 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class TimeService {
static const platform = MethodChannel('deckers.thibault/aves/time');
static Future<String> getDefaultTimeZone() async {
try {
return await platform.invokeMethod('getDefaultTimeZone');
} on PlatformException catch (e) {
debugPrint('getDefaultTimeZone failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return null;
}
}

View file

@ -6,7 +6,7 @@ class ViewerService {
static Future<Map> getIntentData() async {
try {
// return nullable map with 'action' and possibly 'uri' 'mimeType'
// returns nullable map with 'action' and possibly 'uri' 'mimeType'
return await platform.invokeMethod('getIntentData') as Map;
} on PlatformException catch (e) {
debugPrint('getIntentData failed with code=${e.code}, exception=${e.message}, details=${e.details}');

View file

@ -0,0 +1,16 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class WindowService {
static const platform = MethodChannel('deckers.thibault/aves/window');
static Future<void> keepScreenOn(bool on) async {
try {
await platform.invokeMethod('keepScreenOn', <String, dynamic>{
'on': on,
});
} on PlatformException catch (e) {
debugPrint('keepScreenOn failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
}
}

View file

@ -17,6 +17,8 @@ class Durations {
// filter grids animations
static const chipDecorationAnimation = Duration(milliseconds: 200);
static const highlightScrollAnimationMinMillis = 400;
static const highlightScrollAnimationMaxMillis = 2000;
// collection animations
static const filterBarRemovalAnimation = Duration(milliseconds: 400);
@ -44,6 +46,7 @@ class Durations {
static const opToastDisplay = Duration(seconds: 2);
static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100);
static const collectionScalingCompleteNotificationDelay = Duration(milliseconds: 300);
static const highlightScrollInitDelay = Duration(milliseconds: 800);
static const videoProgressTimerInterval = Duration(milliseconds: 300);
static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation;
static const doubleBackTimerDelay = Duration(milliseconds: 1000);

View file

@ -2,6 +2,7 @@ import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/android_file_service.dart';
import 'package:aves/utils/change_notifier.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:path/path.dart';
final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
@ -112,13 +113,13 @@ class Package {
String toString() => '$runtimeType#${shortHash(this)}{packageName=$packageName, categoryLauncher=$categoryLauncher, isSystem=$isSystem, currentLabel=$currentLabel, englishLabel=$englishLabel, ownedDirs=$ownedDirs}';
}
@immutable
class StorageVolume {
final String description, path, state;
final bool isEmulated, isPrimary, isRemovable;
final bool isPrimary, isRemovable;
const StorageVolume({
this.description,
this.isEmulated,
this.isPrimary,
this.isRemovable,
this.path,
@ -126,13 +127,59 @@ class StorageVolume {
});
factory StorageVolume.fromMap(Map map) {
final isPrimary = map['isPrimary'] ?? false;
return StorageVolume(
description: map['description'] ?? '',
isEmulated: map['isEmulated'] ?? false,
isPrimary: map['isPrimary'] ?? false,
description: map['description'] ?? (isPrimary ? 'Internal storage' : 'SD card'),
isPrimary: isPrimary,
isRemovable: map['isRemovable'] ?? false,
path: map['path'] ?? '',
state: map['state'] ?? '',
);
}
}
@immutable
class VolumeRelativeDirectory {
final String volumePath, relativeDir;
const VolumeRelativeDirectory({
this.volumePath,
this.relativeDir,
});
factory VolumeRelativeDirectory.fromMap(Map map) {
return VolumeRelativeDirectory(
volumePath: map['volumePath'],
relativeDir: map['relativeDir'] ?? '',
);
}
// prefer static method over a null returning factory constructor
static VolumeRelativeDirectory fromPath(String dirPath) {
final volume = androidFileUtils.getStorageVolume(dirPath);
if (volume == null) return null;
final root = volume.path;
final rootLength = root.length;
return VolumeRelativeDirectory(
volumePath: root,
relativeDir: dirPath.length < rootLength ? '' : dirPath.substring(rootLength),
);
}
String get directoryDescription => relativeDir.isEmpty ? 'root' : '$relativeDir';
String get volumeDescription {
final volume = androidFileUtils.storageVolumes.firstWhere((volume) => volume.path == volumePath, orElse: () => null);
return volume?.description ?? 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

@ -18,8 +18,8 @@ class AChangeNotifier implements Listenable {
for (final listener in localListeners) {
try {
if (_listeners.contains(listener)) listener();
} catch (exception, stack) {
debugPrint('$runtimeType failed to notify listeners with exception=$exception\n$stack');
} catch (error, stack) {
debugPrint('$runtimeType failed to notify listeners with error=$error\n$stack');
}
}
}

View file

@ -1,3 +1,5 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/painting.dart';
import 'package:latlong/latlong.dart';
@ -8,9 +10,9 @@ class Constants {
static const overflowStrutStyle = StrutStyle(height: 1.3);
static const titleTextStyle = TextStyle(
color: Color(0xFFEEEEEE),
fontSize: 20,
fontFamily: 'Concourse Caps',
fontWeight: FontWeight.w300,
fontFeatures: [FontFeature.enable('smcp')],
);
static const embossShadow = Shadow(
@ -89,6 +91,12 @@ class Constants {
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/connectivity/connectivity/LICENSE',
sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/connectivity/connectivity',
),
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: 'Decorated Icon',
license: 'MIT',
@ -143,12 +151,6 @@ class Constants {
licenseUrl: 'https://github.com/flutter/flutter_markdown/blob/master/LICENSE',
sourceUrl: 'https://github.com/flutter/flutter_markdown',
),
Dependency(
name: 'Flutter Native Timezone',
license: 'Apache 2.0',
licenseUrl: 'https://github.com/pinkfish/flutter_native_timezone/blob/master/LICENSE',
sourceUrl: 'https://github.com/pinkfish/flutter_native_timezone',
),
Dependency(
name: 'Flutter Staggered Animations',
license: 'MIT',
@ -257,12 +259,6 @@ class Constants {
licenseUrl: 'https://github.com/rrousselGit/provider/blob/master/LICENSE',
sourceUrl: 'https://github.com/rrousselGit/provider',
),
Dependency(
name: 'Screen',
license: 'MIT',
licenseUrl: 'https://github.com/clovisnicolas/flutter_screen/blob/master/LICENSE',
sourceUrl: 'https://github.com/clovisnicolas/flutter_screen',
),
Dependency(
name: 'Shared Preferences',
license: 'BSD 3-Clause',

View file

@ -3,6 +3,8 @@ class MimeUtils {
switch (mime) {
case 'image/x-icon':
return 'ICO';
case 'image/x-jg':
return 'ART';
case 'image/vnd.adobe.photoshop':
case 'image/x-photoshop':
return 'PSD';

View file

@ -1,3 +1,5 @@
import 'dart:ui';
import 'package:aves/flutter_version.dart';
import 'package:aves/widgets/common/basic/link_chip.dart';
import 'package:aves/widgets/common/identity/aves_logo.dart';
@ -32,15 +34,19 @@ class _AppReferenceState extends State<AppReference> {
}
Widget _buildAvesLine() {
final textTheme = Theme.of(context).textTheme;
final style = textTheme.headline6.copyWith(fontWeight: FontWeight.bold);
final style = TextStyle(
fontSize: 20,
fontWeight: FontWeight.normal,
letterSpacing: 1.0,
fontFeatures: [FontFeature.enable('smcp')],
);
return FutureBuilder<PackageInfo>(
future: _packageInfoLoader,
builder: (context, snapshot) {
return LinkChip(
leading: AvesLogo(
size: style.fontSize * 1.25,
size: style.fontSize * MediaQuery.textScaleFactorOf(context) * 1.25,
),
text: 'Aves ${snapshot.data?.version}',
url: 'https://github.com/deckerst/aves',

View file

@ -1,3 +1,6 @@
import 'dart:ui';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/basic/link_chip.dart';
import 'package:flutter/material.dart';
@ -13,25 +16,22 @@ class AboutCredits extends StatelessWidget {
constraints: BoxConstraints(minHeight: 48),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
'Credits',
style: Theme.of(context).textTheme.headline6.copyWith(fontFamily: 'Concourse Caps'),
),
child: Text('Credits', style: Constants.titleTextStyle),
),
),
Text.rich(
TextSpan(
children: [
TextSpan(text: 'This app uses the font '),
TextSpan(text: 'This app uses a TopoJSON file from'),
WidgetSpan(
child: LinkChip(
text: 'Concourse',
url: 'https://mbtype.com/fonts/concourse/',
text: 'World Atlas',
url: 'https://github.com/topojson/world-atlas',
textStyle: TextStyle(fontWeight: FontWeight.bold),
),
alignment: PlaceholderAlignment.middle,
),
TextSpan(text: ' for titles and the media information page.'),
TextSpan(text: 'under ISC License.'),
],
),
),

View file

@ -94,10 +94,7 @@ class _LicensesState extends State<Licenses> {
child: Row(
children: [
Expanded(
child: Text(
'Open-Source Licenses',
style: Theme.of(context).textTheme.headline6.copyWith(fontFamily: 'Concourse Caps'),
),
child: Text('Open-Source Licenses', style: Constants.titleTextStyle),
),
PopupMenuButton<LicenseSort>(
itemBuilder: (context) => [

View file

@ -1,4 +1,5 @@
import 'package:aves/model/availability.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/about/news_badge.dart';
import 'package:aves/widgets/common/basic/link_chip.dart';
import 'package:flutter/material.dart';
@ -45,10 +46,7 @@ class _AboutNewVersionState extends State<AboutNewVersion> {
),
alignment: PlaceholderAlignment.middle,
),
TextSpan(
text: 'New Version Available',
style: Theme.of(context).textTheme.headline6.copyWith(fontFamily: 'Concourse Caps'),
),
TextSpan(text: 'New Version Available', style: Constants.titleTextStyle),
],
),
),

View file

@ -48,9 +48,7 @@ class _CollectionPageState extends State<CollectionPage> {
),
),
),
drawer: AppDrawer(
source: collection.source,
),
drawer: AppDrawer(),
resizeToAvoidBottomInset: false,
),
),

View file

@ -6,7 +6,7 @@ class EmptyContent extends StatelessWidget {
final AlignmentGeometry alignment;
const EmptyContent({
@required this.icon,
this.icon,
@required this.text,
this.alignment = const FractionalOffset(.5, .35),
});
@ -19,18 +19,19 @@ class EmptyContent extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(
icon,
size: 64,
color: color,
),
SizedBox(height: 16),
SizedBox(height: 16)
],
Text(
text,
style: TextStyle(
color: color,
fontSize: 22,
fontFamily: 'Concourse',
),
),
],

View file

@ -7,8 +7,10 @@ import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/android_file_service.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/image_op_events.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
import 'package:aves/widgets/common/action_mixins/size_aware.dart';
@ -63,6 +65,20 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
}
Future<void> _moveSelection(BuildContext context, {@required MoveType moveType}) async {
final selectionDirs = selection.where((e) => e.path != null).map((e) => e.directory).toSet();
if (moveType == MoveType.move) {
// check whether moving is possible given OS restrictions,
// before asking to pick a destination album
final restrictedDirs = await AndroidFileService.getRestrictedDirectories();
for (final selectionDir in selectionDirs) {
final dir = VolumeRelativeDirectory.fromPath(selectionDir);
if (restrictedDirs.contains(dir)) {
await showRestrictedDirectoryDialog(context, dir);
return;
}
}
}
final destinationAlbum = await Navigator.push(
context,
MaterialPageRoute<String>(
@ -73,7 +89,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
if (destinationAlbum == null || destinationAlbum.isEmpty) return;
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;
if (!await checkStoragePermission(context, selection)) return;
if (moveType == MoveType.move && !await checkStoragePermissionForAlbums(context, selectionDirs)) return;
if (!await checkFreeSpaceForMove(context, selection, destinationAlbum, moveType)) return;

View file

@ -155,7 +155,7 @@ class _ThumbnailHighlightOverlayState extends State<ThumbnailHighlightOverlay> {
toggledNotifier: _highlightedNotifier,
startAngle: pi * -3 / 4,
centerSweep: false,
onSweepEnd: () => highlightInfo.remove(entry),
onSweepEnd: highlightInfo.clear,
);
}
}

View file

@ -24,7 +24,6 @@ import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/grid/section_layout.dart';
import 'package:aves/widgets/common/grid/sliver.dart';
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
import 'package:aves/widgets/common/providers/highlight_info_provider.dart';
import 'package:aves/widgets/common/scaling.dart';
import 'package:aves/widgets/common/tile_extent_manager.dart';
import 'package:flutter/material.dart';
@ -44,8 +43,7 @@ class ThumbnailCollection extends StatelessWidget {
@override
Widget build(BuildContext context) {
return HighlightInfoProvider(
child: SafeArea(
return SafeArea(
bottom: false,
child: LayoutBuilder(
builder: (context, constraints) {
@ -109,7 +107,7 @@ class ThumbnailCollection extends StatelessWidget {
final sectionedListLayout = context.read<SectionedListLayout<AvesEntry>>();
return sectionedListLayout.getTileRect(entry) ?? Rect.zero;
},
onScaled: (entry) => context.read<HighlightInfo>().add(entry),
onScaled: (entry) => context.read<HighlightInfo>().set(entry),
child: scrollView,
);
@ -143,7 +141,6 @@ class ThumbnailCollection extends StatelessWidget {
);
},
),
),
);
}
}

View file

@ -87,10 +87,7 @@ class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerPr
Future<void> onComplete() => _animationController.reverse().then((_) => widget.onDone(processed));
opStream.listen(
processed.add,
onError: (error) {
debugPrint('_showOpReport error=$error');
onComplete();
},
onError: (error) => debugPrint('_showOpReport error=$error'),
onDone: onComplete,
);
}

View file

@ -1,5 +1,6 @@
import 'package:aves/model/entry.dart';
import 'package:aves/services/android_file_service.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:flutter/material.dart';
@ -9,24 +10,26 @@ mixin PermissionAwareMixin {
}
Future<bool> checkStoragePermissionForAlbums(BuildContext context, Set<String> albumPaths) async {
final restrictedDirs = await AndroidFileService.getRestrictedDirectories();
while (true) {
final dirs = await AndroidFileService.getInaccessibleDirectories(albumPaths);
if (dirs == null) return false;
if (dirs.isEmpty) return true;
final dir = dirs.first;
final volumePath = dir['volumePath'] as String;
final volumeDescription = dir['volumeDescription'] as String;
final relativeDir = dir['relativeDir'] as String;
final dirDisplayName = relativeDir.isEmpty ? 'root' : '$relativeDir';
final restrictedInaccessibleDir = dirs.firstWhere(restrictedDirs.contains, orElse: () => null);
if (restrictedInaccessibleDir != null) {
await showRestrictedDirectoryDialog(context, restrictedInaccessibleDir);
return false;
}
final dir = dirs.first;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AvesDialog(
context: context,
title: 'Storage Volume Access',
content: Text('Please select the $dirDisplayName directory of “$volumeDescription” in the next screen, so that this app can access it and complete your request.'),
content: Text('Please select the ${dir.directoryDescription} directory of “${dir.volumeDescription}” in the next screen, so that this app can access it and complete your request.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
@ -43,11 +46,30 @@ mixin PermissionAwareMixin {
// abort if the user cancels in Flutter
if (confirmed == null || !confirmed) return false;
final granted = await AndroidFileService.requestVolumeAccess(volumePath);
final granted = await AndroidFileService.requestVolumeAccess(dir.volumePath);
if (!granted) {
// abort if the user denies access from the native dialog
return false;
}
}
}
Future<bool> showRestrictedDirectoryDialog(BuildContext context, VolumeRelativeDirectory dir) {
return showDialog<bool>(
context: context,
builder: (context) {
return AvesDialog(
context: context,
title: 'Restricted Access',
content: Text('This app is not allowed to modify files in the ${dir.directoryDescription} directory of “${dir.volumeDescription}”.\n\nPlease use a pre-installed file manager or gallery app to move the items to another directory.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('OK'.toUpperCase()),
),
],
);
},
);
}
}

View file

@ -7,23 +7,16 @@ import 'package:provider/provider.dart';
// - a vertically scrollable body.
// It will prevent the body from scrolling when a user swipe from bottom to use Android Q style navigation gestures.
class BottomGestureAreaProtector extends StatelessWidget {
// as of Flutter v1.22.5, `systemGestureInsets` from `MediaQuery` mistakenly reports no bottom inset,
// so we use an empirical measurement instead
static const double systemGestureInsetsBottom = 32;
@override
Widget build(BuildContext context) {
return Selector<MediaQueryData, double>(
selector: (c, mq) => mq.effectiveBottomPadding,
builder: (c, mqPaddingBottom, child) {
// devices with physical navigation buttons have no bottom insets
// we assume these devices do not use gesture navigation
if (mqPaddingBottom == 0) return SizedBox();
selector: (c, mq) => mq.systemGestureInsets.bottom,
builder: (c, systemGestureBottom, child) {
return Positioned(
left: 0,
right: 0,
bottom: 0,
height: systemGestureInsetsBottom,
height: systemGestureBottom,
child: AbsorbPointer(),
);
},

View file

@ -40,7 +40,14 @@ class LinkChip extends StatelessWidget {
leading,
SizedBox(width: 8),
],
Text(text),
Flexible(
child: Text(
text,
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
),
),
SizedBox(width: 8),
Builder(
builder: (context) => Icon(

View file

@ -15,17 +15,19 @@ class MenuRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
final iconSize = IconTheme.of(context).size * textScaleFactor;
return Row(
children: [
if (checked != null) ...[
Opacity(
opacity: checked ? 1 : 0,
child: Icon(AIcons.checked),
child: Icon(AIcons.checked, size: iconSize),
),
SizedBox(width: 8),
],
if (icon != null) ...[
Icon(icon),
Icon(icon, size: iconSize),
SizedBox(width: 8),
],
Expanded(child: Text(text)),

View file

@ -26,7 +26,7 @@ abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ProxyProvider0<SectionedListLayout<T>>(
update: (context, __) => _updateLayouts(context),
update: (context, _) => _updateLayouts(context),
child: child,
);
}

View file

@ -1,10 +1,19 @@
import 'package:aves/main.dart';
import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/basic/menu_row.dart';
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
typedef FilterCallback = void Function(CollectionFilter filter);
typedef OffsetFilterCallback = void Function(CollectionFilter filter, Offset tapPosition);
typedef OffsetFilterCallback = void Function(BuildContext context, CollectionFilter filter, Offset tapPosition);
enum HeroType { always, onTap, never }
@ -26,7 +35,6 @@ class AvesFilterChip extends StatefulWidget {
static const double minChipHeight = kMinInteractiveDimension;
static const double minChipWidth = 80;
static const double maxChipWidth = 160;
static const double iconSize = 20;
const AvesFilterChip({
Key key,
@ -39,10 +47,43 @@ class AvesFilterChip extends StatefulWidget {
this.padding = 6.0,
this.heroType = HeroType.onTap,
this.onTap,
this.onLongPress,
this.onLongPress = showDefaultLongPressMenu,
}) : assert(filter != null),
super(key: key);
static Future<void> showDefaultLongPressMenu(BuildContext context, CollectionFilter filter, Offset tapPosition) async {
if (AvesApp.mode == AppMode.main) {
final actions = [
if (filter is AlbumFilter) ChipAction.goToAlbumPage,
if ((filter is LocationFilter && filter.level == LocationLevel.country)) ChipAction.goToCountryPage,
if (filter is TagFilter) ChipAction.goToTagPage,
ChipAction.hide,
];
// remove focus, if any, to prevent the keyboard from showing up
// after the user is done with the popup menu
FocusManager.instance.primaryFocus?.unfocus();
final RenderBox overlay = Overlay.of(context).context.findRenderObject();
final touchArea = Size(40, 40);
// TODO TLAD check menu is within safe area, when this lands on stable: https://github.com/flutter/flutter/commit/cfc8ec23b633da1001359e384435e8333c9d3733
final selectedAction = await showMenu<ChipAction>(
context: context,
position: RelativeRect.fromRect(tapPosition & touchArea, Offset.zero & overlay.size),
items: actions
.map((action) => PopupMenuItem(
value: action,
child: MenuRow(text: action.getText(), icon: action.getIcon()),
))
.toList(),
);
if (selectedAction != null) {
// wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => ChipActionDelegate().onActionSelected(context, filter, selectedAction));
}
}
}
@override
_AvesFilterChipState createState() => _AvesFilterChipState();
}
@ -88,7 +129,8 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
@override
Widget build(BuildContext context) {
const iconSize = AvesFilterChip.iconSize;
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
final iconSize = 20 * textScaleFactor;
final hasBackground = widget.background != null;
final leading = filter.iconBuilder(context, iconSize, showGenericIcon: widget.showGenericIcon, embossed: hasBackground);
@ -178,7 +220,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
setState(() => _tapped = true);
}
: null,
onLongPress: widget.onLongPress != null ? () => widget.onLongPress(filter, _tapPosition) : null,
onLongPress: widget.onLongPress != null ? () => widget.onLongPress(context, filter, _tapPosition) : null,
borderRadius: borderRadius,
child: FutureBuilder<Color>(
future: _colorFuture,

View file

@ -7,6 +7,7 @@ import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/constants.dart';
import 'package:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class VideoIcon extends StatelessWidget {
final AvesEntry entry;
@ -172,10 +173,26 @@ class IconUtils {
static Widget getAlbumIcon({
@required BuildContext context,
@required String album,
double size = 24,
double size,
bool embossed = false,
}) {
Widget buildIcon(IconData icon) => embossed ? DecoratedIcon(icon, shadows: [Constants.embossShadow], size: size) : Icon(icon, size: size);
size ??= IconTheme.of(context).size;
Widget buildIcon(IconData icon) => embossed
? MediaQuery(
// `DecoratedIcon` internally uses `Text`,
// which size depends on the ambient `textScaleFactor`
// but we already accommodate for it upstream
data: context.read<MediaQueryData>().copyWith(textScaleFactor: 1.0),
child: DecoratedIcon(
icon,
shadows: [Constants.embossShadow],
size: size,
),
)
: Icon(
icon,
size: size,
);
switch (androidFileUtils.getAlbumType(album)) {
case AlbumType.camera:
return buildIcon(AIcons.cameraAlbum);

View file

@ -1,3 +1,5 @@
import 'dart:ui';
import 'package:aves/utils/color_utils.dart';
import 'package:aves/widgets/common/fx/highlight_decoration.dart';
import 'package:flutter/material.dart';
@ -29,7 +31,8 @@ class HighlightTitle extends StatelessWidget {
)
],
fontSize: fontSize,
fontFamily: 'Concourse Caps',
letterSpacing: 1.0,
fontFeatures: [FontFeature.enable('smcp')],
);
return Align(

View file

@ -14,20 +14,17 @@ import 'package:aves/widgets/debug/storage.dart';
import 'package:aves/widgets/viewer/info/common.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
class AppDebugPage extends StatefulWidget {
static const routeName = '/debug';
final CollectionSource source;
const AppDebugPage({this.source});
@override
State<StatefulWidget> createState() => _AppDebugPageState();
}
class _AppDebugPageState extends State<AppDebugPage> {
CollectionSource get source => widget.source;
CollectionSource get source => context.read<CollectionSource>();
Set<AvesEntry> get visibleEntries => source.visibleEntries;
@ -63,7 +60,8 @@ class _AppDebugPageState extends State<AppDebugPage> {
Widget _buildGeneralTabView() {
final catalogued = visibleEntries.where((entry) => entry.isCatalogued);
final withGps = catalogued.where((entry) => entry.hasGps);
final located = withGps.where((entry) => entry.isLocated);
final withAddress = withGps.where((entry) => entry.hasAddress);
final withFineAddress = withGps.where((entry) => entry.hasFineAddress);
return AvesExpansionTile(
title: 'General',
children: [
@ -104,7 +102,8 @@ class _AppDebugPageState extends State<AppDebugPage> {
'Visible entries': '${visibleEntries.length}',
'Catalogued': '${catalogued.length}',
'With GPS': '${withGps.length}',
'With address': '${located.length}',
'With address': '${withAddress.length}',
'With fine address': '${withFineAddress.length}',
},
),
),

View file

@ -40,7 +40,6 @@ class _DebugStorageSectionState extends State<DebugStorageSection> with Automati
padding: EdgeInsets.symmetric(horizontal: 8),
child: InfoRowGroup({
'description': '${v.description}',
'isEmulated': '${v.isEmulated}',
'isPrimary': '${v.isPrimary}',
'isRemovable': '${v.isRemovable}',
'state': '${v.state}',

View file

@ -1,3 +1,5 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
@ -76,8 +78,8 @@ class DialogTitle extends StatelessWidget {
child: Text(
title,
style: TextStyle(
fontWeight: FontWeight.bold,
fontFamily: 'Concourse Caps',
fontWeight: FontWeight.normal,
fontFeatures: [FontFeature.enable('smcp')],
),
),
);

View file

@ -2,6 +2,7 @@ import 'dart:io';
import 'package:aves/theme/durations.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:path/path.dart';
@ -40,39 +41,29 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
@override
Widget build(BuildContext context) {
final volumeTiles = <Widget>[];
if (_allVolumes.length > 1) {
final byPrimary = groupBy<StorageVolume, bool>(_allVolumes, (volume) => volume.isPrimary);
int compare(StorageVolume a, StorageVolume b) => compareAsciiUpperCase(a.path, b.path);
final primaryVolumes = byPrimary[true]..sort(compare);
final otherVolumes = byPrimary[false]..sort(compare);
volumeTiles.addAll([
Padding(
padding: AvesDialog.contentHorizontalPadding + EdgeInsets.only(top: 20),
child: Text('Storage:'),
),
...primaryVolumes.map(_buildVolumeTile),
...otherVolumes.map(_buildVolumeTile),
SizedBox(height: 8),
]);
}
return AvesDialog(
context: context,
title: 'New Album',
scrollController: _scrollController,
scrollableContent: [
if (_allVolumes.length > 1) ...[
Padding(
padding: AvesDialog.contentHorizontalPadding + EdgeInsets.only(top: 20),
child: Text('Storage:'),
),
..._allVolumes.map((volume) => RadioListTile<StorageVolume>(
value: volume,
groupValue: _selectedVolume,
onChanged: (volume) {
_selectedVolume = volume;
_validate();
setState(() {});
},
title: Text(
volume.description,
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
),
subtitle: Text(
volume.path,
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
),
)),
SizedBox(height: 8),
],
...volumeTiles,
Padding(
padding: AvesDialog.contentHorizontalPadding + EdgeInsets.only(bottom: 8),
child: ValueListenableBuilder<bool>(
@ -83,7 +74,7 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
focusNode: _nameFieldFocusNode,
decoration: InputDecoration(
labelText: 'Album name',
helperText: exists ? 'Album already exists' : '',
helperText: exists ? 'Directory already exists' : '',
),
autofocus: _allVolumes.length == 1,
onChanged: (_) => _validate(),
@ -110,6 +101,28 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
);
}
Widget _buildVolumeTile(StorageVolume volume) => RadioListTile<StorageVolume>(
value: volume,
groupValue: _selectedVolume,
onChanged: (volume) {
_selectedVolume = volume;
_validate();
setState(() {});
},
title: Text(
volume.description,
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
),
subtitle: Text(
volume.path,
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
),
);
void _onFocus() async {
// when the field gets focus, we wait for the soft keyboard to appear
// then scroll to the bottom to make sure the field is in view

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