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] ## [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 ## [v1.3.4] - 2021-02-10
### Added ### Added
- hide album / country / tag from collection - 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…) - search and filter by country, place, XMP tag, type (animated, raster, vector…)
- favorites - favorites
- statistics - statistics
- support Android API 24 ~ 30 (Nougat ~ R) - support Android API 19 ~ 30 (KitKat ~ R)
- Android integration (app shortcuts, handle view/pick intents) - Android integration (app shortcuts, handle view/pick intents)
## Known Issues ## Known Issues

View file

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

View file

@ -14,10 +14,6 @@
https://developer.android.com/preview/privacy/storage#media-file-access https://developer.android.com/preview/privacy/storage#media-file-access
- raw path access: - raw path access:
https://developer.android.com/preview/privacy/storage#media-files-raw-paths 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" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
@ -31,9 +27,7 @@
<!-- to access media with unredacted metadata with scoped storage (Android Q+) --> <!-- to access media with unredacted metadata with scoped storage (Android Q+) -->
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<!-- TODO TLAD remove this permission once this issue is fixed: <!-- TODO TLAD remove this permission when this is fixed: https://github.com/flutter/flutter/issues/42451 -->
https://github.com/flutter/flutter/issues/42451
-->
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- from Android R, we should define <queries> to make other apps visible to this app --> <!-- 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:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name" android:label="@string/app_name"
android:requestLegacyExternalStorage="true"> android:requestLegacyExternalStorage="true">
<!-- TODO TLAD Android 12 https://developer.android.com/about/versions/12/behavior-changes-12#exported -->
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" 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, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
MethodChannel(messenger, AppShortcutHandler.CHANNEL).setMethodCallHandler(AppShortcutHandler(this)) MethodChannel(messenger, AppShortcutHandler.CHANNEL).setMethodCallHandler(AppShortcutHandler(this))
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this)) MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this)) MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(MetadataHandler(this)) MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(MetadataHandler(this))
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this)) MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
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, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) }
StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageOpStreamHandler(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.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.content.res.Configuration import android.content.res.Configuration
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
@ -89,7 +88,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
resources.updateConfiguration(englishConfig, resources.displayMetrics) resources.updateConfiguration(englishConfig, resources.displayMetrics)
englishLabel = resources.getString(labelRes) 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) Log.w(LOG_TAG, "failed to get app label in English for packageName=$packageName", e)
} }
englishLabel 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) Log.w(LOG_TAG, "failed to decode app icon for packageName=$packageName", e)
} }
Glide.with(context).clear(target) 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) Log.w(LOG_TAG, "failed to get app info for packageName=$packageName", e)
return return
} }

View file

