support Android Marshmallow API 23
This commit is contained in:
parent
312f94e87e
commit
284a918971
22 changed files with 233 additions and 156 deletions
|
@ -2,6 +2,8 @@
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
### Added
|
||||||
|
- support Android Marshmallow (API 23)
|
||||||
|
|
||||||
## [v1.3.4] - 2021-02-10
|
## [v1.3.4] - 2021-02-10
|
||||||
### Added
|
### Added
|
||||||
|
|
|
@ -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 23 ~ 30 (Marshmallow ~ R)
|
||||||
- Android integration (app shortcuts, handle view/pick intents)
|
- Android integration (app shortcuts, handle view/pick intents)
|
||||||
|
|
||||||
## Known Issues
|
## Known Issues
|
||||||
|
|
|
@ -53,8 +53,7 @@ android {
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "deckers.thibault.aves"
|
applicationId "deckers.thibault.aves"
|
||||||
// TODO TLAD try minSdkVersion 23
|
minSdkVersion 23
|
||||||
minSdkVersion 24
|
|
||||||
targetSdkVersion 30 // same as compileSdkVersion
|
targetSdkVersion 30 // same as compileSdkVersion
|
||||||
versionCode flutterVersionCode.toInteger()
|
versionCode flutterVersionCode.toInteger()
|
||||||
versionName flutterVersionName
|
versionName flutterVersionName
|
||||||
|
|
|
@ -50,14 +50,15 @@ 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,
|
"codeCacheDir" to context.codeCacheDir,
|
||||||
"filesDir" to context.filesDir,
|
"filesDir" to context.filesDir,
|
||||||
"noBackupFilesDir" to context.noBackupFilesDir,
|
"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.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) }
|
||||||
|
|
|
@ -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
|
||||||
|
@ -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 }
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
format.getSafeInt(MediaFormat.KEY_TRACK_ID) { page[KEY_TRACK_ID] = it }
|
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,8 +679,20 @@ 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 {
|
||||||
|
cursor = context.contentResolver.query(contentUri, projection, null, null, null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// throws SQLiteException when the requested prop is not a known column
|
||||||
|
result.error("getContentResolverProp-query", "failed to query for contentUri=$contentUri", e.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor == null || !cursor.moveToFirst()) {
|
||||||
|
result.error("getContentResolverProp-cursor", "failed to get cursor for contentUri=$contentUri", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var value: Any? = null
|
var value: Any? = null
|
||||||
try {
|
try {
|
||||||
value = when (cursor.getType(0)) {
|
value = when (cursor.getType(0)) {
|
||||||
|
@ -694,9 +708,6 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
cursor.close()
|
cursor.close()
|
||||||
result.success(value?.toString())
|
result.success(value?.toString())
|
||||||
} else {
|
|
||||||
result.error("getContentResolverProp-null", "failed to get cursor for contentUri=$contentUri", null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {
|
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
|
|
@ -4,9 +4,11 @@ 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 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
|
||||||
|
@ -31,31 +33,45 @@ 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 {
|
||||||
|
volumes.add(
|
||||||
|
hashMapOf(
|
||||||
|
"path" to volumePath,
|
||||||
|
"isPrimary" to (volumePath == primaryVolumePath),
|
||||||
|
"isRemovable" to Environment.isExternalStorageRemovable(volumeFile),
|
||||||
|
"state" to Environment.getExternalStorageState(volumeFile)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
result.success(volumes)
|
result.success(volumes)
|
||||||
}
|
}
|
||||||
|
@ -67,21 +83,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) {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -17,9 +17,6 @@ object MediaMetadataRetrieverHelper {
|
||||||
MediaMetadataRetriever.METADATA_KEY_BITRATE to "Bitrate",
|
MediaMetadataRetriever.METADATA_KEY_BITRATE to "Bitrate",
|
||||||
MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE to "Capture Framerate",
|
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",
|
||||||
|
@ -59,6 +56,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") }
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -63,7 +63,7 @@ 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 {
|
segments.relativeDir?.apply {
|
||||||
|
@ -80,27 +80,17 @@ 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 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 {
|
||||||
|
|
|
@ -177,30 +177,47 @@ 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"
|
||||||
}
|
}
|
||||||
val uuid = volume.uuid
|
volume.uuid?.let { uuid ->
|
||||||
if (uuid != null) {
|
|
||||||
return uuid.toUpperCase(Locale.ROOT)
|
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) {
|
||||||
|
context.getSystemService(StorageManager::class.java)?.let { sm ->
|
||||||
for (volumePath in getVolumePaths(context)) {
|
for (volumePath in getVolumePaths(context)) {
|
||||||
try {
|
try {
|
||||||
val volume = sm.getStorageVolume(File(volumePath))
|
val volume = sm.getStorageVolume(File(volumePath))
|
||||||
|
@ -212,6 +229,16 @@ object StorageUtils {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback for <N
|
||||||
|
for (volumePath in getVolumePaths(context)) {
|
||||||
|
val volumeUuid = volumePath.split(File.separator).lastOrNull { it.isNotEmpty() }
|
||||||
|
if (uuid.equals(volumeUuid, ignoreCase = true)) {
|
||||||
|
return volumePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Log.e(LOG_TAG, "failed to find volume path for UUID=$uuid")
|
Log.e(LOG_TAG, "failed to find volume path for UUID=$uuid")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
// 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()
|
||||||
jcenter()
|
jcenter()
|
||||||
|
@ -10,7 +10,7 @@ buildscript {
|
||||||
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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -132,7 +132,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);
|
||||||
|
|
|
@ -53,7 +53,7 @@ class AndroidFileService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// returns a list of directories,
|
// returns a list of directories,
|
||||||
// each directory is a map with "volumePath", "volumeDescription", "relativeDir"
|
// each directory is a map with "volumePath", "relativeDir"
|
||||||
static Future<List<Map>> getInaccessibleDirectories(Iterable<String> dirPaths) async {
|
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>{
|
||||||
|
|
|
@ -114,11 +114,10 @@ class Package {
|
||||||
|
|
||||||
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,10 +125,10 @@ 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'] ?? '',
|
||||||
|
|
|
@ -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';
|
||||||
|
|
||||||
|
@ -16,8 +17,10 @@ mixin PermissionAwareMixin {
|
||||||
|
|
||||||
final dir = dirs.first;
|
final dir = dirs.first;
|
||||||
final volumePath = dir['volumePath'] as String;
|
final volumePath = dir['volumePath'] as String;
|
||||||
final volumeDescription = dir['volumeDescription'] as String;
|
|
||||||
final relativeDir = dir['relativeDir'] as String;
|
final relativeDir = dir['relativeDir'] as String;
|
||||||
|
|
||||||
|
final volume = androidFileUtils.storageVolumes.firstWhere((volume) => volume.path == volumePath, orElse: () => null);
|
||||||
|
final volumeDescription = volume?.description ?? volumePath;
|
||||||
final dirDisplayName = relativeDir.isEmpty ? 'root' : '“$relativeDir”';
|
final dirDisplayName = relativeDir.isEmpty ? 'root' : '“$relativeDir”';
|
||||||
|
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
|
|
|
@ -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}',
|
||||||
|
|
|
@ -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>(
|
||||||
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue