Skip to content

Commit

Permalink
Merge branch 'develop'
Browse files Browse the repository at this point in the history
  • Loading branch information
deckerst committed Feb 26, 2021
2 parents f2bd7b2 + ef528c4 commit 9b74dd2
Show file tree
Hide file tree
Showing 139 changed files with 2,315 additions and 979 deletions.
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

## [v1.3.5] - 2021-02-26
### Added
- support Android KitKat, Lollipop & Marshmallow (API 19 ~ 23)
- quick country reverse geocoding without Play Services
- menu option to hide any filter
- menu option to navigate to the album / country / tag page from filter

### Changed
- analytics are opt-in

### Removed
- removed custom font used in titles and info page

## [v1.3.4] - 2021-02-10
### Added
- hide album / country / tag from collection
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
- search and filter by country, place, XMP tag, type (animated, raster, vector…)
- favorites
- statistics
- support Android API 24 ~ 30 (Nougat ~ R)
- support Android API 19 ~ 30 (KitKat ~ R)
- Android integration (app shortcuts, handle view/pick intents)

## Known Issues
Expand Down
3 changes: 1 addition & 2 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,7 @@ android {

defaultConfig {
applicationId "deckers.thibault.aves"
// TODO TLAD try minSdkVersion 23
minSdkVersion 24
minSdkVersion 19
targetSdkVersion 30 // same as compileSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
Expand Down
9 changes: 2 additions & 7 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,6 @@
https://developer.android.com/preview/privacy/storage#media-file-access
- raw path access:
https://developer.android.com/preview/privacy/storage#media-files-raw-paths
Android R issues:
- users cannot grant directory access to the root Downloads directory,
- users cannot grant directory access to the root directory of each reliable SD card volume
-->

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
Expand All @@ -31,9 +27,7 @@
<!-- to access media with unredacted metadata with scoped storage (Android Q+) -->
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />

<!-- TODO TLAD remove this permission once this issue is fixed:
https://github.com/flutter/flutter/issues/42451
-->
<!-- TODO TLAD remove this permission when this is fixed: https://github.com/flutter/flutter/issues/42451 -->
<uses-permission android:name="android.permission.WAKE_LOCK" />

<!-- from Android R, we should define <queries> to make other apps visible to this app -->
Expand All @@ -48,6 +42,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:requestLegacyExternalStorage="true">
<!-- TODO TLAD Android 12 https://developer.android.com/about/versions/12/behavior-changes-12#exported -->
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,13 @@ class MainActivity : FlutterActivity() {

MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
MethodChannel(messenger, AppShortcutHandler.CHANNEL).setMethodCallHandler(AppShortcutHandler(this))
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(MetadataHandler(this))
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
MethodChannel(messenger, TimeHandler.CHANNEL).setMethodCallHandler(TimeHandler())
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(WindowHandler(this))

StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) }
StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageOpStreamHandler(this, args) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.net.Uri
import android.util.Log
Expand Down Expand Up @@ -89,7 +88,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
@Suppress("DEPRECATION")
resources.updateConfiguration(englishConfig, resources.displayMetrics)
englishLabel = resources.getString(labelRes)
} catch (e: PackageManager.NameNotFoundException) {
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get app label in English for packageName=$packageName", e)
}
englishLabel
Expand Down Expand Up @@ -145,7 +144,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
Log.w(LOG_TAG, "failed to decode app icon for packageName=$packageName", e)
}
Glide.with(context).clear(target)
} catch (e: PackageManager.NameNotFoundException) {
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get app info for packageName=$packageName", e)
return
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,23 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
}

private fun getContextDirs() = hashMapOf(
"dataDir" to context.dataDir,
"cacheDir" to context.cacheDir,
"codeCacheDir" to context.codeCacheDir,
"filesDir" to context.filesDir,
"noBackupFilesDir" to context.noBackupFilesDir,
"obbDir" to context.obbDir,
"externalCacheDir" to context.externalCacheDir,
).mapValues { it.value?.path }
).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
putAll(
hashMapOf(
"codeCacheDir" to context.codeCacheDir,
"noBackupFilesDir" to context.noBackupFilesDir,
)
)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
put("dataDir", context.dataDir)
}
}.mapValues { it.value?.path }

private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
pageId = pageId,
sampleSize = sampleSize,
regionRect = regionRect,
imageSize = Size(imageWidth, imageHeight),
imageWidth = imageWidth,
imageHeight = imageHeight,
result = result,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
// optional parent to distinguish child directories of the same type
dir.parent?.name?.let { dirName = "$it/$dirName" }

val dirMap = metadataMap.getOrDefault(dirName, HashMap())
val dirMap = metadataMap[dirName] ?: HashMap()
metadataMap[dirName] = dirMap