@ -50,14 +50,23 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
} }
private fun getContextDirs() = hashMapOf( private fun getContextDirs() = hashMapOf(
"dataDir" to context.dataDir,
"cacheDir" to context.cacheDir, "cacheDir" to context.cacheDir,
"codeCacheDir" to context.codeCacheDir,
"filesDir" to context.filesDir, "filesDir" to context.filesDir,
"noBackupFilesDir" to context.noBackupFilesDir,
"obbDir" to context.obbDir, "obbDir" to context.obbDir,
"externalCacheDir" to context.externalCacheDir, "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) { private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } 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, pageId = pageId,
sampleSize = sampleSize, sampleSize = sampleSize,
regionRect = regionRect, regionRect = regionRect,
imageSize = Size(imageWidth, imageHeight), imageWidth = imageWidth,
imageHeight = imageHeight,
result = result, 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 // optional parent to distinguish child directories of the same type
dir.parent?.name?.let { dirName = "$it/$dirName" } dir.parent?.name?.let { dirName = "$it/$dirName" }
val dirMap = metadataMap.getOrDefault(dirName, HashMap()) val dirMap = metadataMap[dirName] ?: HashMap()
metadataMap[dirName] = dirMap metadataMap[dirName] = dirMap
// tags // tags
@ -325,7 +325,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)) { if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)) {
val count = xmpMeta.countArrayItems(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 } 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 } 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)) { if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) {
@ -350,7 +350,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
// XMP fallback to IPTC // XMP fallback to IPTC
if (!metadataMap.containsKey(KEY_XMP_SUBJECTS)) { if (!metadataMap.containsKey(KEY_XMP_SUBJECTS)) {
for (dir in metadata.getDirectoriesOfType(IptcDirectory::class.java)) { 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, KEY_MIME_TYPE to trackMime,
) )
format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { page[KEY_IS_DEFAULT] = it != 0 } format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { page[KEY_IS_DEFAULT] = it != 0 }
format.getSafeInt(MediaFormat.KEY_TRACK_ID) { page[KEY_TRACK_ID] = it } 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_WIDTH) { page[KEY_WIDTH] = it }
format.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it } format.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it }
if (isVideo(trackMime)) { if (isVideo(trackMime)) {
@ -677,26 +679,35 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
val projection = arrayOf(prop) val projection = arrayOf(prop)
val cursor = context.contentResolver.query(contentUri, projection, null, null, null) val cursor: Cursor?
if (cursor != null && cursor.moveToFirst()) { try {
var value: Any? = null cursor = context.contentResolver.query(contentUri, projection, null, null, null)
try { } catch (e: Exception) {
value = when (cursor.getType(0)) { // throws SQLiteException when the requested prop is not a known column
Cursor.FIELD_TYPE_NULL -> null result.error("getContentResolverProp-query", "failed to query for contentUri=$contentUri", e.message)
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(0) return
Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(0)
Cursor.FIELD_TYPE_STRING -> cursor.getString(0)
Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(0)
else -> null
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get value for key=$prop", e)
}
cursor.close()
result.success(value?.toString())
} else {
result.error("getContentResolverProp-null", "failed to get cursor for contentUri=$contentUri", null)
} }
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)) {
Cursor.FIELD_TYPE_NULL -> null
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(0)
Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(0)
Cursor.FIELD_TYPE_STRING -> cursor.getString(0)
Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(0)
else -> null
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get value for key=$prop", e)
}
cursor.close()
result.success(value?.toString())
} }
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) { private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {

View file

@ -4,9 +4,12 @@ import android.content.Context
import android.media.MediaScannerConnection import android.media.MediaScannerConnection
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager import android.os.storage.StorageManager
import androidx.core.os.EnvironmentCompat
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.utils.PermissionManager import deckers.thibault.aves.utils.PermissionManager
import deckers.thibault.aves.utils.StorageUtils.getPrimaryVolumePath
import deckers.thibault.aves.utils.StorageUtils.getVolumePaths import deckers.thibault.aves.utils.StorageUtils.getVolumePaths
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
@ -24,6 +27,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
"getFreeSpace" -> safe(call, result, ::getFreeSpace) "getFreeSpace" -> safe(call, result, ::getFreeSpace)
"getGrantedDirectories" -> safe(call, result, ::getGrantedDirectories) "getGrantedDirectories" -> safe(call, result, ::getGrantedDirectories)
"getInaccessibleDirectories" -> safe(call, result, ::getInaccessibleDirectories) "getInaccessibleDirectories" -> safe(call, result, ::getInaccessibleDirectories)
"getRestrictedDirectories" -> safe(call, result, ::getRestrictedDirectories)
"revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess) "revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess)
"scanFile" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::scanFile) } "scanFile" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::scanFile) }
else -> result.notImplemented() 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) { 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>>()
val volumes = ArrayList<Map<String, Any>>() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val sm = context.getSystemService(StorageManager::class.java) val sm = context.getSystemService(StorageManager::class.java)
if (sm != null) { if (sm != null) {
for (volumePath in getVolumePaths(context)) { for (volumePath in getVolumePaths(context)) {
try { try {
sm.getStorageVolume(File(volumePath))?.let { sm.getStorageVolume(File(volumePath))?.let {
val volumeMap = HashMap<String, Any>() volumes.add(
volumeMap["path"] = volumePath hashMapOf(
volumeMap["description"] = it.getDescription(context) "path" to volumePath,
volumeMap["isPrimary"] = it.isPrimary "description" to it.getDescription(context),
volumeMap["isRemovable"] = it.isRemovable "isPrimary" to it.isPrimary,
volumeMap["isEmulated"] = it.isEmulated "isRemovable" to it.isRemovable,
volumeMap["state"] = it.state "state" to it.state,
volumes.add(volumeMap) )
)
} }
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
// ignore // ignore
} }
} }
} }
volumes
} else { } else {
// TODO TLAD find alternative for Android <N val primaryVolumePath = getPrimaryVolumePath(context)
emptyList() 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) result.success(volumes)
} }
@ -67,21 +92,9 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
return 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, // `StorageStatsManager` `getFreeBytes()` is only available from API 26,
// and non-primary volume UUIDs cannot be used with it // and non-primary volume UUIDs cannot be used with it
val file = File(path)
try { try {
result.success(file.freeSpace) result.success(file.freeSpace)
} catch (e: SecurityException) { } catch (e: SecurityException) {
@ -100,8 +113,11 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
return return
} }
val dirs = PermissionManager.getInaccessibleDirectories(context, dirPaths) result.success(PermissionManager.getInaccessibleDirectories(context, dirPaths))
result.success(dirs) }
private fun getRestrictedDirectories(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
result.success(PermissionManager.getRestrictedDirectories(context))
} }
private fun revokeDirectoryAccess(call: MethodCall, result: MethodChannel.Result) { private fun revokeDirectoryAccess(call: MethodCall, result: MethodChannel.Result) {
@ -111,6 +127,11 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
return 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) val success = PermissionManager.revokeDirectoryAccess(context, path)
result.success(success) 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.BitmapRegionDecoder
import android.graphics.Rect import android.graphics.Rect
import android.net.Uri import android.net.Uri
import android.util.Size
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.DiskCacheStrategy
@ -37,7 +36,8 @@ class RegionFetcher internal constructor(
pageId: Int?, pageId: Int?,
sampleSize: Int, sampleSize: Int,
regionRect: Rect, regionRect: Rect,
imageSize: Size, imageWidth: Int,
imageHeight: Int,
result: MethodChannel.Result, result: MethodChannel.Result,
) { ) {
if (MimeTypes.isHeifLike(mimeType) && pageId != null) { if (MimeTypes.isHeifLike(mimeType) && pageId != null) {
@ -48,7 +48,8 @@ class RegionFetcher internal constructor(
pageId = null, pageId = null,
sampleSize = sampleSize, sampleSize = sampleSize,
regionRect = regionRect, regionRect = regionRect,
imageSize = imageSize, imageWidth = imageWidth,
imageHeight = imageHeight,
result = result, result = result,
) )
return return
@ -79,9 +80,9 @@ class RegionFetcher internal constructor(
// with raw images, the known image size may not match the decoded image size // with raw images, the known image size may not match the decoded image size
// so we scale the requested region accordingly // so we scale the requested region accordingly
val effectiveRect = if (imageSize.width != decoder.width || imageSize.height != decoder.height) { val effectiveRect = if (imageWidth != decoder.width || imageHeight != decoder.height) {
val xf = decoder.width.toDouble() / imageSize.width val xf = decoder.width.toDouble() / imageWidth
val yf = decoder.height.toDouble() / imageSize.height val yf = decoder.height.toDouble() / imageHeight
Rect( Rect(
(regionRect.left * xf).roundToInt(), (regionRect.left * xf).roundToInt(),
(regionRect.top * yf).roundToInt(), (regionRect.top * yf).roundToInt(),

View file

@ -47,7 +47,6 @@ class ThumbnailFetcher internal constructor(
fun fetch() { fun fetch() {
var bitmap: Bitmap? = null var bitmap: Bitmap? = null
var recycle = true
var exception: Exception? = null var exception: Exception? = null
try { try {
@ -66,14 +65,13 @@ class ThumbnailFetcher internal constructor(
if (bitmap == null) { if (bitmap == null) {
try { try {
bitmap = getByGlide() bitmap = getByGlide()
recycle = false
} catch (e: Exception) { } catch (e: Exception) {
exception = e exception = e
} }
} }
if (bitmap != null) { if (bitmap != null) {
result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = recycle)) result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false))
} else { } else {
var errorDetails: String? = exception?.message var errorDetails: String? = exception?.message
if (errorDetails?.isNotEmpty() == true) { 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) } MediaStoreImageProvider().fetchAll(context, knownEntries ?: emptyMap()) { success(it) }
endOfStream() endOfStream()
} }

View file

@ -1,6 +1,7 @@
package deckers.thibault.aves.channel.streams package deckers.thibault.aves.channel.streams
import android.app.Activity import android.app.Activity
import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
@ -26,6 +27,16 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
this.eventSink = eventSink this.eventSink = eventSink
handler = Handler(Looper.getMainLooper()) 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) }) requestVolumeAccess(activity, path!!, { success(true) }, { success(false) })
} }
@ -42,6 +53,17 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
endOfStream() 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() { private fun endOfStream() {
handler.post { handler.post {
try { try {

View file

@ -9,6 +9,7 @@ import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.ImageHeaderParser import com.bumptech.glide.load.ImageHeaderParser
import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser
import com.bumptech.glide.module.AppGlideModule import com.bumptech.glide.module.AppGlideModule
import deckers.thibault.aves.utils.compatRemoveIf
@GlideModule @GlideModule
class AvesAppGlideModule : AppGlideModule() { class AvesAppGlideModule : AppGlideModule() {
@ -20,7 +21,7 @@ class AvesAppGlideModule : AppGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) { override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
// prevent ExifInterface error logs // prevent ExifInterface error logs
// cf https://github.com/bumptech/glide/issues/3383 // 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 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.OlympusImageProcessingMakernoteDirectory
import com.drew.metadata.exif.makernotes.OlympusMakernoteDirectory import com.drew.metadata.exif.makernotes.OlympusMakernoteDirectory
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.* import java.util.*
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.floor import kotlin.math.floor
@ -16,6 +18,7 @@ import kotlin.math.roundToLong
object ExifInterfaceHelper { object ExifInterfaceHelper {
private val LOG_TAG = LogUtils.createTag(ExifInterfaceHelper::class.java) 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 private const val precisionErrorTolerance = 1e-10
@ -358,11 +361,15 @@ object ExifInterfaceHelper {
fun ExifInterface.getSafeDateMillis(tag: String, save: (value: Long) -> Unit) { fun ExifInterface.getSafeDateMillis(tag: String, save: (value: Long) -> Unit) {
if (this.hasAttribute(tag)) { if (this.hasAttribute(tag)) {
// TODO TLAD parse date with "yyyy:MM:dd HH:mm:ss" or find the original long val dateString = this.getAttribute(tag)
val formattedDate = this.getAttribute(tag) if (dateString != null) {
val value = formattedDate?.toLongOrNull() try {
if (value != null && value > 0) { DATETIME_FORMAT.parse(dateString)?.let { date ->
save(value) 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_ARTIST to "Artist",
MediaMetadataRetriever.METADATA_KEY_AUTHOR to "Author", MediaMetadataRetriever.METADATA_KEY_AUTHOR to "Author",
MediaMetadataRetriever.METADATA_KEY_BITRATE to "Bitrate", 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_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_COMPILATION to "Compilation",
MediaMetadataRetriever.METADATA_KEY_COMPOSER to "Composer", MediaMetadataRetriever.METADATA_KEY_COMPOSER to "Composer",
MediaMetadataRetriever.METADATA_KEY_DATE to "Date", MediaMetadataRetriever.METADATA_KEY_DATE to "Date",
@ -38,6 +34,9 @@ object MediaMetadataRetrieverHelper {
MediaMetadataRetriever.METADATA_KEY_WRITER to "Writer", MediaMetadataRetriever.METADATA_KEY_WRITER to "Writer",
MediaMetadataRetriever.METADATA_KEY_YEAR to "Year", MediaMetadataRetriever.METADATA_KEY_YEAR to "Year",
).apply { ).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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
putAll( putAll(
hashMapOf( 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") } 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), // 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. // 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. // 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 // 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 // to a temporary file, and reusing that preview file for all metadata reading purposes

View file

@ -12,10 +12,6 @@ object MetadataExtractorHelper {
// extensions // 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) { fun Directory.getSafeString(tag: Int, save: (value: String) -> Unit) {
if (this.containsTag(tag)) save(this.getString(tag)) 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 // `xmlBytes`: bytes representing the XML embedded in a MP4 `uuid` box, according to Spherical Video V1 spec
class GSpherical(xmlBytes: ByteArray) { class GSpherical(xmlBytes: ByteArray) {
var spherical: Boolean = false private var spherical: Boolean = false
var stitched: Boolean = false private var stitched: Boolean = false
var stitchingSoftware: String = "" private var stitchingSoftware: String = ""
var projectionType: String = "" private var projectionType: String = ""
var stereoMode: String? = null private var stereoMode: String? = null
var sourceCount: Int? = null private var sourceCount: Int? = null
var initialViewHeadingDegrees: Int? = null private var initialViewHeadingDegrees: Int? = null
var initialViewPitchDegrees: Int? = null private var initialViewPitchDegrees: Int? = null
var initialViewRollDegrees: Int? = null private var initialViewRollDegrees: Int? = null
var timestamp: Int? = null private var timestamp: Int? = null
var fullPanoWidthPixels: Int? = null private var fullPanoWidthPixels: Int? = null
var fullPanoHeightPixels: Int? = null private var fullPanoHeightPixels: Int? = null
var croppedAreaImageWidthPixels: Int? = null private var croppedAreaImageWidthPixels: Int? = null
var croppedAreaImageHeightPixels: Int? = null private var croppedAreaImageHeightPixels: Int? = null
var croppedAreaLeftPixels: Int? = null private var croppedAreaLeftPixels: Int? = null
var croppedAreaTopPixels: Int? = null private var croppedAreaTopPixels: Int? = null
init { init {
try { try {

View file

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

View file

@ -1,7 +1,6 @@
package deckers.thibault.aves.model package deckers.thibault.aves.model
import android.net.Uri import android.net.Uri
import deckers.thibault.aves.model.FieldMap
class AvesEntry(map: FieldMap) { class AvesEntry(map: FieldMap) {
val uri: Uri = Uri.parse(map["uri"] as String) // content or file URI 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 import deckers.thibault.aves.model.SourceEntry
internal class ContentImageProvider : ImageProvider() { 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) { if (mimeType == null) {
callback.onFailure(Exception("MIME type is null for uri=$uri")) callback.onFailure(Exception("MIME type is null for uri=$uri"))
return return

View file

@ -6,7 +6,7 @@ import deckers.thibault.aves.model.SourceEntry
import java.io.File import java.io.File
internal class FileImageProvider : ImageProvider() { 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) { if (mimeType == null) {
callback.onFailure(Exception("MIME type is null for uri=$uri")) callback.onFailure(Exception("MIME type is null for uri=$uri"))
return return

View file

@ -35,7 +35,7 @@ import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
abstract class ImageProvider { 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()) 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.getDocumentFile
import deckers.thibault.aves.utils.StorageUtils.requireAccessPermission import deckers.thibault.aves.utils.StorageUtils.requireAccessPermission
import deckers.thibault.aves.utils.UriUtils.tryParseId import deckers.thibault.aves.utils.UriUtils.tryParseId
import kotlinx.coroutines.delay
import java.io.File import java.io.File
import java.util.* import java.util.*
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
class MediaStoreImageProvider : ImageProvider() { 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 isModified = fun(contentId: Int, dateModifiedSecs: Int): Boolean {
val knownDate = knownEntries[contentId] val knownDate = knownEntries[contentId]
return knownDate == null || knownDate < dateModifiedSecs return knownDate == null || knownDate < dateModifiedSecs
@ -35,7 +34,7 @@ class MediaStoreImageProvider : ImageProvider() {
fetchFrom(context, isModified, handleNewEntry, VIDEO_CONTENT_URI, VIDEO_PROJECTION) 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 id = uri.tryParseId()
val onSuccess = fun(entry: FieldMap) { val onSuccess = fun(entry: FieldMap) {
entry["uri"] = uri.toString() entry["uri"] = uri.toString()
@ -45,17 +44,17 @@ class MediaStoreImageProvider : ImageProvider() {
if (id != null) { if (id != null) {
if (mimeType == null || isImage(mimeType)) { if (mimeType == null || isImage(mimeType)) {
val contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, id) 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)) { if (mimeType == null || isVideo(mimeType)) {
val contentUri = ContentUris.withAppendedId(VIDEO_CONTENT_URI, id) 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") // 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 // without an equivalent image/video if it is shared from a file browser
// but the file is not publicly visible // 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")) callback.onFailure(Exception("failed to fetch entry at uri=$uri"))
} }
@ -109,15 +108,15 @@ class MediaStoreImageProvider : ImageProvider() {
return obsoleteIds return obsoleteIds
} }
private suspend fun fetchFrom( private fun fetchFrom(
context: Context, context: Context,
isValidEntry: NewEntryChecker, isValidEntry: NewEntryChecker,
handleNewEntry: NewEntryHandler, handleNewEntry: NewEntryHandler,
contentUri: Uri, contentUri: Uri,
projection: Array<String>, projection: Array<String>,
fileMimeType: String? = null, fileMimeType: String? = null,
): Int { ): Boolean {
var newEntryCount = 0 var found = false
val orderBy = "${MediaStore.MediaColumns.DATE_MODIFIED} DESC" val orderBy = "${MediaStore.MediaColumns.DATE_MODIFIED} DESC"
try { try {
val cursor = context.contentResolver.query(contentUri, projection, null, null, orderBy) val cursor = context.contentResolver.query(contentUri, projection, null, null, orderBy)
@ -191,11 +190,7 @@ class MediaStoreImageProvider : ImageProvider() {
} }
handleNewEntry(entryMap) handleNewEntry(entryMap)
// TODO TLAD is this necessary? found = true
if (newEntryCount % 30 == 0) {
delay(10)
}
newEntryCount++
} }
} }
} }
@ -204,7 +199,7 @@ class MediaStoreImageProvider : ImageProvider() {
} catch (e: Exception) { } catch (e: Exception) {
Log.e(LOG_TAG, "failed to get entries", e) Log.e(LOG_TAG, "failed to get entries", e)
} }
return newEntryCount return found
} }
private fun needSize(mimeType: String) = MimeTypes.SVG != mimeType 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 // generic raster
private const val BMP = "image/bmp" private const val BMP = "image/bmp"
private const val DJVU = "image/vnd.djvu"
const val GIF = "image/gif" const val GIF = "image/gif"
const val HEIC = "image/heic" const val HEIC = "image/heic"
private const val HEIF = "image/heif" private const val HEIF = "image/heif"
@ -35,6 +36,7 @@ object MimeTypes {
private const val VIDEO = "video" private const val VIDEO = "video"
private const val MP2T = "video/mp2t" private const val MP2T = "video/mp2t"
private const val MP2TS = "video/mp2ts"
private const val WEBM = "video/webm" private const val WEBM = "video/webm"
fun isImage(mimeType: String?) = mimeType != null && mimeType.startsWith(IMAGE) fun isImage(mimeType: String?) = mimeType != null && mimeType.startsWith(IMAGE)
@ -68,7 +70,7 @@ object MimeTypes {
// as of `metadata-extractor` v2.14.0 // as of `metadata-extractor` v2.14.0
fun isSupportedByMetadataExtractor(mimeType: String) = when (mimeType) { fun isSupportedByMetadataExtractor(mimeType: String) = when (mimeType) {
WBMP, MP2T, WEBM -> false DJVU, WBMP, MP2T, MP2TS, WEBM -> false
else -> true else -> true
} }

View file

@ -5,12 +5,15 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager import android.os.storage.StorageManager
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi
import deckers.thibault.aves.utils.StorageUtils.PathSegments import deckers.thibault.aves.utils.StorageUtils.PathSegments
import java.io.File import java.io.File
import java.util.* import java.util.*
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlin.collections.ArrayList
object PermissionManager { object PermissionManager {
private val LOG_TAG = LogUtils.createTag(PermissionManager::class.java) private val LOG_TAG = LogUtils.createTag(PermissionManager::class.java)
@ -20,6 +23,7 @@ object PermissionManager {
// permission request code to pending runnable // permission request code to pending runnable
private val pendingPermissionMap = ConcurrentHashMap<Int, PendingPermissionHandler>() private val pendingPermissionMap = ConcurrentHashMap<Int, PendingPermissionHandler>()
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
fun requestVolumeAccess(activity: Activity, path: String, onGranted: () -> Unit, onDenied: () -> Unit) { 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") Log.i(LOG_TAG, "request user to select and grant access permission to volume=$path")
@ -63,12 +67,23 @@ object PermissionManager {
// inaccessible dirs // inaccessible dirs
val segments = PathSegments(context, dirPath) val segments = PathSegments(context, dirPath)
segments.volumePath?.let { volumePath -> segments.volumePath?.let { volumePath ->
val dirSet = dirsPerVolume.getOrDefault(volumePath, HashSet()) val dirSet = dirsPerVolume[volumePath] ?: HashSet()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// request primary directory on volume from Android R // request primary directory on volume from Android R
segments.relativeDir?.apply { val relativeDir = segments.relativeDir
val primaryDir = split(File.separator).firstOrNull { it.isNotEmpty() } if (relativeDir != null) {
primaryDir?.let { dirSet.add(it) } 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 { } else {
// request volume root until Android Q // request volume root until Android Q
@ -80,28 +95,52 @@ object PermissionManager {
} }
// format for easier handling on Flutter // format for easier handling on Flutter
val inaccessibleDirs = ArrayList<Map<String, String>>() return ArrayList<Map<String, String>>().apply {
val sm = context.getSystemService(StorageManager::class.java) addAll(dirsPerVolume.flatMap { (volumePath, relativeDirs) ->
if (sm != null) { relativeDirs.map { relativeDir ->
for ((volumePath, relativeDirs) in dirsPerVolume) { hashMapOf(
var volumeDescription: String? = null "volumePath" to volumePath,
try { "relativeDir" to relativeDir,
volumeDescription = sm.getStorageVolume(File(volumePath))?.getDescription(context) )
} catch (e: IllegalArgumentException) {
// ignore
} }
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 { fun revokeDirectoryAccess(context: Context, path: String): Boolean {
return StorageUtils.convertDirPathToTreeUri(context, path)?.let { return StorageUtils.convertDirPathToTreeUri(context, path)?.let {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION 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.text.TextUtils
import android.util.Log import android.util.Log
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi
import com.commonsware.cwac.document.DocumentFileCompat import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath
import java.io.File import java.io.File
@ -148,7 +149,7 @@ object StorageUtils {
return paths.map { ensureTrailingSeparator(it) }.toTypedArray() return paths.map { ensureTrailingSeparator(it) }.toTypedArray()
} }
// return physicalPaths based on phone model // returns physicalPaths based on phone model
@SuppressLint("SdCardPath") @SuppressLint("SdCardPath")
private val physicalPaths = arrayOf( private val physicalPaths = arrayOf(
"/storage/sdcard0", "/storage/sdcard0",
@ -177,41 +178,68 @@ object StorageUtils {
* Volume tree URIs * Volume tree URIs
*/ */
// e.g.
// /storage/emulated/0/ -> primary
// /storage/10F9-3F13/Pictures/ -> 10F9-3F13
private fun getVolumeUuidForTreeUri(context: Context, anyPath: String): String? { private fun getVolumeUuidForTreeUri(context: Context, anyPath: String): String? {
val sm = context.getSystemService(StorageManager::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
if (sm != null) { context.getSystemService(StorageManager::class.java)?.let { sm ->
val volume = sm.getStorageVolume(File(anyPath)) sm.getStorageVolume(File(anyPath))?.let { volume ->
if (volume != null) { if (volume.isPrimary) {
if (volume.isPrimary) { return "primary"
return "primary" }
} volume.uuid?.let { uuid ->
val uuid = volume.uuid return uuid.toUpperCase(Locale.ROOT)
if (uuid != null) { }
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") Log.e(LOG_TAG, "failed to find volume UUID for anyPath=$anyPath")
return null return null
} }
// e.g.
// primary -> /storage/emulated/0/
// 10F9-3F13 -> /storage/10F9-3F13/
private fun getVolumePathFromTreeUriUuid(context: Context, uuid: String): String? { private fun getVolumePathFromTreeUriUuid(context: Context, uuid: String): String? {
if (uuid == "primary") { if (uuid == "primary") {
return getPrimaryVolumePath(context) return getPrimaryVolumePath(context)
} }
val sm = context.getSystemService(StorageManager::class.java)
if (sm != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
for (volumePath in getVolumePaths(context)) { context.getSystemService(StorageManager::class.java)?.let { sm ->
try { for (volumePath in getVolumePaths(context)) {
val volume = sm.getStorageVolume(File(volumePath)) try {
if (volume != null && uuid.equals(volume.uuid, ignoreCase = true)) { val volume = sm.getStorageVolume(File(volumePath))
return volumePath if (volume != null && uuid.equals(volume.uuid, ignoreCase = true)) {
return volumePath
}
} catch (e: IllegalArgumentException) {
// ignore
} }
} catch (e: IllegalArgumentException) {
// ignore
} }
} }
} }
// 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") Log.e(LOG_TAG, "failed to find volume path for UUID=$uuid")
return null return null
} }
@ -219,6 +247,7 @@ object StorageUtils {
// e.g. // e.g.
// /storage/emulated/0/ -> content://com.android.externalstorage.documents/tree/primary%3A // /storage/emulated/0/ -> content://com.android.externalstorage.documents/tree/primary%3A
// /storage/10F9-3F13/Pictures/ -> content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures // /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? { fun convertDirPathToTreeUri(context: Context, dirPath: String): Uri? {
val uuid = getVolumeUuidForTreeUri(context, dirPath) val uuid = getVolumeUuidForTreeUri(context, dirPath)
if (uuid != null) { if (uuid != null) {
@ -260,7 +289,7 @@ object StorageUtils {
fun getDocumentFile(context: Context, anyPath: String, mediaUri: Uri): DocumentFileCompat? { fun getDocumentFile(context: Context, anyPath: String, mediaUri: Uri): DocumentFileCompat? {
try { 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 // 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)) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isMediaStoreContentUri(mediaUri)) {
// cleanest API to get it // cleanest API to get it
@ -284,7 +313,7 @@ object StorageUtils {
// returns null if directory does not exist and could not be created // returns null if directory does not exist and could not be created
fun createDirectoryIfAbsent(context: Context, dirPath: String): DocumentFileCompat? { fun createDirectoryIfAbsent(context: Context, dirPath: String): DocumentFileCompat? {
val cleanDirPath = ensureTrailingSeparator(dirPath) 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 grantedDir = getGrantedDirForPath(context, cleanDirPath) ?: return null
val rootTreeUri = convertDirPathToTreeUri(context, grantedDir) ?: return null val rootTreeUri = convertDirPathToTreeUri(context, grantedDir) ?: return null
var parentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: 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. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext.kotlin_version = '1.4.21' ext.kotlin_version = '1.4.30'
repositories { repositories {
google() google()
mavenCentral()
// TODO TLAD remove jcenter (migrating to mavenCentral) when this is fixed: https://youtrack.jetbrains.com/issue/IDEA-261387
jcenter() jcenter()
} }
dependencies { 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 'com.android.tools.build:gradle:3.6.4'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.gms:google-services:4.3.5' 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 { allprojects {
repositories { repositories {
google() google()
mavenCentral()
// TODO TLAD remove jcenter (migrating to mavenCentral) when this is fixed: https://youtrack.jetbrains.com/issue/IDEA-261387
jcenter() jcenter()
} }
// gradle.projectsEvaluated { // 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. You must use the app for legal, authorized and acceptable purposes.
# Disclaimer # 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 # 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. 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 '$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) { List<String> toDMS(LatLng latLng) {
if (latLng == null) return []; if (latLng == null) return [];
final lat = latLng.latitude; 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/utils/debouncer.dart';
import 'package:aves/widgets/common/behaviour/route_tracker.dart'; import 'package:aves/widgets/common/behaviour/route_tracker.dart';
import 'package:aves/widgets/common/behaviour/routes.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/home_page.dart';
import 'package:aves/widgets/welcome_page.dart'; import 'package:aves/widgets/welcome_page.dart';
import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_analytics/firebase_analytics.dart';
@ -74,8 +75,8 @@ class _AvesAppState extends State<AvesApp> {
textTheme: TextTheme( textTheme: TextTheme(
headline6: TextStyle( headline6: TextStyle(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.normal,
fontFamily: 'Concourse Caps', fontFeatures: [FontFeature.enable('smcp')],
), ),
), ),
), ),
@ -114,24 +115,26 @@ class _AvesAppState extends State<AvesApp> {
value: settings, value: settings,
child: Provider<CollectionSource>.value( child: Provider<CollectionSource>.value(
value: _mediaStoreSource, value: _mediaStoreSource,
child: OverlaySupport( child: HighlightInfoProvider(
child: FutureBuilder<void>( child: OverlaySupport(
future: _appSetup, child: FutureBuilder<void>(
builder: (context, snapshot) { future: _appSetup,
final home = (!snapshot.hasError && snapshot.connectionState == ConnectionState.done) builder: (context, snapshot) {
? getFirstPage() final home = (!snapshot.hasError && snapshot.connectionState == ConnectionState.done)
: Scaffold( ? getFirstPage()
body: snapshot.hasError ? _buildError(snapshot.error) : SizedBox.shrink(), : Scaffold(
); body: snapshot.hasError ? _buildError(snapshot.error) : SizedBox.shrink(),
return MaterialApp( );
navigatorKey: _navigatorKey, return MaterialApp(
home: home, navigatorKey: _navigatorKey,
navigatorObservers: _navigatorObservers, home: home,
title: 'Aves', navigatorObservers: _navigatorObservers,
darkTheme: darkTheme, title: 'Aves',
themeMode: ThemeMode.dark, darkTheme: darkTheme,
); themeMode: ThemeMode.dark,
}, );
},
),
), ),
), ),
), ),

View file

@ -14,6 +14,9 @@ enum ChipAction {
pin, pin,
unpin, unpin,
rename, rename,
goToAlbumPage,
goToCountryPage,
goToTagPage,
} }
extension ExtraChipAction on ChipAction { extension ExtraChipAction on ChipAction {
@ -21,6 +24,12 @@ extension ExtraChipAction on ChipAction {
switch (this) { switch (this) {
case ChipAction.delete: case ChipAction.delete:
return '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: case ChipAction.hide:
return 'Hide'; return 'Hide';
case ChipAction.pin: case ChipAction.pin:
@ -37,6 +46,12 @@ extension ExtraChipAction on ChipAction {
switch (this) { switch (this) {
case ChipAction.delete: case ChipAction.delete:
return AIcons.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: case ChipAction.hide:
return AIcons.hide; return AIcons.hide;
case ChipAction.pin: case ChipAction.pin:

View file

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

View file

@ -1,5 +1,7 @@
import 'dart:async'; 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/entry_cache.dart';
import 'package:aves/model/favourite_repo.dart'; import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/metadata.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/math_utils.dart';
import 'package:aves/utils/time_utils.dart'; import 'package:aves/utils/time_utils.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:country_code/country_code.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:geocoder/geocoder.dart'; import 'package:geocoder/geocoder.dart';
@ -47,7 +50,7 @@ class AvesEntry {
final Future<List<Address>> Function(Coordinates coordinates) _findAddresses = Geocoder.local.findAddressesFromCoordinates; final Future<List<Address>> Function(Coordinates coordinates) _findAddresses = Geocoder.local.findAddressesFromCoordinates;
// TODO TLAD make it dynamic if it depends on OS/lib versions // 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({ AvesEntry({
this.uri, this.uri,
@ -286,6 +289,8 @@ class AvesEntry {
static const ratioSeparator = '\u2236'; static const ratioSeparator = '\u2236';
static const resolutionSeparator = ' \u00D7 '; static const resolutionSeparator = ' \u00D7 ';
bool get isSized => (width ?? 0) > 0 && (height ?? 0) > 0;
String get resolutionText { String get resolutionText {
final ws = width ?? '?'; final ws = width ?? '?';
final hs = height ?? '?'; final hs = height ?? '?';
@ -366,9 +371,15 @@ class AvesEntry {
return _durationText; 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; LatLng get latLng => hasGps ? LatLng(_catalogMetadata.latitude, _catalogMetadata.longitude) : null;
@ -389,7 +400,7 @@ class AvesEntry {
String _bestTitle; String _bestTitle;
String get bestTitle { String get bestTitle {
_bestTitle ??= (isCatalogued && _catalogMetadata.xmpTitleDescription?.isNotEmpty == true) ? _catalogMetadata.xmpTitleDescription : sourceTitle; _bestTitle ??= _catalogMetadata?.xmpTitleDescription?.isNotEmpty == true ? _catalogMetadata.xmpTitleDescription : sourceTitle;
return _bestTitle; return _bestTitle;
} }
@ -444,17 +455,36 @@ class AvesEntry {
addressChangeNotifier.notifyListeners(); addressChangeNotifier.notifyListeners();
} }
Future<void> locate({bool background = false}) async { Future<void> locate({@required bool background}) async {
if (isLocated) return; if (!hasGps) return;
await _locateCountry();
if (await availability.canLocatePlaces) {
await locatePlace(background: background);
}
}
await catalog(background: background); // quick reverse geocoding to find the country, using an offline asset
final latitude = _catalogMetadata?.latitude; Future<void> _locateCountry() async {
final longitude = _catalogMetadata?.longitude; if (!hasGps || hasAddress) return;
if (latitude == null || longitude == null || (latitude == 0 && longitude == 0)) 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 { try {
Future<List<Address>> call() => _findAddresses(coordinates); Future<List<Address>> call() => _findAddresses(Coordinates(coordinates.latitude, coordinates.longitude));
final addresses = await (background final addresses = await (background
? servicePolicy.call( ? servicePolicy.call(
call, call,
@ -476,38 +506,34 @@ class AvesEntry {
locality: address.locality ?? (cc == null && cn == null && aa == null ? address.addressLine : null), locality: address.locality ?? (cc == null && cn == null && aa == null ? address.addressLine : null),
); );
} }
} catch (error, stackTrace) { } catch (error, stack) {
debugPrint('$runtimeType locate failed with path=$path coordinates=$coordinates error=$error\n$stackTrace'); debugPrint('$runtimeType locate failed with path=$path coordinates=$coordinates error=$error\n$stack');
} }
} }
Future<String> findAddressLine() async { Future<String> findAddressLine() async {
final latitude = _catalogMetadata?.latitude; if (!hasGps) return null;
final longitude = _catalogMetadata?.longitude;
if (latitude == null || longitude == null || (latitude == 0 && longitude == 0)) return null;
final coordinates = Coordinates(latitude, longitude); final coordinates = latLng;
try { try {
final addresses = await _findAddresses(coordinates); final addresses = await _findAddresses(Coordinates(coordinates.latitude, coordinates.longitude));
if (addresses != null && addresses.isNotEmpty) { if (addresses != null && addresses.isNotEmpty) {
final address = addresses.first; final address = addresses.first;
return address.addressLine; return address.addressLine;
} }
} catch (error, stackTrace) { } catch (error, stack) {
debugPrint('$runtimeType findAddressLine failed with path=$path coordinates=$coordinates error=$error\n$stackTrace'); debugPrint('$runtimeType findAddressLine failed with path=$path coordinates=$coordinates error=$error\n$stack');
} }
return null; return null;
} }
String get shortAddress { String get shortAddress {
if (!isLocated) return '';
// `admin area` examples: Seoul, Geneva, null // `admin area` examples: Seoul, Geneva, null
// `locality` examples: Mapo-gu, Geneva, Annecy // `locality` examples: Mapo-gu, Geneva, Annecy
return { return {
_addressDetails.countryName, _addressDetails?.countryName,
_addressDetails.adminArea, _addressDetails?.adminArea,
_addressDetails.locality, _addressDetails?.locality,
}.where((part) => part != null && part.isNotEmpty).join(', '); }.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/mime.dart';
import 'package:aves/model/filters/query.dart'; import 'package:aves/model/filters/query.dart';
import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/filters/type.dart';
import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/color_utils.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -17,6 +18,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
QueryFilter.type, QueryFilter.type,
FavouriteFilter.type, FavouriteFilter.type,
MimeFilter.type, MimeFilter.type,
TypeFilter.type,
AlbumFilter.type, AlbumFilter.type,
LocationFilter.type, LocationFilter.type,
TagFilter.type, TagFilter.type,
@ -32,6 +34,8 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
return FavouriteFilter(); return FavouriteFilter();
case LocationFilter.type: case LocationFilter.type:
return LocationFilter.fromMap(jsonMap); return LocationFilter.fromMap(jsonMap);
case TypeFilter.type:
return TypeFilter.fromMap(jsonMap);
case MimeFilter.type: case MimeFilter.type:
return MimeFilter.fromMap(jsonMap); return MimeFilter.fromMap(jsonMap);
case QueryFilter.type: case QueryFilter.type:

View file

@ -19,7 +19,7 @@ class LocationFilter extends CollectionFilter {
if (split.length > 1) _countryCode = split[1]; if (split.length > 1) _countryCode = split[1];
if (_location.isEmpty) { if (_location.isEmpty) {
_test = (entry) => !entry.isLocated; _test = (entry) => !entry.hasGps;
} else if (level == LocationLevel.country) { } else if (level == LocationLevel.country) {
_test = (entry) => entry.addressDetails?.countryCode == _countryCode; _test = (entry) => entry.addressDetails?.countryCode == _countryCode;
} else if (level == LocationLevel.place) { } else if (level == LocationLevel.place) {
@ -55,7 +55,13 @@ class LocationFilter extends CollectionFilter {
final flag = countryCodeToFlag(_countryCode); final flag = countryCodeToFlag(_countryCode);
// as of Flutter v1.22.3, emoji shadows are rendered as colorful duplicates, // 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 // 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); 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 { class MimeFilter extends CollectionFilter {
static const type = 'mime'; 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; final String mime;
EntryFilter _test; EntryFilter _test;
String _label; String _label;
@ -20,23 +14,7 @@ class MimeFilter extends CollectionFilter {
MimeFilter(this.mime) { MimeFilter(this.mime) {
var lowMime = mime.toLowerCase(); var lowMime = mime.toLowerCase();
if (mime == animated) { if (lowMime.endsWith('/*')) {
_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('/*')) {
lowMime = lowMime.substring(0, lowMime.length - 2); lowMime = lowMime.substring(0, lowMime.length - 2);
_test = (entry) => entry.mimeType.startsWith(lowMime); _test = (entry) => entry.mimeType.startsWith(lowMime);
if (lowMime == 'video') { 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'; import 'package:flutter/foundation.dart';
class HighlightInfo extends ChangeNotifier { class HighlightInfo extends ChangeNotifier {
final Queue<Object> _items = Queue(); Object _item;
void add(Object item) { void set(Object item) {
if (_items.contains(item)) return; if (_item == item) return;
_item = item;
_items.addFirst(item);
while (_items.length > 5) {
_items.removeLast();
}
notifyListeners(); notifyListeners();
} }
void remove(Object item) { Object clear() {
_items.removeWhere((element) => element == item); if (_item == null) return null;
final item = _item;
_item = null;
notifyListeners(); 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}'; String toString() => '$runtimeType#${shortHash(this)}{aperture=$aperture, exposureTime=$exposureTime, focalLength=$focalLength, iso=$iso}';
} }
@immutable
class AddressDetails { class AddressDetails {
final int contentId; final int contentId;
final String countryCode, countryName, adminArea, locality; final String countryCode, countryName, adminArea, locality;
String get place => locality != null && locality.isNotEmpty ? locality : adminArea; String get place => locality != null && locality.isNotEmpty ? locality : adminArea;
AddressDetails({ const AddressDetails({
this.contentId, this.contentId,
this.countryCode, this.countryCode,
this.countryName, this.countryName,

View file

@ -195,8 +195,8 @@ class MetadataDb {
metadataEntries.where((metadata) => metadata != null).forEach((metadata) => _batchInsertMetadata(batch, metadata)); metadataEntries.where((metadata) => metadata != null).forEach((metadata) => _batchInsertMetadata(batch, metadata));
await batch.commit(noResult: true); await batch.commit(noResult: true);
debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries'); debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
} catch (exception, stack) { } catch (error, stack) {
debugPrint('$runtimeType failed to save metadata with exception=$exception\n$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'; import 'package:latlong/latlong.dart';
enum CoordinateFormat { dms, decimal } 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 } enum KeepScreenOn { never, viewerOnly, always }
@ -17,6 +17,6 @@ extension ExtraKeepScreenOn on KeepScreenOn {
} }
void apply() { 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); set hasAcceptedTerms(bool newValue) => setAndNotify(hasAcceptedTermsKey, newValue);
bool get isCrashlyticsEnabled => getBoolOrDefault(isCrashlyticsEnabledKey, true); bool get isCrashlyticsEnabled => getBoolOrDefault(isCrashlyticsEnabledKey, false);
set isCrashlyticsEnabled(bool newValue) { set isCrashlyticsEnabled(bool newValue) {
setAndNotify(isCrashlyticsEnabledKey, newValue); setAndNotify(isCrashlyticsEnabledKey, newValue);

View file

@ -23,34 +23,36 @@ mixin AlbumMixin on SourceBase {
void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent()); void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent());
String getUniqueAlbumName(String album) { String getUniqueAlbumName(String dirPath) {
final otherAlbums = _directories.where((item) => item != album); String unique(String dirPath, [bool Function(String) test]) {
final parts = album.split(separator); final otherAlbums = _directories.where(test ?? (_) => true).where((item) => item != dirPath);
var partCount = 0; final parts = dirPath.split(separator);
String testName; var partCount = 0;
do { String testName;
testName = separator + parts.skip(parts.length - ++partCount).join(separator); do {
} while (otherAlbums.any((item) => item.endsWith(testName))); testName = separator + parts.skip(parts.length - ++partCount).join(separator);
final uniqueName = 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; return uniqueName;
} }
final volumeRootLength = volume.path.length; final dir = VolumeRelativeDirectory.fromPath(dirPath);
if (album.length < volumeRootLength) { if (dir == null) return dirPath;
// `album` is at the root, without trailing '/'
return uniqueName;
}
final albumRelativePath = album.substring(volumeRootLength); final uniqueNameInDevice = unique(dirPath);
if (uniqueName.length < albumRelativePath.length) { final relativeDir = dir.relativeDir;
return uniqueName; if (relativeDir.isEmpty) return uniqueNameInDevice;
} else if (volume.isPrimary) {
return albumRelativePath; if (uniqueNameInDevice.length < relativeDir.length) {
return uniqueNameInDevice;
} else { } 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(_filterEntryCountMap.remove);
directories.forEach(_filterRecentEntryMap.remove); directories.forEach(_filterRecentEntryMap.remove);
} }
eventBus.fire(AlbumSummaryInvalidatedEvent(directories));
} }
int albumEntryCount(AlbumFilter filter) { int albumEntryCount(AlbumFilter filter) {
@ -131,3 +134,9 @@ mixin AlbumMixin on SourceBase {
} }
class AlbumsChangedEvent {} 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<EntryAddedEvent>().listen((e) => onEntryAdded(e.entries)));
_subscriptions.add(source.eventBus.on<EntryRemovedEvent>().listen((e) => onEntryRemoved(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<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<CatalogMetadataChangedEvent>().listen((e) => _refresh()));
_subscriptions.add(source.eventBus.on<AddressMetadataChangedEvent>().listen((e) { _subscriptions.add(source.eventBus.on<AddressMetadataChangedEvent>().listen((e) {
if (this.filters.any((filter) => filter is LocationFilter)) { if (this.filters.any((filter) => filter is LocationFilter)) {

View file

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

View file

@ -1,5 +1,6 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/geo/countries.dart';
import 'package:aves/model/availability.dart'; import 'package:aves/model/availability.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
@ -28,10 +29,45 @@ mixin LocationMixin on SourceBase {
} }
Future<void> locateEntries() async { Future<void> locateEntries() async {
if (!(await availability.canGeolocate)) return; await _locateCountries();
await _locatePlaces();
}
// final stopwatch = Stopwatch()..start(); // quick reverse geocoding to find the countries, using an offline asset
final byLocated = groupBy<AvesEntry, bool>(visibleEntries.where((entry) => entry.hasGps), (entry) => entry.isLocated); 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] ?? []; final todo = byLocated[false] ?? [];
if (todo.isEmpty) return; if (todo.isEmpty) return;
@ -55,6 +91,7 @@ mixin LocationMixin on SourceBase {
final knownLocations = <Tuple2, AddressDetails>{}; final knownLocations = <Tuple2, AddressDetails>{};
byLocated[true]?.forEach((entry) => knownLocations.putIfAbsent(approximateLatLng(entry), () => entry.addressDetails)); byLocated[true]?.forEach((entry) => knownLocations.putIfAbsent(approximateLatLng(entry), () => entry.addressDetails));
stateNotifier.value = SourceState.locating;
var progressDone = 0; var progressDone = 0;
final progressTotal = todo.length; final progressTotal = todo.length;
setProgress(done: progressDone, total: progressTotal); setProgress(done: progressDone, total: progressTotal);
@ -65,12 +102,12 @@ mixin LocationMixin on SourceBase {
if (knownLocations.containsKey(latLng)) { if (knownLocations.containsKey(latLng)) {
entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId); entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId);
} else { } else {
await entry.locate(background: true); await entry.locatePlace(background: true);
// it is intended to insert `null` if the geocoder failed, // it is intended to insert `null` if the geocoder failed,
// so that we skip geocoding of following entries with the same coordinates // so that we skip geocoding of following entries with the same coordinates
knownLocations[latLng] = entry.addressDetails; knownLocations[latLng] = entry.addressDetails;
} }
if (entry.isLocated) { if (entry.hasFineAddress) {
newAddresses.add(entry.addressDetails); newAddresses.add(entry.addressDetails);
if (newAddresses.length >= _commitCountThreshold) { if (newAddresses.length >= _commitCountThreshold) {
await metadataDb.saveAddresses(List.unmodifiable(newAddresses)); await metadataDb.saveAddresses(List.unmodifiable(newAddresses));
@ -80,9 +117,11 @@ mixin LocationMixin on SourceBase {
} }
setProgress(done: ++progressDone, total: progressTotal); setProgress(done: ++progressDone, total: progressTotal);
}); });
await metadataDb.saveAddresses(List.unmodifiable(newAddresses)); if (newAddresses.isNotEmpty) {
onAddressMetadataChanged(); await metadataDb.saveAddresses(List.unmodifiable(newAddresses));
// debugPrint('$runtimeType locateEntries complete in ${stopwatch.elapsed.inSeconds}s'); onAddressMetadataChanged();
}
// debugPrint('$runtimeType _locatePlaces complete in ${stopwatch.elapsed.inSeconds}s');
} }
void onAddressMetadataChanged() { void onAddressMetadataChanged() {
@ -91,17 +130,23 @@ mixin LocationMixin on SourceBase {
} }
void updateLocations() { void updateLocations() {
final locations = visibleEntries.where((entry) => entry.isLocated).map((entry) => entry.addressDetails).toList(); final locations = visibleEntries.where((entry) => entry.hasAddress).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 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 // the same country code could be found with different country names
// e.g. if the locale changed between geolocating calls // e.g. if the locale changed between geolocating calls
// so we merge countries by code, keeping only one name for each code // 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)); 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)) {
invalidateCountryFilterSummary(); sortedCountries = List.unmodifiable(updatedCountries);
eventBus.fire(LocationsChangedEvent()); invalidateCountryFilterSummary();
eventBus.fire(CountriesChangedEvent());
}
} }
// filter summary // filter summary
@ -111,13 +156,16 @@ mixin LocationMixin on SourceBase {
final Map<String, AvesEntry> _filterRecentEntryMap = {}; final Map<String, AvesEntry> _filterRecentEntryMap = {};
void invalidateCountryFilterSummary([Set<AvesEntry> entries]) { void invalidateCountryFilterSummary([Set<AvesEntry> entries]) {
Set<String> countryCodes;
if (entries == null) { if (entries == null) {
_filterEntryCountMap.clear(); _filterEntryCountMap.clear();
_filterRecentEntryMap.clear(); _filterRecentEntryMap.clear();
} else { } 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); countryCodes.forEach(_filterEntryCountMap.remove);
} }
eventBus.fire(CountrySummaryInvalidatedEvent(countryCodes));
} }
int countryEntryCount(LocationFilter filter) { int countryEntryCount(LocationFilter filter) {
@ -131,4 +179,12 @@ mixin LocationMixin on SourceBase {
class AddressMetadataChangedEvent {} 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/model/source/collection_source.dart';
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/media_store_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/android_file_utils.dart';
import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/math_utils.dart';
import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_native_timezone/flutter_native_timezone.dart';
import 'package:pedantic/pedantic.dart';
class MediaStoreSource extends CollectionSource { class MediaStoreSource extends CollectionSource {
bool _initialized = false; bool _initialized = false;
@ -27,7 +26,7 @@ class MediaStoreSource extends CollectionSource {
stateNotifier.value = SourceState.loading; stateNotifier.value = SourceState.loading;
await metadataDb.init(); await metadataDb.init();
await favourites.init(); await favourites.init();
final currentTimeZone = await FlutterNativeTimezone.getLocalTimezone(); final currentTimeZone = await TimeService.getDefaultTimeZone();
final catalogTimeZone = settings.catalogTimeZone; final catalogTimeZone = settings.catalogTimeZone;
if (currentTimeZone != catalogTimeZone) { if (currentTimeZone != catalogTimeZone) {
// clear catalog metadata to get correct date/times when moving to a different time zone // clear catalog metadata to get correct date/times when moving to a different time zone
@ -103,25 +102,25 @@ class MediaStoreSource extends CollectionSource {
updateDirectories(); 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(); await catalogEntries();
unawaited(analytics.setUserProperty(name: 'tag_count', value: (ceilBy(sortedTags.length, 1)).toString()));
stateNotifier.value = SourceState.locating;
await locateEntries(); await locateEntries();
unawaited(analytics.setUserProperty(name: 'country_count', value: (ceilBy(sortedCountries.length, 1)).toString()));
stateNotifier.value = SourceState.ready; stateNotifier.value = SourceState.ready;
_reportCollectionDimensions();
debugPrint('$runtimeType refresh done, elapsed=${stopwatch.elapsed}'); debugPrint('$runtimeType refresh done, elapsed=${stopwatch.elapsed}');
}, },
onError: (error) => debugPrint('$runtimeType stream error=$error'), 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: // returns URIs to retry later. They could be URIs that are:
// 1) currently being processed during bulk move/deletion // 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 // 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) { final uriByContentId = Map.fromEntries(changedUris.map((uri) {
if (uri == null) return null; 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); final contentId = int.tryParse(idString);
if (contentId == null) return null; if (contentId == null) return null;
return MapEntry(contentId, uri); return MapEntry(contentId, uri);
@ -175,13 +177,8 @@ class MediaStoreSource extends CollectionSource {
addEntries(newEntries); addEntries(newEntries);
await metadataDb.saveEntries(newEntries); await metadataDb.saveEntries(newEntries);
cleanEmptyAlbums(existingDirectories); cleanEmptyAlbums(existingDirectories);
stateNotifier.value = SourceState.cataloguing;
await catalogEntries(); await catalogEntries();
stateNotifier.value = SourceState.locating;
await locateEntries(); await locateEntries();
stateNotifier.value = SourceState.ready; stateNotifier.value = SourceState.ready;
} }

View file

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

View file

@ -28,7 +28,7 @@ class AndroidDebugService {
static Future<Map> getBitmapFactoryInfo(AvesEntry entry) async { static Future<Map> getBitmapFactoryInfo(AvesEntry entry) async {
try { 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>{ final result = await platform.invokeMethod('getBitmapFactoryInfo', <String, dynamic>{
'uri': entry.uri, 'uri': entry.uri,
}) as Map; }) as Map;
@ -41,7 +41,7 @@ class AndroidDebugService {
static Future<Map> getContentResolverMetadata(AvesEntry entry) async { static Future<Map> getContentResolverMetadata(AvesEntry entry) async {
try { 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>{ final result = await platform.invokeMethod('getContentResolverMetadata', <String, dynamic>{
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
'uri': entry.uri, 'uri': entry.uri,
@ -55,7 +55,7 @@ class AndroidDebugService {
static Future<Map> getExifInterfaceMetadata(AvesEntry entry) async { static Future<Map> getExifInterfaceMetadata(AvesEntry entry) async {
try { 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>{ final result = await platform.invokeMethod('getExifInterfaceMetadata', <String, dynamic>{
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
'uri': entry.uri, 'uri': entry.uri,
@ -70,7 +70,7 @@ class AndroidDebugService {
static Future<Map> getMediaMetadataRetrieverMetadata(AvesEntry entry) async { static Future<Map> getMediaMetadataRetrieverMetadata(AvesEntry entry) async {
try { 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>{ final result = await platform.invokeMethod('getMediaMetadataRetrieverMetadata', <String, dynamic>{
'uri': entry.uri, 'uri': entry.uri,
}) as Map; }) as Map;
@ -83,7 +83,7 @@ class AndroidDebugService {
static Future<Map> getMetadataExtractorSummary(AvesEntry entry) async { static Future<Map> getMetadataExtractorSummary(AvesEntry entry) async {
try { 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>{ final result = await platform.invokeMethod('getMetadataExtractorSummary', <String, dynamic>{
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
'uri': entry.uri, 'uri': entry.uri,

View file

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

View file

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

View file

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

View file

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

View file

@ -42,8 +42,8 @@ class SvgMetadataService {
} }
} }
} }
} catch (exception, stack) { } catch (error, stack) {
debugPrint('failed to parse XML from SVG with exception=$exception\n$stack'); debugPrint('failed to parse XML from SVG with error=$error\n$stack');
} }
return null; return null;
} }
@ -78,8 +78,8 @@ class SvgMetadataService {
if (docDir.isNotEmpty) docDirectory: docDir, if (docDir.isNotEmpty) docDirectory: docDir,
if (metadataDir.isNotEmpty) metadataDirectory: metadataDir, if (metadataDir.isNotEmpty) metadataDirectory: metadataDir,
}; };
} catch (exception, stack) { } catch (error, stack) {
debugPrint('failed to parse XML from SVG with exception=$exception\n$stack'); debugPrint('failed to parse XML from SVG with error=$error\n$stack');
return null; 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 { static Future<Map> getIntentData() async {
try { 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; return await platform.invokeMethod('getIntentData') as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getIntentData failed with code=${e.code}, exception=${e.message}, details=${e.details}'); 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 // filter grids animations
static const chipDecorationAnimation = Duration(milliseconds: 200); static const chipDecorationAnimation = Duration(milliseconds: 200);
static const highlightScrollAnimationMinMillis = 400;
static const highlightScrollAnimationMaxMillis = 2000;
// collection animations // collection animations
static const filterBarRemovalAnimation = Duration(milliseconds: 400); static const filterBarRemovalAnimation = Duration(milliseconds: 400);
@ -44,6 +46,7 @@ class Durations {
static const opToastDisplay = Duration(seconds: 2); static const opToastDisplay = Duration(seconds: 2);
static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100); static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100);
static const collectionScalingCompleteNotificationDelay = Duration(milliseconds: 300); static const collectionScalingCompleteNotificationDelay = Duration(milliseconds: 300);
static const highlightScrollInitDelay = Duration(milliseconds: 800);
static const videoProgressTimerInterval = Duration(milliseconds: 300); static const videoProgressTimerInterval = Duration(milliseconds: 300);
static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation; static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation;
static const doubleBackTimerDelay = Duration(milliseconds: 1000); 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/services/android_file_service.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
final AndroidFileUtils androidFileUtils = AndroidFileUtils._private(); 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}'; String toString() => '$runtimeType#${shortHash(this)}{packageName=$packageName, categoryLauncher=$categoryLauncher, isSystem=$isSystem, currentLabel=$currentLabel, englishLabel=$englishLabel, ownedDirs=$ownedDirs}';
} }
@immutable
class StorageVolume { class StorageVolume {
final String description, path, state; final String description, path, state;
final bool isEmulated, isPrimary, isRemovable; final bool isPrimary, isRemovable;
const StorageVolume({ const StorageVolume({
this.description, this.description,
this.isEmulated,
this.isPrimary, this.isPrimary,
this.isRemovable, this.isRemovable,
this.path, this.path,
@ -126,13 +127,59 @@ class StorageVolume {
}); });
factory StorageVolume.fromMap(Map map) { factory StorageVolume.fromMap(Map map) {
final isPrimary = map['isPrimary'] ?? false;
return StorageVolume( return StorageVolume(
description: map['description'] ?? '', description: map['description'] ?? (isPrimary ? 'Internal storage' : 'SD card'),
isEmulated: map['isEmulated'] ?? false, isPrimary: isPrimary,
isPrimary: map['isPrimary'] ?? false,
isRemovable: map['isRemovable'] ?? false, isRemovable: map['isRemovable'] ?? false,
path: map['path'] ?? '', path: map['path'] ?? '',
state: map['state'] ?? '', 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) { for (final listener in localListeners) {
try { try {
if (_listeners.contains(listener)) listener(); if (_listeners.contains(listener)) listener();
} catch (exception, stack) { } catch (error, stack) {
debugPrint('$runtimeType failed to notify listeners with exception=$exception\n$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/material.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import 'package:latlong/latlong.dart'; import 'package:latlong/latlong.dart';
@ -8,9 +10,9 @@ class Constants {
static const overflowStrutStyle = StrutStyle(height: 1.3); static const overflowStrutStyle = StrutStyle(height: 1.3);
static const titleTextStyle = TextStyle( static const titleTextStyle = TextStyle(
color: Color(0xFFEEEEEE),
fontSize: 20, fontSize: 20,
fontFamily: 'Concourse Caps', fontWeight: FontWeight.w300,
fontFeatures: [FontFeature.enable('smcp')],
); );
static const embossShadow = Shadow( static const embossShadow = Shadow(
@ -89,6 +91,12 @@ class Constants {
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/connectivity/connectivity/LICENSE', licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/connectivity/connectivity/LICENSE',
sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/connectivity/connectivity', 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( Dependency(
name: 'Decorated Icon', name: 'Decorated Icon',
license: 'MIT', license: 'MIT',
@ -143,12 +151,6 @@ class Constants {
licenseUrl: 'https://github.com/flutter/flutter_markdown/blob/master/LICENSE', licenseUrl: 'https://github.com/flutter/flutter_markdown/blob/master/LICENSE',
sourceUrl: 'https://github.com/flutter/flutter_markdown', 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( Dependency(
name: 'Flutter Staggered Animations', name: 'Flutter Staggered Animations',
license: 'MIT', license: 'MIT',
@ -257,12 +259,6 @@ class Constants {
licenseUrl: 'https://github.com/rrousselGit/provider/blob/master/LICENSE', licenseUrl: 'https://github.com/rrousselGit/provider/blob/master/LICENSE',
sourceUrl: 'https://github.com/rrousselGit/provider', 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( Dependency(
name: 'Shared Preferences', name: 'Shared Preferences',
license: 'BSD 3-Clause', license: 'BSD 3-Clause',

View file

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

View file

@ -1,3 +1,5 @@
import 'dart:ui';
import 'package:aves/flutter_version.dart'; import 'package:aves/flutter_version.dart';
import 'package:aves/widgets/common/basic/link_chip.dart'; import 'package:aves/widgets/common/basic/link_chip.dart';
import 'package:aves/widgets/common/identity/aves_logo.dart'; import 'package:aves/widgets/common/identity/aves_logo.dart';
@ -32,15 +34,19 @@ class _AppReferenceState extends State<AppReference> {
} }
Widget _buildAvesLine() { Widget _buildAvesLine() {
final textTheme = Theme.of(context).textTheme; final style = TextStyle(
final style = textTheme.headline6.copyWith(fontWeight: FontWeight.bold); fontSize: 20,
fontWeight: FontWeight.normal,
letterSpacing: 1.0,
fontFeatures: [FontFeature.enable('smcp')],
);
return FutureBuilder<PackageInfo>( return FutureBuilder<PackageInfo>(
future: _packageInfoLoader, future: _packageInfoLoader,
builder: (context, snapshot) { builder: (context, snapshot) {
return LinkChip( return LinkChip(
leading: AvesLogo( leading: AvesLogo(
size: style.fontSize * 1.25, size: style.fontSize * MediaQuery.textScaleFactorOf(context) * 1.25,
), ),
text: 'Aves ${snapshot.data?.version}', text: 'Aves ${snapshot.data?.version}',
url: 'https://github.com/deckerst/aves', 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:aves/widgets/common/basic/link_chip.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -13,25 +16,22 @@ class AboutCredits extends StatelessWidget {
constraints: BoxConstraints(minHeight: 48), constraints: BoxConstraints(minHeight: 48),
child: Align( child: Align(
alignment: AlignmentDirectional.centerStart, alignment: AlignmentDirectional.centerStart,
child: Text( child: Text('Credits', style: Constants.titleTextStyle),
'Credits',
style: Theme.of(context).textTheme.headline6.copyWith(fontFamily: 'Concourse Caps'),
),
), ),
), ),
Text.rich( Text.rich(
TextSpan( TextSpan(
children: [ children: [
TextSpan(text: 'This app uses the font '), TextSpan(text: 'This app uses a TopoJSON file from'),
WidgetSpan( WidgetSpan(
child: LinkChip( child: LinkChip(
text: 'Concourse', text: 'World Atlas',
url: 'https://mbtype.com/fonts/concourse/', url: 'https://github.com/topojson/world-atlas',
textStyle: TextStyle(fontWeight: FontWeight.bold), textStyle: TextStyle(fontWeight: FontWeight.bold),
), ),
alignment: PlaceholderAlignment.middle, 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( child: Row(
children: [ children: [
Expanded( Expanded(
child: Text( child: Text('Open-Source Licenses', style: Constants.titleTextStyle),
'Open-Source Licenses',
style: Theme.of(context).textTheme.headline6.copyWith(fontFamily: 'Concourse Caps'),
),
), ),
PopupMenuButton<LicenseSort>( PopupMenuButton<LicenseSort>(
itemBuilder: (context) => [ itemBuilder: (context) => [

View file

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

View file

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

View file

@ -6,7 +6,7 @@ class EmptyContent extends StatelessWidget {
final AlignmentGeometry alignment; final AlignmentGeometry alignment;
const EmptyContent({ const EmptyContent({
@required this.icon, this.icon,
@required this.text, @required this.text,
this.alignment = const FractionalOffset(.5, .35), this.alignment = const FractionalOffset(.5, .35),
}); });
@ -19,18 +19,19 @@ class EmptyContent extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( if (icon != null) ...[
icon, Icon(
size: 64, icon,
color: color, size: 64,
), color: color,
SizedBox(height: 16), ),
SizedBox(height: 16)
],
Text( Text(
text, text,
style: TextStyle( style: TextStyle(
color: color, color: color,
fontSize: 22, 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_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/android_app_service.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_file_service.dart';
import 'package:aves/services/image_op_events.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/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
import 'package:aves/widgets/common/action_mixins/size_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 { 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( final destinationAlbum = await Navigator.push(
context, context,
MaterialPageRoute<String>( MaterialPageRoute<String>(
@ -73,7 +89,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
if (destinationAlbum == null || destinationAlbum.isEmpty) return; if (destinationAlbum == null || destinationAlbum.isEmpty) return;
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) 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; if (!await checkFreeSpaceForMove(context, selection, destinationAlbum, moveType)) return;

View file

@ -155,7 +155,7 @@ class _ThumbnailHighlightOverlayState extends State<ThumbnailHighlightOverlay> {
toggledNotifier: _highlightedNotifier, toggledNotifier: _highlightedNotifier,
startAngle: pi * -3 / 4, startAngle: pi * -3 / 4,
centerSweep: false, 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/section_layout.dart';
import 'package:aves/widgets/common/grid/sliver.dart'; import 'package:aves/widgets/common/grid/sliver.dart';
import 'package:aves/widgets/common/identity/scroll_thumb.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/scaling.dart';
import 'package:aves/widgets/common/tile_extent_manager.dart'; import 'package:aves/widgets/common/tile_extent_manager.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -44,105 +43,103 @@ class ThumbnailCollection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return HighlightInfoProvider( return SafeArea(
child: SafeArea( bottom: false,
bottom: false, child: LayoutBuilder(
child: LayoutBuilder( builder: (context, constraints) {
builder: (context, constraints) { final viewportSize = constraints.biggest;
final viewportSize = constraints.biggest; assert(viewportSize.isFinite, 'Cannot layout collection with unbounded constraints.');
assert(viewportSize.isFinite, 'Cannot layout collection with unbounded constraints.'); if (viewportSize.isEmpty) return SizedBox.shrink();
if (viewportSize.isEmpty) return SizedBox.shrink();
final tileExtentManager = TileExtentManager( final tileExtentManager = TileExtentManager(
settingsRouteKey: context.currentRouteName, settingsRouteKey: context.currentRouteName,
extentNotifier: _tileExtentNotifier, extentNotifier: _tileExtentNotifier,
columnCountDefault: columnCountDefault, columnCountDefault: columnCountDefault,
extentMin: extentMin, extentMin: extentMin,
spacing: spacing, spacing: spacing,
)..applyTileExtent(viewportSize: viewportSize); )..applyTileExtent(viewportSize: viewportSize);
final cacheExtent = tileExtentManager.getEffectiveExtentMax(viewportSize) * 2; final cacheExtent = tileExtentManager.getEffectiveExtentMax(viewportSize) * 2;
final scrollController = PrimaryScrollController.of(context); final scrollController = PrimaryScrollController.of(context);
// do not replace by Provider.of<CollectionLens> // do not replace by Provider.of<CollectionLens>
// so that view updates on collection filter changes // so that view updates on collection filter changes
return Consumer<CollectionLens>( return Consumer<CollectionLens>(
builder: (context, collection, child) { builder: (context, collection, child) {
final scrollView = AnimationLimiter( final scrollView = AnimationLimiter(
child: CollectionScrollView( child: CollectionScrollView(
scrollableKey: _scrollableKey,
collection: collection,
appBar: CollectionAppBar(
appBarHeightNotifier: _appBarHeightNotifier,
collection: collection,
),
appBarHeightNotifier: _appBarHeightNotifier,
isScrollingNotifier: _isScrollingNotifier,
scrollController: scrollController,
cacheExtent: cacheExtent,
),
);
final scaler = GridScaleGestureDetector<AvesEntry>(
tileExtentManager: tileExtentManager,
scrollableKey: _scrollableKey, scrollableKey: _scrollableKey,
appBarHeightNotifier: _appBarHeightNotifier,
viewportSize: viewportSize,
gridBuilder: (center, extent, child) => CustomPaint(
// painting the thumbnail half-border on top of the grid yields artifacts,
// so we use a `foregroundPainter` to cover them instead
foregroundPainter: GridPainter(
center: center,
extent: extent,
spacing: tileExtentManager.spacing,
strokeWidth: DecoratedThumbnail.borderWidth * 2,
color: DecoratedThumbnail.borderColor,
),
child: child,
),
scaledBuilder: (entry, extent) => DecoratedThumbnail(
entry: entry,
extent: extent,
selectable: false,
highlightable: false,
),
getScaledItemTileRect: (context, entry) {
final sectionedListLayout = context.read<SectionedListLayout<AvesEntry>>();
return sectionedListLayout.getTileRect(entry) ?? Rect.zero;
},
onScaled: (entry) => context.read<HighlightInfo>().add(entry),
child: scrollView,
);
final selector = GridSelectionGestureDetector(
selectable: AvesApp.mode == AppMode.main,
collection: collection, collection: collection,
scrollController: scrollController, appBar: CollectionAppBar(
appBarHeightNotifier: _appBarHeightNotifier, appBarHeightNotifier: _appBarHeightNotifier,
child: scaler,
);
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: _tileExtentNotifier,
builder: (context, tileExtent, child) => SectionedEntryListLayoutProvider(
collection: collection, collection: collection,
scrollableWidth: viewportSize.width,
tileExtent: tileExtent,
columnCount: tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent),
tileBuilder: (entry) => InteractiveThumbnail(
key: ValueKey(entry.contentId),
collection: collection,
entry: entry,
tileExtent: tileExtent,
isScrollingNotifier: _isScrollingNotifier,
),
child: selector,
), ),
); appBarHeightNotifier: _appBarHeightNotifier,
return sectionedListLayoutProvider; isScrollingNotifier: _isScrollingNotifier,
}, scrollController: scrollController,
); cacheExtent: cacheExtent,
}, ),
), );
final scaler = GridScaleGestureDetector<AvesEntry>(
tileExtentManager: tileExtentManager,
scrollableKey: _scrollableKey,
appBarHeightNotifier: _appBarHeightNotifier,
viewportSize: viewportSize,
gridBuilder: (center, extent, child) => CustomPaint(
// painting the thumbnail half-border on top of the grid yields artifacts,
// so we use a `foregroundPainter` to cover them instead
foregroundPainter: GridPainter(
center: center,
extent: extent,
spacing: tileExtentManager.spacing,
strokeWidth: DecoratedThumbnail.borderWidth * 2,
color: DecoratedThumbnail.borderColor,
),
child: child,
),
scaledBuilder: (entry, extent) => DecoratedThumbnail(
entry: entry,
extent: extent,
selectable: false,
highlightable: false,
),
getScaledItemTileRect: (context, entry) {
final sectionedListLayout = context.read<SectionedListLayout<AvesEntry>>();
return sectionedListLayout.getTileRect(entry) ?? Rect.zero;
},
onScaled: (entry) => context.read<HighlightInfo>().set(entry),
child: scrollView,
);
final selector = GridSelectionGestureDetector(
selectable: AvesApp.mode == AppMode.main,
collection: collection,
scrollController: scrollController,
appBarHeightNotifier: _appBarHeightNotifier,
child: scaler,
);
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: _tileExtentNotifier,
builder: (context, tileExtent, child) => SectionedEntryListLayoutProvider(
collection: collection,
scrollableWidth: viewportSize.width,
tileExtent: tileExtent,
columnCount: tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent),
tileBuilder: (entry) => InteractiveThumbnail(
key: ValueKey(entry.contentId),
collection: collection,
entry: entry,
tileExtent: tileExtent,
isScrollingNotifier: _isScrollingNotifier,
),
child: selector,
),
);
return sectionedListLayoutProvider;
},
);
},
), ),
); );
} }

View file

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

View file

@ -1,5 +1,6 @@
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/services/android_file_service.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:aves/widgets/dialogs/aves_dialog.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -9,24 +10,26 @@ mixin PermissionAwareMixin {
} }
Future<bool> checkStoragePermissionForAlbums(BuildContext context, Set<String> albumPaths) async { Future<bool> checkStoragePermissionForAlbums(BuildContext context, Set<String> albumPaths) async {
final restrictedDirs = await AndroidFileService.getRestrictedDirectories();
while (true) { while (true) {
final dirs = await AndroidFileService.getInaccessibleDirectories(albumPaths); final dirs = await AndroidFileService.getInaccessibleDirectories(albumPaths);
if (dirs == null) return false; if (dirs == null) return false;
if (dirs.isEmpty) return true; if (dirs.isEmpty) return true;
final dir = dirs.first; final restrictedInaccessibleDir = dirs.firstWhere(restrictedDirs.contains, orElse: () => null);
final volumePath = dir['volumePath'] as String; if (restrictedInaccessibleDir != null) {
final volumeDescription = dir['volumeDescription'] as String; await showRestrictedDirectoryDialog(context, restrictedInaccessibleDir);
final relativeDir = dir['relativeDir'] as String; return false;
final dirDisplayName = relativeDir.isEmpty ? 'root' : '$relativeDir'; }
final dir = dirs.first;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) { builder: (context) {
return AvesDialog( return AvesDialog(
context: context, context: context,
title: 'Storage Volume Access', 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: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@ -43,11 +46,30 @@ mixin PermissionAwareMixin {
// abort if the user cancels in Flutter // abort if the user cancels in Flutter
if (confirmed == null || !confirmed) return false; if (confirmed == null || !confirmed) return false;
final granted = await AndroidFileService.requestVolumeAccess(volumePath); final granted = await AndroidFileService.requestVolumeAccess(dir.volumePath);
if (!granted) { if (!granted) {
// abort if the user denies access from the native dialog // abort if the user denies access from the native dialog
return false; 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. // - a vertically scrollable body.
// It will prevent the body from scrolling when a user swipe from bottom to use Android Q style navigation gestures. // It will prevent the body from scrolling when a user swipe from bottom to use Android Q style navigation gestures.
class BottomGestureAreaProtector extends StatelessWidget { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Selector<MediaQueryData, double>( return Selector<MediaQueryData, double>(
selector: (c, mq) => mq.effectiveBottomPadding, selector: (c, mq) => mq.systemGestureInsets.bottom,
builder: (c, mqPaddingBottom, child) { builder: (c, systemGestureBottom, child) {
// devices with physical navigation buttons have no bottom insets
// we assume these devices do not use gesture navigation
if (mqPaddingBottom == 0) return SizedBox();
return Positioned( return Positioned(
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
height: systemGestureInsetsBottom, height: systemGestureBottom,
child: AbsorbPointer(), child: AbsorbPointer(),
); );
}, },

View file

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

View file

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

View file

@ -26,7 +26,7 @@ abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ProxyProvider0<SectionedListLayout<T>>( return ProxyProvider0<SectionedListLayout<T>>(
update: (context, __) => _updateLayouts(context), update: (context, _) => _updateLayouts(context),
child: child, 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/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/theme/icons.dart';
import 'package:aves/utils/constants.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/material.dart';
import 'package:flutter/scheduler.dart';
typedef FilterCallback = void Function(CollectionFilter filter); 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 } enum HeroType { always, onTap, never }
@ -26,7 +35,6 @@ class AvesFilterChip extends StatefulWidget {
static const double minChipHeight = kMinInteractiveDimension; static const double minChipHeight = kMinInteractiveDimension;
static const double minChipWidth = 80; static const double minChipWidth = 80;
static const double maxChipWidth = 160; static const double maxChipWidth = 160;
static const double iconSize = 20;
const AvesFilterChip({ const AvesFilterChip({
Key key, Key key,
@ -39,10 +47,43 @@ class AvesFilterChip extends StatefulWidget {
this.padding = 6.0, this.padding = 6.0,
this.heroType = HeroType.onTap, this.heroType = HeroType.onTap,
this.onTap, this.onTap,
this.onLongPress, this.onLongPress = showDefaultLongPressMenu,
}) : assert(filter != null), }) : assert(filter != null),
super(key: key); 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 @override
_AvesFilterChipState createState() => _AvesFilterChipState(); _AvesFilterChipState createState() => _AvesFilterChipState();
} }
@ -88,7 +129,8 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const iconSize = AvesFilterChip.iconSize; final textScaleFactor = MediaQuery.textScaleFactorOf(context);
final iconSize = 20 * textScaleFactor;
final hasBackground = widget.background != null; final hasBackground = widget.background != null;
final leading = filter.iconBuilder(context, iconSize, showGenericIcon: widget.showGenericIcon, embossed: hasBackground); final leading = filter.iconBuilder(context, iconSize, showGenericIcon: widget.showGenericIcon, embossed: hasBackground);
@ -178,7 +220,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
setState(() => _tapped = true); setState(() => _tapped = true);
} }
: null, : null,
onLongPress: widget.onLongPress != null ? () => widget.onLongPress(filter, _tapPosition) : null, onLongPress: widget.onLongPress != null ? () => widget.onLongPress(context, filter, _tapPosition) : null,
borderRadius: borderRadius, borderRadius: borderRadius,
child: FutureBuilder<Color>( child: FutureBuilder<Color>(
future: _colorFuture, future: _colorFuture,

View file

@ -7,6 +7,7 @@ import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:decorated_icon/decorated_icon.dart'; import 'package:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class VideoIcon extends StatelessWidget { class VideoIcon extends StatelessWidget {
final AvesEntry entry; final AvesEntry entry;
@ -172,10 +173,26 @@ class IconUtils {
static Widget getAlbumIcon({ static Widget getAlbumIcon({
@required BuildContext context, @required BuildContext context,
@required String album, @required String album,
double size = 24, double size,
bool embossed = false, 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)) { switch (androidFileUtils.getAlbumType(album)) {
case AlbumType.camera: case AlbumType.camera:
return buildIcon(AIcons.cameraAlbum); return buildIcon(AIcons.cameraAlbum);

View file

@ -1,3 +1,5 @@
import 'dart:ui';
import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/color_utils.dart';
import 'package:aves/widgets/common/fx/highlight_decoration.dart'; import 'package:aves/widgets/common/fx/highlight_decoration.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -29,7 +31,8 @@ class HighlightTitle extends StatelessWidget {
) )
], ],
fontSize: fontSize, fontSize: fontSize,
fontFamily: 'Concourse Caps', letterSpacing: 1.0,
fontFeatures: [FontFeature.enable('smcp')],
); );
return Align( 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:aves/widgets/viewer/info/common.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
class AppDebugPage extends StatefulWidget { class AppDebugPage extends StatefulWidget {
static const routeName = '/debug'; static const routeName = '/debug';
final CollectionSource source;
const AppDebugPage({this.source});
@override @override
State<StatefulWidget> createState() => _AppDebugPageState(); State<StatefulWidget> createState() => _AppDebugPageState();
} }
class _AppDebugPageState extends State<AppDebugPage> { class _AppDebugPageState extends State<AppDebugPage> {
CollectionSource get source => widget.source; CollectionSource get source => context.read<CollectionSource>();
Set<AvesEntry> get visibleEntries => source.visibleEntries; Set<AvesEntry> get visibleEntries => source.visibleEntries;
@ -63,7 +60,8 @@ class _AppDebugPageState extends State<AppDebugPage> {
Widget _buildGeneralTabView() { Widget _buildGeneralTabView() {
final catalogued = visibleEntries.where((entry) => entry.isCatalogued); final catalogued = visibleEntries.where((entry) => entry.isCatalogued);
final withGps = catalogued.where((entry) => entry.hasGps); 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( return AvesExpansionTile(
title: 'General', title: 'General',
children: [ children: [
@ -104,7 +102,8 @@ class _AppDebugPageState extends State<AppDebugPage> {
'Visible entries': '${visibleEntries.length}', 'Visible entries': '${visibleEntries.length}',
'Catalogued': '${catalogued.length}', 'Catalogued': '${catalogued.length}',
'With GPS': '${withGps.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), padding: EdgeInsets.symmetric(horizontal: 8),
child: InfoRowGroup({ child: InfoRowGroup({
'description': '${v.description}', 'description': '${v.description}',
'isEmulated': '${v.isEmulated}',
'isPrimary': '${v.isPrimary}', 'isPrimary': '${v.isPrimary}',
'isRemovable': '${v.isRemovable}', 'isRemovable': '${v.isRemovable}',
'state': '${v.state}', 'state': '${v.state}',

View file

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

View file

@ -2,6 +2,7 @@ import 'dart:io';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
@ -40,39 +41,29 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
@override @override
Widget build(BuildContext context) { 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( return AvesDialog(
context: context, context: context,
title: 'New Album', title: 'New Album',
scrollController: _scrollController, scrollController: _scrollController,
scrollableContent: [ scrollableContent: [
if (_allVolumes.length > 1) ...[ ...volumeTiles,
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),
],
Padding( Padding(
padding: AvesDialog.contentHorizontalPadding + EdgeInsets.only(bottom: 8), padding: AvesDialog.contentHorizontalPadding + EdgeInsets.only(bottom: 8),
child: ValueListenableBuilder<bool>( child: ValueListenableBuilder<bool>(
@ -83,7 +74,7 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
focusNode: _nameFieldFocusNode, focusNode: _nameFieldFocusNode,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Album name', labelText: 'Album name',
helperText: exists ? 'Album already exists' : '', helperText: exists ? 'Directory already exists' : '',
), ),
autofocus: _allVolumes.length == 1, autofocus: _allVolumes.length == 1,
onChanged: (_) => _validate(), 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 { void _onFocus() async {
// when the field gets focus, we wait for the soft keyboard to appear // 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 // 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