// tags
Expand Down Expand Up @@ -325,7 +325,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)) {
val count = xmpMeta.countArrayItems(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)
val values = (1 until count + 1).map { xmpMeta.getArrayItem(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME, it).value }
metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(separator = XMP_SUBJECTS_SEPARATOR)
metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(XMP_SUBJECTS_SEPARATOR)
}
xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.TITLE_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it }
if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) {
Expand All @@ -350,7 +350,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
// XMP fallback to IPTC
if (!metadataMap.containsKey(KEY_XMP_SUBJECTS)) {
for (dir in metadata.getDirectoriesOfType(IptcDirectory::class.java)) {
dir.keywords?.let { metadataMap[KEY_XMP_SUBJECTS] = it.joinToString(separator = XMP_SUBJECTS_SEPARATOR) }
dir.keywords?.let { metadataMap[KEY_XMP_SUBJECTS] = it.joinToString(XMP_SUBJECTS_SEPARATOR) }
}
}

Expand Down Expand Up @@ -594,7 +594,9 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
KEY_MIME_TYPE to trackMime,
)
format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { page[KEY_IS_DEFAULT] = it != 0 }
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_HEIGHT) { page[KEY_HEIGHT] = it }
if (isVideo(trackMime)) {
Expand Down Expand Up @@ -677,26 +679,35 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}

val projection = arrayOf(prop)
val cursor = context.contentResolver.query(contentUri, projection, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
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)
val cursor: Cursor?
try {
cursor = context.contentResolver.query(contentUri, projection, null, null, null)
} catch (e: Exception) {
// throws SQLiteException when the requested prop is not a known column
result.error("getContentResolverProp-query", "failed to query for contentUri=$contentUri", e.message)
return
}

if (cursor == null || !cursor.moveToFirst()) {
result.error("getContentResolverProp-cursor", "failed to get cursor for contentUri=$contentUri", null)
return
}

var value: Any? = null
try {
value = when (cursor.getType(0)) {
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
}
cursor.close()
result.success(value?.toString())
} else {
result.error("getContentResolverProp-null", "failed to get cursor for contentUri=$contentUri", 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import android.content.Context
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager
import androidx.core.os.EnvironmentCompat
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.utils.PermissionManager
import deckers.thibault.aves.utils.StorageUtils.getPrimaryVolumePath
import deckers.thibault.aves.utils.StorageUtils.getVolumePaths
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
Expand All @@ -24,38 +27,60 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
"getFreeSpace" -> safe(call, result, ::getFreeSpace)
"getGrantedDirectories" -> safe(call, result, ::getGrantedDirectories)
"getInaccessibleDirectories" -> safe(call, result, ::getInaccessibleDirectories)
"getRestrictedDirectories" -> safe(call, result, ::getRestrictedDirectories)
"revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess)
"scanFile" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::scanFile) }
else -> result.notImplemented()
}
}

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)
if (sm != null) {
for (volumePath in getVolumePaths(context)) {
try {
sm.getStorageVolume(File(volumePath))?.let {
val volumeMap = HashMap<String, Any>()
volumeMap["path"] = volumePath
volumeMap["description"] = it.getDescription(context)
volumeMap["isPrimary"] = it.isPrimary
volumeMap["isRemovable"] = it.isRemovable
volumeMap["isEmulated"] = it.isEmulated
volumeMap["state"] = it.state
volumes.add(volumeMap)
volumes.add(
hashMapOf(
"path" to volumePath,
"description" to it.getDescription(context),
"isPrimary" to it.isPrimary,
"isRemovable" to it.isRemovable,
"state" to it.state,
)
)
}
} catch (e: IllegalArgumentException) {
// ignore
}
}
}
volumes
} else {
// TODO TLAD find alternative for Android <N
emptyList()
val primaryVolumePath = getPrimaryVolumePath(context)
for (volumePath in getVolumePaths(context)) {
val volumeFile = File(volumePath)
try {
val isPrimary = volumePath == primaryVolumePath
val isRemovable = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
Environment.isExternalStorageRemovable(volumeFile)
} else {
// random guess
!isPrimary
}
volumes.add(
hashMapOf(
"path" to volumePath,
"isPrimary" to isPrimary,
"isRemovable" to isRemovable,
"state" to EnvironmentCompat.getStorageState(volumeFile)
)
)
} catch (e: IllegalArgumentException) {
// ignore
}
}
}
result.success(volumes)
}
Expand All @@ -67,21 +92,9 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
return
}

val sm = context.getSystemService(StorageManager::class.java)
if (sm == null) {
result.error("getFreeSpace-sm", "failed because of missing Storage Manager", null)
return
}

val file = File(path)
val volume = sm.getStorageVolume(file)
if (volume == null) {
result.error("getFreeSpace-volume", "failed because of missing volume for path=$path", null)
return
}

// `StorageStatsManager` `getFreeBytes()` is only available from API 26,
// and non-primary volume UUIDs cannot be used with it
val file = File(path)
try {
result.success(file.freeSpace)
} catch (e: SecurityException) {
Expand All @@ -100,8 +113,11 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
return
}

val dirs = PermissionManager.getInaccessibleDirectories(context, dirPaths)
result.success(dirs)
result.success(PermissionManager.getInaccessibleDirectories(context, dirPaths))
}

private fun getRestrictedDirectories(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
result.success(PermissionManager.getRestrictedDirectories(context))
}

private fun revokeDirectoryAccess(call: MethodCall, result: MethodChannel.Result) {
Expand All @@ -111,6 +127,11 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
return
}

if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
result.error("revokeDirectoryAccess-unsupported", "volume access is not allowed before Android Lollipop", null)
return
}

val success = PermissionManager.revokeDirectoryAccess(context, path)
result.success(success)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading

0 comments on commit 9b74dd2

Please sign in to comment.