Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2023-05-26 23:08:12 +02:00
commit 79c5f5777b
374 changed files with 8073 additions and 3755 deletions

@ -1 +1 @@
Subproject commit 4d9e56e694b656610ab87fcf2efbcd226e0ed8cf Subproject commit 9cd3d0d9ff05768afa249e036acc66e8abe93bff

View file

@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file.
## <a id="unreleased"></a>[Unreleased] ## <a id="unreleased"></a>[Unreleased]
## <a id="v1.8.7"></a>[v1.8.7] - 2023-05-26
### Added
- option to set the Tags page as home
- support for animated PNG (requires rescan)
- Info: added day filter with item date
- Widget: option to update image on tap
- Slideshow / Screen saver: option for random transition
- Norwegian (Nynorsk) translation (thanks tryvseu)
### Changed
- keep showing empty albums if are pinned
- remember whether to show the title filter when picking albums
- upgraded Flutter to stable v3.10.2
### Fixed
- crash when cataloguing PSD with large XMP
- crash when cataloguing large HEIF
## <a id="v1.8.6"></a>[v1.8.6] - 2023-04-30 ## <a id="v1.8.6"></a>[v1.8.6] - 2023-04-30
### Added ### Added
@ -330,8 +352,8 @@ All notable changes to this project will be documented in this file.
- Albums / Countries / Tags: live title filter - Albums / Countries / Tags: live title filter
- option to hide confirmation message after moving items to the bin - option to hide confirmation message after moving items to the bin
- Collection / Info: edit description via Exif / IPTC / XMP - Collection / Info: edit description via Exif / IPTC / XMP
- Info: read XMP from HEIC on Android >=11 - Info: read XMP from HEIF on Android >=11
- Collection: support HEIC motion photos on Android >=11 - Collection: support HEIF motion photos on Android >=11
- Search: `recently added` filter - Search: `recently added` filter
- Dutch translation (thanks Martijn Fabrie, Koen Koppens) - Dutch translation (thanks Martijn Fabrie, Koen Koppens)
@ -742,7 +764,7 @@ All notable changes to this project will be documented in this file.
### Fixed ### Fixed
- auto album identification and naming - auto album identification and naming
- opening HEIC images from downloads content URI on Android >=11 - opening HEIF images from downloads content URI on Android >=11
## [v1.4.7] - 2021-08-06 [YANKED] ## [v1.4.7] - 2021-08-06 [YANKED]
@ -837,7 +859,7 @@ All notable changes to this project will be documented in this file.
### Added ### Added
- Motion photo support - Motion photo support
- Viewer: play videos in multi-track HEIC - Viewer: play videos in multi-track HEIF
- Handle share intent - Handle share intent
### Changed ### Changed
@ -846,7 +868,7 @@ All notable changes to this project will be documented in this file.
### Fixed ### Fixed
- fixed crash when cataloguing large MP4/PSD - crash when cataloguing large MP4/PSD
- prevent videos playing in the background when quickly switching entries - prevent videos playing in the background when quickly switching entries
## [v1.4.0] - 2021-04-16 ## [v1.4.0] - 2021-04-16
@ -964,7 +986,7 @@ All notable changes to this project will be documented in this file.
### Added ### Added
Collection: identify multipage TIFF & multitrack HEIC/HEIF Viewer: support for multipage TIFF Collection: identify multipage TIFF & multitrack HEIF Viewer: support for multipage TIFF
Viewer: support for cropped panoramas Albums: grouping options Viewer: support for cropped panoramas Albums: grouping options
### Changed ### Changed
@ -1075,7 +1097,7 @@ upgraded libtiff to 4.2.0 for TIFF decoding
- Viewer: leave when the loaded item is deleted and it is the last one - Viewer: leave when the loaded item is deleted and it is the last one
- Viewer: refresh the viewer overlay and info page when the loaded image is modified - Viewer: refresh the viewer overlay and info page when the loaded image is modified
- Info: prevent reporting a "Media" section for images other than HEIC/HEIF - Info: prevent reporting a "Media" section for images other than HEIF
- Fixed opening items shared via a "file" media content URI - Fixed opening items shared via a "file" media content URI
### Removed ### Removed

View file

@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins { plugins {
id 'com.android.application' id 'com.android.application'
id 'kotlin-android' id 'kotlin-android'
@ -36,17 +38,17 @@ if (keystorePropertiesFile.exists()) {
// for release using credentials in environment variables set up by GitHub Actions // for release using credentials in environment variables set up by GitHub Actions
// warning: in property file, single quotes should be escaped with a backslash // warning: in property file, single quotes should be escaped with a backslash
// but they should not be escaped when stored in env variables // but they should not be escaped when stored in env variables
keystoreProperties['storeFile'] = System.getenv('AVES_STORE_FILE') ?: '<NONE>' keystoreProperties["storeFile"] = System.getenv("AVES_STORE_FILE") ?: "<NONE>"
keystoreProperties['storePassword'] = System.getenv('AVES_STORE_PASSWORD') ?: '<NONE>' keystoreProperties["storePassword"] = System.getenv("AVES_STORE_PASSWORD") ?: "<NONE>"
keystoreProperties['keyAlias'] = System.getenv('AVES_KEY_ALIAS') ?: '<NONE>' keystoreProperties["keyAlias"] = System.getenv("AVES_KEY_ALIAS") ?: "<NONE>"
keystoreProperties['keyPassword'] = System.getenv('AVES_KEY_PASSWORD') ?: '<NONE>' keystoreProperties["keyPassword"] = System.getenv("AVES_KEY_PASSWORD") ?: "<NONE>"
keystoreProperties['googleApiKey'] = System.getenv('AVES_GOOGLE_API_KEY') ?: '<NONE>' keystoreProperties["googleApiKey"] = System.getenv("AVES_GOOGLE_API_KEY") ?: "<NONE>"
keystoreProperties['huaweiApiKey'] = System.getenv('AVES_HUAWEI_API_KEY') ?: '<NONE>' keystoreProperties["huaweiApiKey"] = System.getenv("AVES_HUAWEI_API_KEY") ?: "<NONE>"
} }
android { android {
namespace 'deckers.thibault.aves' namespace 'deckers.thibault.aves'
compileSdkVersion 33 compileSdk 33
ndkVersion flutter.ndkVersion ndkVersion flutter.ndkVersion
compileOptions { compileOptions {
@ -60,10 +62,6 @@ android {
disable 'InvalidPackage' disable 'InvalidPackage'
} }
kotlinOptions {
jvmTarget = '1.8'
}
sourceSets { sourceSets {
main.java.srcDirs += 'src/main/kotlin' main.java.srcDirs += 'src/main/kotlin'
} }
@ -80,21 +78,21 @@ android {
targetSdkVersion 33 targetSdkVersion 33
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
manifestPlaceholders = [googleApiKey: keystoreProperties['googleApiKey'] ?: '<NONE>', manifestPlaceholders = [googleApiKey: keystoreProperties["googleApiKey"] ?: "<NONE>",
huaweiApiKey: keystoreProperties['huaweiApiKey'] ?: '<NONE>'] huaweiApiKey: keystoreProperties["huaweiApiKey"] ?: "<NONE>"]
multiDexEnabled true multiDexEnabled true
} }
signingConfigs { signingConfigs {
release { release {
keyAlias keystoreProperties['keyAlias'] keyAlias keystoreProperties["keyAlias"]
keyPassword keystoreProperties['keyPassword'] keyPassword keystoreProperties["keyPassword"]
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null storeFile keystoreProperties["storeFile"] ? file(keystoreProperties["storeFile"]) : null
storePassword keystoreProperties['storePassword'] storePassword keystoreProperties["storePassword"]
} }
} }
flavorDimensions "store" flavorDimensions = ["store"]
productFlavors { productFlavors {
play { play {
@ -174,6 +172,15 @@ android {
} }
} }
tasks.withType(KotlinCompile).configureEach {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
kotlinOptions {
jvmTarget = '1.8'
}
}
flutter { flutter {
source '../..' source '../..'
} }
@ -195,20 +202,21 @@ repositories {
} }
dependencies { dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1'
implementation "androidx.appcompat:appcompat:1.6.1" implementation "androidx.appcompat:appcompat:1.6.1"
implementation 'androidx.core:core-ktx:1.10.0' implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.exifinterface:exifinterface:1.3.6' implementation 'androidx.exifinterface:exifinterface:1.3.6'
implementation 'androidx.lifecycle:lifecycle-process:2.6.1' implementation 'androidx.lifecycle:lifecycle-process:2.6.1'
implementation 'androidx.media:media:1.6.0' implementation 'androidx.media:media:1.6.0'
implementation 'androidx.multidex:multidex:2.0.1' implementation 'androidx.multidex:multidex:2.0.1'
implementation 'androidx.security:security-crypto:1.1.0-alpha06' implementation 'androidx.security:security-crypto:1.1.0-alpha06'
implementation 'androidx.work:work-runtime-ktx:2.8.1'
implementation 'com.caverock:androidsvg-aar:1.4' implementation 'com.caverock:androidsvg-aar:1.4'
implementation 'com.commonsware.cwac:document:0.5.0' implementation 'com.commonsware.cwac:document:0.5.0'
implementation 'com.drewnoakes:metadata-extractor:2.18.0' implementation 'com.drewnoakes:metadata-extractor:2.18.0'
implementation 'com.github.bumptech.glide:glide:4.15.1' implementation "com.github.bumptech.glide:glide:$glide_version"
// SLF4J implementation for `mp4parser` // SLF4J implementation for `mp4parser`
implementation 'org.slf4j:slf4j-simple:2.0.7' implementation 'org.slf4j:slf4j-simple:2.0.7'
@ -217,15 +225,17 @@ dependencies {
// - https://jitpack.io/p/deckerst/mp4parser // - https://jitpack.io/p/deckerst/mp4parser
// - https://jitpack.io/p/deckerst/pixymeta-android // - https://jitpack.io/p/deckerst/pixymeta-android
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a' implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a'
implementation 'com.github.deckerst.mp4parser:isoparser:42f2cdc087' implementation 'com.github.deckerst.mp4parser:isoparser:b7b853f2e3'
implementation 'com.github.deckerst.mp4parser:muxer:42f2cdc087' implementation 'com.github.deckerst.mp4parser:muxer:b7b853f2e3'
implementation 'com.github.deckerst:pixymeta-android:706bd73d6e' implementation 'com.github.deckerst:pixymeta-android:706bd73d6e'
// huawei flavor only // huawei flavor only
huaweiImplementation 'com.huawei.agconnect:agconnect-core:1.8.0.300' huaweiImplementation "com.huawei.agconnect:agconnect-core:$huawei_agconnect_version"
testImplementation "org.junit.jupiter:junit-jupiter-engine:5.9.2"
kapt 'androidx.annotation:annotation:1.6.0' kapt 'androidx.annotation:annotation:1.6.0'
kapt 'com.github.bumptech.glide:compiler:4.15.1' kapt "com.github.bumptech.glide:compiler:$glide_version"
compileOnly rootProject.findProject(':streams_channel') compileOnly rootProject.findProject(':streams_channel')
} }

View file

@ -1,14 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!--
Gradle v7.4 / Android Gradle Plugin v7.3.0 recommend:
- removing "package" from AndroidManifest.xml
- adding it as "namespace" in app/build.gradle
This change eventually prevents building the app with Flutter v3.7.11.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
package="deckers.thibault.aves"
android:installLocation="auto"> android:installLocation="auto">
<uses-feature <uses-feature
@ -19,14 +12,18 @@ This change eventually prevents building the app with Flutter v3.7.11.
android:required="false" /> android:required="false" />
<!-- <!--
Scoped storage on Android 10 (API 29) is inconvenient because users need to confirm edition on each individual file. TODO TLAD [Android 14 (API 34)] request/handle READ_MEDIA_VISUAL_USER_SELECTED permission
So we request `WRITE_EXTERNAL_STORAGE` until Android 10 (API 29), and enable `requestLegacyExternalStorage` cf https://developer.android.com/about/versions/14/changes/partial-photo-video-access
--> -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission <uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE" android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<!--
Scoped storage on Android 10 (API 29) is inconvenient because users need to confirm edition on each individual file.
So we request `WRITE_EXTERNAL_STORAGE` until Android 10 (API 29), and enable `requestLegacyExternalStorage`
-->
<uses-permission <uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" android:maxSdkVersion="29"
@ -142,6 +139,15 @@ This change eventually prevents building the app with Flutter v3.7.11.
<data android:mimeType="vnd.android.cursor.dir/image" /> <data android:mimeType="vnd.android.cursor.dir/image" />
<data android:mimeType="vnd.android.cursor.dir/video" /> <data android:mimeType="vnd.android.cursor.dir/video" />
</intent-filter> </intent-filter>
<!--
<intent-filter>
<action android:name="android.intent.action.EDIT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
<data android:mimeType="vnd.android.cursor.dir/image" />
</intent-filter>
-->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.GET_CONTENT" /> <action android:name="android.intent.action.GET_CONTENT" />
@ -242,11 +248,6 @@ This change eventually prevents building the app with Flutter v3.7.11.
</intent-filter> </intent-filter>
</service> </service>
<service
android:name=".AnalysisService"
android:description="@string/analysis_service_description"
android:exported="false" />
<service <service
android:name=".ScreenSaverService" android:name=".ScreenSaverService"
android:exported="true" android:exported="true"
@ -294,7 +295,7 @@ This change eventually prevents building the app with Flutter v3.7.11.
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<!-- as of Flutter v3.7.7, background blur yields black screen with Impeller --> <!-- as of Flutter v3.10.1, Impeller badly renders text, fails to render videos, and crashes with Google Maps -->
<meta-data <meta-data
android:name="io.flutter.embedding.android.EnableImpeller" android:name="io.flutter.embedding.android.EnableImpeller"
android:value="false" /> android:value="false" />

View file

@ -1,258 +0,0 @@
package deckers.thibault.aves
import android.app.Notification
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.*
import android.util.Log
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.MainActivity.Companion.OPEN_FROM_ANALYSIS_SERVICE
import deckers.thibault.aves.channel.calls.*
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler
import deckers.thibault.aves.utils.FlutterUtils
import deckers.thibault.aves.utils.LogUtils
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.runBlocking
class AnalysisService : Service() {
private var flutterEngine: FlutterEngine? = null
private var backgroundChannel: MethodChannel? = null
private var serviceLooper: Looper? = null
private var serviceHandler: ServiceHandler? = null
private val analysisServiceBinder = AnalysisServiceBinder()
override fun onCreate() {
Log.i(LOG_TAG, "Create analysis service")
runBlocking {
FlutterUtils.initFlutterEngine(this@AnalysisService, SHARED_PREFERENCES_KEY, CALLBACK_HANDLE_KEY) {
flutterEngine = it
}
}
try {
initChannels(this)
HandlerThread("Analysis service handler", Process.THREAD_PRIORITY_BACKGROUND).apply {
start()
serviceLooper = looper
serviceHandler = ServiceHandler(looper)
}
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to initialize service", e)
}
}
override fun onDestroy() {
Log.i(LOG_TAG, "Destroy analysis service")
}
override fun onBind(intent: Intent) = analysisServiceBinder
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val channel = NotificationChannelCompat.Builder(CHANNEL_ANALYSIS, NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getText(R.string.analysis_channel_name))
.setShowBadge(false)
.build()
NotificationManagerCompat.from(this).createNotificationChannel(channel)
startForeground(NOTIFICATION_ID, buildNotification())
val msgData = Bundle()
intent?.extras?.let {
msgData.putAll(it)
}
serviceHandler?.obtainMessage()?.let { msg ->
msg.arg1 = startId
msg.data = msgData
serviceHandler?.sendMessage(msg)
}
return START_NOT_STICKY
}
private fun detachAndStop() {
analysisServiceBinder.detach()
stopSelf()
}
private fun initChannels(context: Context) {
val engine = flutterEngine
engine ?: throw Exception("Flutter engine is not initialized")
val messenger = engine.dartExecutor
// channels for analysis
// dart -> platform -> dart
// - need Context
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(context))
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(context))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(context))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(context))
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(context))
// result streaming: dart -> platform ->->-> dart
// - need Context
StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(context, args) }
StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(context, args) }
// channel for service management
backgroundChannel = MethodChannel(messenger, BACKGROUND_CHANNEL).apply {
setMethodCallHandler { call, result -> onMethodCall(call, result) }
}
}
private fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"initialized" -> {
Log.d(LOG_TAG, "background channel is ready")
result.success(null)
}
"updateNotification" -> {
val title = call.argument<String>("title")
val message = call.argument<String>("message")
val notification = buildNotification(title, message)
NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, notification)
result.success(null)
}
"refreshApp" -> {
analysisServiceBinder.refreshApp()
result.success(null)
}
"stop" -> {
detachAndStop()
result.success(null)
}
else -> result.notImplemented()
}
}
private fun buildNotification(title: String? = null, message: String? = null): Notification {
val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val stopServiceIntent = Intent(this, AnalysisService::class.java).let {
it.putExtra(KEY_COMMAND, COMMAND_STOP)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
PendingIntent.getForegroundService(this, STOP_SERVICE_REQUEST, it, pendingIntentFlags)
} else {
PendingIntent.getService(this, STOP_SERVICE_REQUEST, it, pendingIntentFlags)
}
}
val openAppIntent = Intent(this, MainActivity::class.java).let {
PendingIntent.getActivity(this, OPEN_FROM_ANALYSIS_SERVICE, it, pendingIntentFlags)
}
val stopAction = NotificationCompat.Action.Builder(
R.drawable.ic_outline_stop_24,
getString(R.string.analysis_notification_action_stop),
stopServiceIntent
).build()
val icon = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) R.drawable.ic_notification else R.mipmap.ic_launcher_round
return NotificationCompat.Builder(this, CHANNEL_ANALYSIS)
.setContentTitle(title ?: getText(R.string.analysis_notification_default_title))
.setContentText(message)
.setBadgeIconType(NotificationCompat.BADGE_ICON_NONE)
.setSmallIcon(icon)
.setContentIntent(openAppIntent)
.setPriority(NotificationCompat.PRIORITY_LOW)
.addAction(stopAction)
.build()
}
private inner class ServiceHandler(looper: Looper) : Handler(looper) {
override fun handleMessage(msg: Message) {
val data = msg.data
when (data.getString(KEY_COMMAND)) {
COMMAND_START -> {
runBlocking {
FlutterUtils.runOnUiThread {
val entryIds = data.getIntArray(KEY_ENTRY_IDS)?.toList()
backgroundChannel?.invokeMethod(
"start", hashMapOf(
"entryIds" to entryIds,
"force" to data.getBoolean(KEY_FORCE),
)
)
}
}
}
COMMAND_STOP -> {
// unconditionally stop the service
runBlocking {
FlutterUtils.runOnUiThread {
backgroundChannel?.invokeMethod("stop", null)
}
}
detachAndStop()
}
else -> {
}
}
}
}
companion object {
private val LOG_TAG = LogUtils.createTag<AnalysisService>()
private const val BACKGROUND_CHANNEL = "deckers.thibault/aves/analysis_service_background"
const val SHARED_PREFERENCES_KEY = "analysis_service"
const val CALLBACK_HANDLE_KEY = "callback_handle"
const val NOTIFICATION_ID = 1
const val STOP_SERVICE_REQUEST = 1
const val CHANNEL_ANALYSIS = "analysis"
const val KEY_COMMAND = "command"
const val COMMAND_START = "start"
const val COMMAND_STOP = "stop"
const val KEY_ENTRY_IDS = "entry_ids"
const val KEY_FORCE = "force"
}
}
class AnalysisServiceBinder : Binder() {
private val listeners = hashSetOf<AnalysisServiceListener>()
fun startListening(listener: AnalysisServiceListener) = listeners.add(listener)
fun stopListening(listener: AnalysisServiceListener) = listeners.remove(listener)
fun refreshApp() {
val localListeners = listeners.toSet()
for (listener in localListeners) {
try {
listener.refreshApp()
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to notify listener=$listener", e)
}
}
}
fun detach() {
val localListeners = listeners.toSet()
for (listener in localListeners) {
try {
listener.detachFromActivity()
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to detach listener=$listener", e)
}
}
}
companion object {
private val LOG_TAG = LogUtils.createTag<AnalysisServiceBinder>()
}
}
interface AnalysisServiceListener {
fun refreshApp()
fun detachFromActivity()
}

View file

@ -0,0 +1,177 @@
package deckers.thibault.aves
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.calls.DeviceHandler
import deckers.thibault.aves.channel.calls.GeocodingHandler
import deckers.thibault.aves.channel.calls.MediaStoreHandler
import deckers.thibault.aves.channel.calls.MetadataFetchHandler
import deckers.thibault.aves.channel.calls.StorageHandler
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler
import deckers.thibault.aves.utils.FlutterUtils
import deckers.thibault.aves.utils.LogUtils
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.runBlocking
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
class AnalysisWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters) {
private var workCont: Continuation<Any?>? = null
private var flutterEngine: FlutterEngine? = null
private var backgroundChannel: MethodChannel? = null
override suspend fun doWork(): Result {
createNotificationChannel()
setForeground(createForegroundInfo())
suspendCoroutine { cont ->
workCont = cont
onStart()
}
return Result.success()
}
private fun onStart() {
Log.i(LOG_TAG, "Start analysis worker")
runBlocking {
FlutterUtils.initFlutterEngine(applicationContext, SHARED_PREFERENCES_KEY, CALLBACK_HANDLE_KEY) {
flutterEngine = it
}
}
try {
initChannels(applicationContext)
runBlocking {
FlutterUtils.runOnUiThread {
backgroundChannel?.invokeMethod(
"start", hashMapOf(
"entryIds" to inputData.getIntArray(KEY_ENTRY_IDS)?.toList(),
"force" to inputData.getBoolean(KEY_FORCE, false),
"progressTotal" to inputData.getInt(KEY_PROGRESS_TOTAL, 0),
"progressOffset" to inputData.getInt(KEY_PROGRESS_OFFSET, 0),
)
)
}
}
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to initialize worker", e)
workCont?.resumeWithException(e)
}
}
private fun initChannels(context: Context) {
val engine = flutterEngine
engine ?: throw Exception("Flutter engine is not initialized")
val messenger = engine.dartExecutor
// channels for analysis
// dart -> platform -> dart
// - need Context
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(context))
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(context))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(context))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(context))
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(context))
// result streaming: dart -> platform ->->-> dart
// - need Context
StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(context, args) }
StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(context, args) }
// channel for service management
backgroundChannel = MethodChannel(messenger, BACKGROUND_CHANNEL).apply {
setMethodCallHandler { call, result -> onMethodCall(call, result) }
}
}
private fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"initialized" -> {
Log.d(LOG_TAG, "Analysis background channel is ready")
result.success(null)
}
"updateNotification" -> {
val title = call.argument<String>("title")
val message = call.argument<String>("message")
setForegroundAsync(createForegroundInfo(title, message))
result.success(null)
}
"stop" -> {
workCont?.resume(null)
result.success(null)
}
else -> result.notImplemented()
}
}
private fun createNotificationChannel() {
val channel = NotificationChannelCompat.Builder(NOTIFICATION_CHANNEL, NotificationManagerCompat.IMPORTANCE_LOW)
.setName(applicationContext.getText(R.string.analysis_channel_name))
.setShowBadge(false)
.build()
NotificationManagerCompat.from(applicationContext).createNotificationChannel(channel)
}
private fun createForegroundInfo(title: String? = null, message: String? = null): ForegroundInfo {
val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val openAppIntent = Intent(applicationContext, MainActivity::class.java).let {
PendingIntent.getActivity(applicationContext, MainActivity.OPEN_FROM_ANALYSIS_SERVICE, it, pendingIntentFlags)
}
val stopAction = NotificationCompat.Action.Builder(
R.drawable.ic_outline_stop_24,
applicationContext.getString(R.string.analysis_notification_action_stop),
WorkManager.getInstance(applicationContext).createCancelPendingIntent(id)
).build()
val icon = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) R.drawable.ic_notification else R.mipmap.ic_launcher_round
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL)
.setContentTitle(title ?: applicationContext.getText(R.string.analysis_notification_default_title))
.setContentText(message)
.setBadgeIconType(NotificationCompat.BADGE_ICON_NONE)
.setSmallIcon(icon)
.setContentIntent(openAppIntent)
.setPriority(NotificationCompat.PRIORITY_LOW)
.addAction(stopAction)
.build()
return ForegroundInfo(NOTIFICATION_ID, notification)
}
companion object {
private val LOG_TAG = LogUtils.createTag<AnalysisWorker>()
private const val BACKGROUND_CHANNEL = "deckers.thibault/aves/analysis_service_background"
const val SHARED_PREFERENCES_KEY = "analysis_service"
const val CALLBACK_HANDLE_KEY = "callback_handle"
const val NOTIFICATION_CHANNEL = "analysis"
const val NOTIFICATION_ID = 1
const val KEY_ENTRY_IDS = "entry_ids"
const val KEY_FORCE = "force"
const val KEY_PROGRESS_TOTAL = "progress_total"
const val KEY_PROGRESS_OFFSET = "progress_offset"
}
}

View file

@ -18,6 +18,7 @@ import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
import deckers.thibault.aves.channel.calls.* import deckers.thibault.aves.channel.calls.*
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.FlutterUtils import deckers.thibault.aves.utils.FlutterUtils
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import io.flutter.FlutterInjector import io.flutter.FlutterInjector
@ -40,11 +41,11 @@ class HomeWidgetProvider : AppWidgetProvider() {
val widgetInfo = appWidgetManager.getAppWidgetOptions(widgetId) val widgetInfo = appWidgetManager.getAppWidgetOptions(widgetId)
defaultScope.launch { defaultScope.launch {
val backgroundBytes = getBytes(context, widgetId, widgetInfo, drawEntryImage = false) val backgroundProps = getProps(context, widgetId, widgetInfo, drawEntryImage = false)
updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, backgroundBytes) updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, backgroundProps)
val imageBytes = getBytes(context, widgetId, widgetInfo, drawEntryImage = true, reuseEntry = false) val imageProps = getProps(context, widgetId, widgetInfo, drawEntryImage = true, reuseEntry = false)
updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, imageBytes) updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, imageProps)
} }
} }
} }
@ -59,8 +60,8 @@ class HomeWidgetProvider : AppWidgetProvider() {
} }
imageByteFetchJob = defaultScope.launch { imageByteFetchJob = defaultScope.launch {
delay(500) delay(500)
val imageBytes = getBytes(context, widgetId, widgetInfo, drawEntryImage = true, reuseEntry = true) val imageProps = getProps(context, widgetId, widgetInfo, drawEntryImage = true, reuseEntry = true)
updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, imageBytes) updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, imageProps)
} }
} }
@ -76,13 +77,13 @@ class HomeWidgetProvider : AppWidgetProvider() {
return Pair(widthPx, heightPx) return Pair(widthPx, heightPx)
} }
private suspend fun getBytes( private suspend fun getProps(
context: Context, context: Context,
widgetId: Int, widgetId: Int,
widgetInfo: Bundle, widgetInfo: Bundle,
drawEntryImage: Boolean, drawEntryImage: Boolean,
reuseEntry: Boolean = false, reuseEntry: Boolean = false,
): ByteArray? { ): FieldMap? {
val (widthPx, heightPx) = getWidgetSizePx(context, widgetInfo) val (widthPx, heightPx) = getWidgetSizePx(context, widgetInfo)
if (widthPx == 0 || heightPx == 0) return null if (widthPx == 0 || heightPx == 0) return null
@ -90,7 +91,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
val messenger = flutterEngine!!.dartExecutor val messenger = flutterEngine!!.dartExecutor
val channel = MethodChannel(messenger, WIDGET_DRAW_CHANNEL) val channel = MethodChannel(messenger, WIDGET_DRAW_CHANNEL)
try { try {
val bytes = suspendCoroutine<Any?> { cont -> val props = suspendCoroutine<Any?> { cont ->
defaultScope.launch { defaultScope.launch {
FlutterUtils.runOnUiThread { FlutterUtils.runOnUiThread {
channel.invokeMethod("drawWidget", hashMapOf( channel.invokeMethod("drawWidget", hashMapOf(
@ -116,7 +117,8 @@ class HomeWidgetProvider : AppWidgetProvider() {
} }
} }
} }
if (bytes is ByteArray) return bytes @Suppress("unchecked_cast")
return props as FieldMap?
} catch (e: Exception) { } catch (e: Exception) {
Log.e(LOG_TAG, "failed to draw widget for widgetId=$widgetId widthPx=$widthPx heightPx=$heightPx", e) Log.e(LOG_TAG, "failed to draw widget for widgetId=$widgetId widthPx=$widthPx heightPx=$heightPx", e)
} }
@ -128,9 +130,16 @@ class HomeWidgetProvider : AppWidgetProvider() {
appWidgetManager: AppWidgetManager, appWidgetManager: AppWidgetManager,
widgetId: Int, widgetId: Int,
widgetInfo: Bundle, widgetInfo: Bundle,
bytes: ByteArray?, props: FieldMap?,
) { ) {
bytes ?: return props ?: return
val bytes = props["bytes"] as ByteArray?
val updateOnTap = props["updateOnTap"] as Boolean?
if (bytes == null || updateOnTap == null) {
Log.e(LOG_TAG, "missing arguments")
return
}
val (widthPx, heightPx) = getWidgetSizePx(context, widgetInfo) val (widthPx, heightPx) = getWidgetSizePx(context, widgetInfo)
if (widthPx == 0 || heightPx == 0) return if (widthPx == 0 || heightPx == 0) return
@ -139,24 +148,11 @@ class HomeWidgetProvider : AppWidgetProvider() {
val bitmap = Bitmap.createBitmap(widthPx, heightPx, Bitmap.Config.ARGB_8888) val bitmap = Bitmap.createBitmap(widthPx, heightPx, Bitmap.Config.ARGB_8888)
bitmap.copyPixelsFromBuffer(ByteBuffer.wrap(bytes)) bitmap.copyPixelsFromBuffer(ByteBuffer.wrap(bytes))
// set a unique URI to prevent the intent (and its extras) from being shared by different widgets val pendingIntent = if (updateOnTap) buildUpdateIntent(context, widgetId) else buildOpenAppIntent(context, widgetId)
val intent = Intent(MainActivity.INTENT_ACTION_WIDGET_OPEN, Uri.parse("widget://$widgetId"), context, MainActivity::class.java)
.putExtra(MainActivity.EXTRA_KEY_WIDGET_ID, widgetId)
val activity = PendingIntent.getActivity(
context,
0,
intent,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
)
val views = RemoteViews(context.packageName, R.layout.app_widget).apply { val views = RemoteViews(context.packageName, R.layout.app_widget).apply {
setImageViewBitmap(R.id.widget_img, bitmap) setImageViewBitmap(R.id.widget_img, bitmap)
setOnClickPendingIntent(R.id.widget_img, activity) setOnClickPendingIntent(R.id.widget_img, pendingIntent)
} }
appWidgetManager.updateAppWidget(widgetId, views) appWidgetManager.updateAppWidget(widgetId, views)
@ -166,6 +162,39 @@ class HomeWidgetProvider : AppWidgetProvider() {
} }
} }
private fun buildUpdateIntent(context: Context, widgetId: Int): PendingIntent {
val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE, Uri.parse("widget://$widgetId"), context, HomeWidgetProvider::class.java)
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(widgetId))
return PendingIntent.getBroadcast(
context,
0,
intent,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
)
}
private fun buildOpenAppIntent(context: Context, widgetId: Int): PendingIntent {
// set a unique URI to prevent the intent (and its extras) from being shared by different widgets
val intent = Intent(MainActivity.INTENT_ACTION_WIDGET_OPEN, Uri.parse("widget://$widgetId"), context, MainActivity::class.java)
.putExtra(MainActivity.EXTRA_KEY_WIDGET_ID, widgetId)
return PendingIntent.getActivity(
context,
0,
intent,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
)
}
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<HomeWidgetProvider>() private val LOG_TAG = LogUtils.createTag<HomeWidgetProvider>()
private const val WIDGET_DART_ENTRYPOINT = "widgetMain" private const val WIDGET_DART_ENTRYPOINT = "widgetMain"

View file

@ -142,6 +142,7 @@ open class MainActivity : FlutterFragmentActivity() {
result.success(intentDataMap) result.success(intentDataMap)
intentDataMap.clear() intentDataMap.clear()
} }
"submitPickedItems" -> submitPickedItems(call) "submitPickedItems" -> submitPickedItems(call)
"submitPickedCollectionFilters" -> submitPickedCollectionFilters(call) "submitPickedCollectionFilters" -> submitPickedCollectionFilters(call)
} }
@ -169,7 +170,6 @@ open class MainActivity : FlutterFragmentActivity() {
override fun onStop() { override fun onStop() {
Log.i(LOG_TAG, "onStop") Log.i(LOG_TAG, "onStop")
analysisHandler.detachFromActivity()
super.onStop() super.onStop()
} }
@ -204,8 +204,10 @@ open class MainActivity : FlutterFragmentActivity() {
DOCUMENT_TREE_ACCESS_REQUEST -> onDocumentTreeAccessResult(requestCode, resultCode, data) DOCUMENT_TREE_ACCESS_REQUEST -> onDocumentTreeAccessResult(requestCode, resultCode, data)
DELETE_SINGLE_PERMISSION_REQUEST, DELETE_SINGLE_PERMISSION_REQUEST,
MEDIA_WRITE_BULK_PERMISSION_REQUEST -> onScopedStoragePermissionResult(resultCode) MEDIA_WRITE_BULK_PERMISSION_REQUEST -> onScopedStoragePermissionResult(resultCode)
CREATE_FILE_REQUEST, CREATE_FILE_REQUEST,
OPEN_FILE_REQUEST -> onStorageAccessResult(requestCode, data?.data) OPEN_FILE_REQUEST -> onStorageAccessResult(requestCode, data?.data)
PICK_COLLECTION_FILTERS_REQUEST -> onCollectionFiltersPickResult(resultCode, data) PICK_COLLECTION_FILTERS_REQUEST -> onCollectionFiltersPickResult(resultCode, data)
} }
} }
@ -222,19 +224,17 @@ open class MainActivity : FlutterFragmentActivity() {
return return
} }
@SuppressLint("WrongConstant", "ObsoleteSdkInt") val canPersist = (intent.flags and Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) != 0
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { @SuppressLint("WrongConstant")
val canPersist = (intent.flags and Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) != 0 if (canPersist) {
if (canPersist) { // save access permissions across reboots
// save access permissions across reboots val takeFlags = (intent.flags
val takeFlags = (intent.flags and (Intent.FLAG_GRANT_READ_URI_PERMISSION
and (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION))
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)) try {
try { contentResolver.takePersistableUriPermission(treeUri, takeFlags)
contentResolver.takePersistableUriPermission(treeUri, takeFlags) } catch (e: SecurityException) {
} catch (e: SecurityException) { Log.w(LOG_TAG, "failed to take persistable URI permission for uri=$treeUri", e)
Log.w(LOG_TAG, "failed to take persistable URI permission for uri=$treeUri", e)
}
} }
} }
@ -264,6 +264,7 @@ open class MainActivity : FlutterFragmentActivity() {
) )
} }
} }
Intent.ACTION_VIEW, Intent.ACTION_SEND, "com.android.camera.action.REVIEW" -> { Intent.ACTION_VIEW, Intent.ACTION_SEND, "com.android.camera.action.REVIEW" -> {
(intent.data ?: intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM))?.let { uri -> (intent.data ?: intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM))?.let { uri ->
// MIME type is optional // MIME type is optional
@ -275,6 +276,19 @@ open class MainActivity : FlutterFragmentActivity() {
) )
} }
} }
Intent.ACTION_EDIT -> {
(intent.data ?: intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM))?.let { uri ->
// MIME type is optional
val type = intent.type ?: intent.resolveType(this)
return hashMapOf(
INTENT_DATA_KEY_ACTION to INTENT_ACTION_EDIT,
INTENT_DATA_KEY_MIME_TYPE to type,
INTENT_DATA_KEY_URI to uri.toString(),
)
}
}
Intent.ACTION_GET_CONTENT, Intent.ACTION_PICK -> { Intent.ACTION_GET_CONTENT, Intent.ACTION_PICK -> {
return hashMapOf( return hashMapOf(
INTENT_DATA_KEY_ACTION to INTENT_ACTION_PICK_ITEMS, INTENT_DATA_KEY_ACTION to INTENT_ACTION_PICK_ITEMS,
@ -282,6 +296,7 @@ open class MainActivity : FlutterFragmentActivity() {
INTENT_DATA_KEY_ALLOW_MULTIPLE to (intent.extras?.getBoolean(Intent.EXTRA_ALLOW_MULTIPLE) ?: false), INTENT_DATA_KEY_ALLOW_MULTIPLE to (intent.extras?.getBoolean(Intent.EXTRA_ALLOW_MULTIPLE) ?: false),
) )
} }
Intent.ACTION_SEARCH -> { Intent.ACTION_SEARCH -> {
val viewUri = intent.dataString val viewUri = intent.dataString
return if (viewUri != null) hashMapOf( return if (viewUri != null) hashMapOf(
@ -293,6 +308,7 @@ open class MainActivity : FlutterFragmentActivity() {
INTENT_DATA_KEY_QUERY to intent.getStringExtra(SearchManager.QUERY), INTENT_DATA_KEY_QUERY to intent.getStringExtra(SearchManager.QUERY),
) )
} }
INTENT_ACTION_PICK_COLLECTION_FILTERS -> { INTENT_ACTION_PICK_COLLECTION_FILTERS -> {
val initialFilters = extractFiltersFromIntent(intent) val initialFilters = extractFiltersFromIntent(intent)
return hashMapOf( return hashMapOf(
@ -300,6 +316,7 @@ open class MainActivity : FlutterFragmentActivity() {
INTENT_DATA_KEY_FILTERS to initialFilters, INTENT_DATA_KEY_FILTERS to initialFilters,
) )
} }
INTENT_ACTION_WIDGET_OPEN -> { INTENT_ACTION_WIDGET_OPEN -> {
val widgetId = intent.getIntExtra(EXTRA_KEY_WIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) val widgetId = intent.getIntExtra(EXTRA_KEY_WIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
if (widgetId != AppWidgetManager.INVALID_APPWIDGET_ID) { if (widgetId != AppWidgetManager.INVALID_APPWIDGET_ID) {
@ -309,9 +326,11 @@ open class MainActivity : FlutterFragmentActivity() {
) )
} }
} }
Intent.ACTION_RUN -> { Intent.ACTION_RUN -> {
// flutter run // flutter run
} }
else -> { else -> {
Log.w(LOG_TAG, "unhandled intent action=${intent?.action}") Log.w(LOG_TAG, "unhandled intent action=${intent?.action}")
} }
@ -426,6 +445,7 @@ open class MainActivity : FlutterFragmentActivity() {
const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 6 const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 6
const val PICK_COLLECTION_FILTERS_REQUEST = 7 const val PICK_COLLECTION_FILTERS_REQUEST = 7
const val INTENT_ACTION_EDIT = "edit"
const val INTENT_ACTION_PICK_ITEMS = "pick_items" const val INTENT_ACTION_PICK_ITEMS = "pick_items"
const val INTENT_ACTION_PICK_COLLECTION_FILTERS = "pick_collection_filters" const val INTENT_ACTION_PICK_COLLECTION_FILTERS = "pick_collection_filters"
const val INTENT_ACTION_SCREEN_SAVER = "screen_saver" const val INTENT_ACTION_SCREEN_SAVER = "screen_saver"

View file

@ -1,6 +1,5 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.ContextWrapper import android.content.ContextWrapper
import android.content.res.Configuration import android.content.res.Configuration
@ -27,13 +26,10 @@ class AccessibilityHandler(private val contextWrapper: ContextWrapper) : MethodC
private fun areAnimationsRemoved(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { private fun areAnimationsRemoved(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
var removed = false var removed = false
@SuppressLint("ObsoleteSdkInt") try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { removed = Settings.Global.getFloat(contextWrapper.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE) == 0f
try { } catch (e: Exception) {
removed = Settings.Global.getFloat(contextWrapper.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE) == 0f Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
}
} }
result.success(removed) result.success(removed)
} }

View file

@ -1,38 +1,35 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import androidx.core.app.ComponentActivity
import android.content.ServiceConnection import androidx.work.ExistingWorkPolicy
import android.os.Build import androidx.work.OneTimeWorkRequest
import android.os.IBinder import androidx.work.OneTimeWorkRequestBuilder
import android.util.Log import androidx.work.WorkInfo
import deckers.thibault.aves.AnalysisService import androidx.work.WorkManager
import deckers.thibault.aves.AnalysisServiceBinder import androidx.work.workDataOf
import deckers.thibault.aves.AnalysisServiceListener import deckers.thibault.aves.AnalysisWorker
import deckers.thibault.aves.utils.ContextUtils.isMyServiceRunning import deckers.thibault.aves.utils.FlutterUtils
import deckers.thibault.aves.utils.LogUtils
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
class AnalysisHandler(private val activity: Activity, private val onAnalysisCompleted: () -> Unit) : MethodChannel.MethodCallHandler, AnalysisServiceListener {
class AnalysisHandler(private val activity: ComponentActivity, private val onAnalysisCompleted: () -> Unit) : MethodChannel.MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"registerCallback" -> ioScope.launch { Coresult.safe(call, result, ::registerCallback) } "registerCallback" -> ioScope.launch { Coresult.safe(call, result, ::registerCallback) }
"startService" -> Coresult.safe(call, result, ::startAnalysis) "startAnalysis" -> Coresult.safe(call, result, ::startAnalysis)
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
@SuppressLint("CommitPrefEdits")
private fun registerCallback(call: MethodCall, result: MethodChannel.Result) { private fun registerCallback(call: MethodCall, result: MethodChannel.Result) {
val callbackHandle = call.argument<Number>("callbackHandle")?.toLong() val callbackHandle = call.argument<Number>("callbackHandle")?.toLong()
if (callbackHandle == null) { if (callbackHandle == null) {
@ -40,9 +37,9 @@ class AnalysisHandler(private val activity: Activity, private val onAnalysisComp
return return
} }
activity.getSharedPreferences(AnalysisService.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE) activity.getSharedPreferences(AnalysisWorker.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
.edit() .edit()
.putLong(AnalysisService.CALLBACK_HANDLE_KEY, callbackHandle) .putLong(AnalysisWorker.CALLBACK_HANDLE_KEY, callbackHandle)
.apply() .apply()
result.success(true) result.success(true)
} }
@ -55,22 +52,35 @@ class AnalysisHandler(private val activity: Activity, private val onAnalysisComp
} }
// can be null or empty // can be null or empty
val entryIds = call.argument<List<Int>>("entryIds") val allEntryIds = call.argument<List<Int>>("entryIds")
val progressTotal = allEntryIds?.size ?: 0
var progressOffset = 0
if (!activity.isMyServiceRunning(AnalysisService::class.java)) { // work `Data` cannot occupy more than 10240 bytes when serialized
val intent = Intent(activity, AnalysisService::class.java) // so we split it when we have a long list of entry IDs
.putExtra(AnalysisService.KEY_COMMAND, AnalysisService.COMMAND_START) val chunked = allEntryIds?.chunked(WORK_DATA_CHUNK_SIZE) ?: listOf(null)
.putExtra(AnalysisService.KEY_ENTRY_IDS, entryIds?.toIntArray())
.putExtra(AnalysisService.KEY_FORCE, force) fun buildRequest(entryIds: List<Int>?, progressOffset: Int): OneTimeWorkRequest {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val workData = workDataOf(
// Foreground services cannot start from background, but the service here may start fine AnalysisWorker.KEY_ENTRY_IDS to entryIds?.toIntArray(),
// while the current lifecycle state (via `ProcessLifecycleOwner.get().lifecycle.currentState`) AnalysisWorker.KEY_FORCE to force,
// is only `INITIALIZED`, so we should not preemptively return when the state is below `STARTED`. AnalysisWorker.KEY_PROGRESS_TOTAL to progressTotal,
activity.startForegroundService(intent) AnalysisWorker.KEY_PROGRESS_OFFSET to progressOffset,
} else { )
activity.startService(intent) return OneTimeWorkRequestBuilder<AnalysisWorker>().apply { setInputData(workData) }.build()
}
} }
var work = WorkManager.getInstance(activity).beginUniqueWork(
ANALYSIS_WORK_NAME,
ExistingWorkPolicy.KEEP,
buildRequest(chunked.first(), progressOffset),
)
chunked.drop(1).forEach { entryIds ->
progressOffset += WORK_DATA_CHUNK_SIZE
work = work.then(buildRequest(entryIds, progressOffset))
}
work.enqueue()
attachToActivity() attachToActivity()
result.success(null) result.success(null)
} }
@ -78,44 +88,23 @@ class AnalysisHandler(private val activity: Activity, private val onAnalysisComp
private var attached = false private var attached = false
fun attachToActivity() { fun attachToActivity() {
if (activity.isMyServiceRunning(AnalysisService::class.java)) { if (!attached) {
val intent = Intent(activity, AnalysisService::class.java)
activity.bindService(intent, connection, Context.BIND_AUTO_CREATE)
attached = true attached = true
} WorkManager.getInstance(activity).getWorkInfosForUniqueWorkLiveData(ANALYSIS_WORK_NAME).observe(activity) { list ->
} if (list.any { it.state == WorkInfo.State.SUCCEEDED }) {
runBlocking {
override fun detachFromActivity() { FlutterUtils.runOnUiThread {
if (attached) { onAnalysisCompleted()
attached = false }
activity.unbindService(connection) }
} }
} }
override fun refreshApp() {
if (attached) {
onAnalysisCompleted()
}
}
private val connection = object : ServiceConnection {
var binder: AnalysisServiceBinder? = null
override fun onServiceConnected(name: ComponentName, service: IBinder) {
Log.i(LOG_TAG, "Analysis service connected")
binder = service as AnalysisServiceBinder
binder?.startListening(this@AnalysisHandler)
}
override fun onServiceDisconnected(name: ComponentName) {
Log.i(LOG_TAG, "Analysis service disconnected")
binder?.stopListening(this@AnalysisHandler)
binder = null
} }
} }
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<AnalysisHandler>()
const val CHANNEL = "deckers.thibault/aves/analysis" const val CHANNEL = "deckers.thibault/aves/analysis"
private const val ANALYSIS_WORK_NAME = "analysis_work"
private const val WORK_DATA_CHUNK_SIZE = 1000
} }
} }

View file

@ -1,6 +1,5 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.annotation.SuppressLint
import android.content.* import android.content.*
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.content.res.Configuration import android.content.res.Configuration
@ -40,6 +39,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.util.* import java.util.*
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -69,13 +69,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
// apps tend to use their name in English when creating directories // apps tend to use their name in English when creating directories
// so we get their names in English as well as the current locale // so we get their names in English as well as the current locale
val englishConfig = Configuration().apply { val englishConfig = Configuration().apply {
@SuppressLint("ObsoleteSdkInt") setLocale(Locale.ENGLISH)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
setLocale(Locale.ENGLISH)
} else {
@Suppress("deprecation")
locale = Locale.ENGLISH
}
} }
val pm = context.packageManager val pm = context.packageManager
@ -169,8 +163,8 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
.submit(size, size) .submit(size, size)
try { try {
@Suppress("BlockingMethodInNonBlockingContext") val bitmap = withContext(Dispatchers.IO) { target.get() }
data = target.get()?.getBytes(canHaveAlpha = true, recycle = false) data = bitmap?.getBytes(canHaveAlpha = true, recycle = false)
} catch (e: Exception) { } catch (e: Exception) {
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)
} }

View file

@ -40,7 +40,6 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
hashMapOf( hashMapOf(
"canGrantDirectoryAccess" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP), "canGrantDirectoryAccess" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP),
"canPinShortcut" to ShortcutManagerCompat.isRequestPinShortcutSupported(context), "canPinShortcut" to ShortcutManagerCompat.isRequestPinShortcutSupported(context),
"canPrint" to (sdkInt >= Build.VERSION_CODES.KITKAT),
"canRenderFlagEmojis" to (sdkInt >= Build.VERSION_CODES.M), "canRenderFlagEmojis" to (sdkInt >= Build.VERSION_CODES.M),
"canRenderSubdivisionFlagEmojis" to (sdkInt >= Build.VERSION_CODES.O), "canRenderSubdivisionFlagEmojis" to (sdkInt >= Build.VERSION_CODES.O),
"canRequestManageMedia" to (sdkInt >= Build.VERSION_CODES.S), "canRequestManageMedia" to (sdkInt >= Build.VERSION_CODES.S),

View file

@ -64,7 +64,6 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
if (canReadWithExifInterface(mimeType)) { if (canReadWithExifInterface(mimeType)) {
try { try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
@Suppress("BlockingMethodInNonBlockingContext")
val exif = ExifInterface(input) val exif = ExifInterface(input)
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
exif.thumbnailBitmap?.let { bitmap -> exif.thumbnailBitmap?.let { bitmap ->

View file

@ -3,8 +3,8 @@ package deckers.thibault.aves.channel.calls
import android.content.Context import android.content.Context
import android.location.Address import android.location.Address
import android.location.Geocoder import android.location.Geocoder
import android.os.Build
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.utils.getFromLocationCompat
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
@ -12,8 +12,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.IOException import java.util.Locale
import java.util.*
// as of 2021/03/10, geocoding packages exist but: // as of 2021/03/10, geocoding packages exist but:
// - `geocoder` is unmaintained // - `geocoder` is unmaintained
@ -76,26 +75,9 @@ class GeocodingHandler(private val context: Context) : MethodCallHandler {
} }
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { geocoder!!.getFromLocationCompat(
geocoder!!.getFromLocation(latitude, longitude, maxResults, object : Geocoder.GeocodeListener { latitude, longitude, maxResults, ::processAddresses,
override fun onGeocode(addresses: List<Address?>) = processAddresses(addresses.filterNotNull()) ) { code, message, details -> result.error(code, message, details) }
override fun onError(errorMessage: String?) {
result.error("getAddress-asyncerror", "failed to get address", errorMessage)
}
})
} else {
try {
@Suppress("deprecation")
val addresses = geocoder!!.getFromLocation(latitude, longitude, maxResults) ?: ArrayList()
processAddresses(addresses)
} catch (e: IOException) {
// `grpc failed`, etc.
result.error("getAddress-network", "failed to get address because of network issues", e.message)
} catch (e: Exception) {
result.error("getAddress-exception", "failed to get address", e.message)
}
}
} }
companion object { companion object {

View file

@ -1,13 +1,15 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import deckers.thibault.aves.SearchSuggestionsProvider import deckers.thibault.aves.SearchSuggestionsProvider
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.* import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class GlobalSearchHandler(private val context: Context) : MethodCallHandler { class GlobalSearchHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@ -19,7 +21,6 @@ class GlobalSearchHandler(private val context: Context) : MethodCallHandler {
} }
} }
@SuppressLint("CommitPrefEdits")
private fun registerCallback(call: MethodCall, result: MethodChannel.Result) { private fun registerCallback(call: MethodCall, result: MethodChannel.Result) {
val callbackHandle = call.argument<Number>("callbackHandle")?.toLong() val callbackHandle = call.argument<Number>("callbackHandle")?.toLong()
if (callbackHandle == null) { if (callbackHandle == null) {

View file

@ -1,6 +1,5 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
@ -28,20 +27,30 @@ import com.drew.metadata.png.PngDirectory
import com.drew.metadata.webp.WebpDirectory import com.drew.metadata.webp.WebpDirectory
import com.drew.metadata.xmp.XmpDirectory import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.metadata.* import deckers.thibault.aves.metadata.ExifGeoTiffTags
import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDouble import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDouble
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeRational import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeRational
import deckers.thibault.aves.metadata.ExifTags
import deckers.thibault.aves.metadata.GSpherical
import deckers.thibault.aves.metadata.GeoTiffKeys
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt
import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.Metadata.DIR_DNG import deckers.thibault.aves.metadata.Metadata.DIR_DNG
import deckers.thibault.aves.metadata.Metadata.DIR_EXIF_GEOTIFF import deckers.thibault.aves.metadata.Metadata.DIR_EXIF_GEOTIFF
import deckers.thibault.aves.metadata.Metadata.DIR_PNG_TEXTUAL_DATA import deckers.thibault.aves.metadata.Metadata.DIR_PNG_TEXTUAL_DATA
import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode
import deckers.thibault.aves.metadata.Mp4ParserHelper
import deckers.thibault.aves.metadata.MultiPage
import deckers.thibault.aves.metadata.PixyMetaHelper
import deckers.thibault.aves.metadata.QuickTimeMetadata
import deckers.thibault.aves.metadata.XMP
import deckers.thibault.aves.metadata.XMP.doesPropExist import deckers.thibault.aves.metadata.XMP.doesPropExist
import deckers.thibault.aves.metadata.XMP.getPropArrayItemValues import deckers.thibault.aves.metadata.XMP.getPropArrayItemValues
import deckers.thibault.aves.metadata.XMP.getSafeDateMillis import deckers.thibault.aves.metadata.XMP.getSafeDateMillis
@ -66,6 +75,7 @@ import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeInt
import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeRational import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeRational
import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeString import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeString
import deckers.thibault.aves.metadata.metadataextractor.Helper.isPngTextDir import deckers.thibault.aves.metadata.metadataextractor.Helper.isPngTextDir
import deckers.thibault.aves.metadata.metadataextractor.PngActlDirectory
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.ContextUtils.queryContentPropValue import deckers.thibault.aves.utils.ContextUtils.queryContentPropValue
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
@ -84,7 +94,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.json.JSONObject import org.json.JSONObject
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.text.DecimalFormat import java.text.DecimalFormat
import java.text.ParseException import java.text.ParseException
@ -305,12 +314,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
val key = kv.key val key = kv.key
// `PNG-iTXt` uses UTF-8, contrary to `PNG-tEXt` and `PNG-zTXt` using Latin-1 / ISO-8859-1 // `PNG-iTXt` uses UTF-8, contrary to `PNG-tEXt` and `PNG-zTXt` using Latin-1 / ISO-8859-1
val charset = if (baseDirName == PNG_ITXT_DIR_NAME) { val charset = if (baseDirName == PNG_ITXT_DIR_NAME) {
@SuppressLint("ObsoleteSdkInt") StandardCharsets.UTF_8
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
StandardCharsets.UTF_8
} else {
Charset.forName("UTF-8")
}
} else { } else {
kv.value.charset kv.value.charset
} }
@ -654,6 +658,11 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
} }
} }
} }
// identification of animated PNG
if (metadata.containsDirectoryOfType(PngActlDirectory::class.java)) {
flags = flags or MASK_IS_ANIMATED
}
} }
MimeTypes.GIF -> { MimeTypes.GIF -> {
@ -747,10 +756,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int
try { try {
@SuppressLint("ObsoleteSdkInt") retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { metadataMap[KEY_ROTATION_DEGREES] = it }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { metadataMap[KEY_ROTATION_DEGREES] = it }
}
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { metadataMap[KEY_DATE_MILLIS] = it } retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { metadataMap[KEY_DATE_MILLIS] = it }
} }

View file

@ -6,12 +6,12 @@ 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.os.Build
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
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.decoder.MultiTrackImage import deckers.thibault.aves.decoder.MultiTrackImage
import deckers.thibault.aves.utils.BitmapRegionDecoderCompat
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
@ -68,13 +68,7 @@ class RegionFetcher internal constructor(
try { try {
if (currentDecoderRef == null) { if (currentDecoderRef == null) {
val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input -> val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input ->
@Suppress("BlockingMethodInNonBlockingContext") BitmapRegionDecoderCompat.newInstance(input)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
BitmapRegionDecoder.newInstance(input)
} else {
@Suppress("deprecation")
BitmapRegionDecoder.newInstance(input, false)
}
} }
if (newDecoder == null) { if (newDecoder == null) {
result.error("getRegion-read-null", "failed to open file for mimeType=$mimeType uri=$uri regionRect=$regionRect", null) result.error("getRegion-read-null", "failed to open file for mimeType=$mimeType uri=$uri regionRect=$regionRect", null)

View file

@ -8,7 +8,7 @@ import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
class ActivityWindowHandler(private val activity: Activity) : WindowHandler(activity) { class ActivityWindowHandler(private val activity: Activity) : WindowHandler(activity) {
override fun isActivity(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { override fun isActivity(call: MethodCall, result: MethodChannel.Result) {
result.success(true) result.success(true)
} }
@ -49,11 +49,11 @@ class ActivityWindowHandler(private val activity: Activity) : WindowHandler(acti
result.success(true) result.success(true)
} }
override fun isCutoutAware(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { override fun isCutoutAware(call: MethodCall, result: MethodChannel.Result) {
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
} }
override fun getCutoutInsets(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { override fun getCutoutInsets(call: MethodCall, result: MethodChannel.Result) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
result.error("getCutoutInsets-sdk", "unsupported SDK version=${Build.VERSION.SDK_INT}", null) result.error("getCutoutInsets-sdk", "unsupported SDK version=${Build.VERSION.SDK_INT}", null)
return return

View file

@ -5,7 +5,7 @@ import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
class ServiceWindowHandler(service: Service) : WindowHandler(service) { class ServiceWindowHandler(service: Service) : WindowHandler(service) {
override fun isActivity(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { override fun isActivity(call: MethodCall, result: MethodChannel.Result) {
result.success(false) result.success(false)
} }
@ -21,7 +21,7 @@ class ServiceWindowHandler(service: Service) : WindowHandler(service) {
result.success(false) result.success(false)
} }
override fun isCutoutAware(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { override fun isCutoutAware(call: MethodCall, result: MethodChannel.Result) {
result.success(false) result.success(false)
} }

View file

@ -40,7 +40,7 @@ abstract class WindowHandler(private val contextWrapper: ContextWrapper) : Metho
abstract fun requestOrientation(call: MethodCall, result: MethodChannel.Result) abstract fun requestOrientation(call: MethodCall, result: MethodChannel.Result)
abstract fun isCutoutAware(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) abstract fun isCutoutAware(call: MethodCall, result: MethodChannel.Result)
abstract fun getCutoutInsets(call: MethodCall, result: MethodChannel.Result) abstract fun getCutoutInsets(call: MethodCall, result: MethodChannel.Result)

View file

@ -1,6 +1,5 @@
package deckers.thibault.aves.channel.streams package deckers.thibault.aves.channel.streams
import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
@ -77,7 +76,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
private fun requestMediaFileAccess() { private fun requestMediaFileAccess() {
val uris = (args["uris"] as List<*>?)?.mapNotNull { if (it is String) Uri.parse(it) else null } val uris = (args["uris"] as List<*>?)?.mapNotNull { if (it is String) Uri.parse(it) else null }
val mimeTypes = (args["mimeTypes"] as List<*>?)?.mapNotNull { if (it is String) it else null } val mimeTypes = (args["mimeTypes"] as List<*>?)?.mapNotNull { if (it is String) it else null }
if (uris == null || uris.isEmpty() || mimeTypes == null || mimeTypes.size != uris.size) { if (uris.isNullOrEmpty() || mimeTypes == null || mimeTypes.size != uris.size) {
error("requestMediaFileAccess-args", "missing arguments", null) error("requestMediaFileAccess-args", "missing arguments", null)
return return
} }
@ -112,12 +111,6 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
} }
private suspend fun createFile() { private suspend fun createFile() {
@SuppressLint("ObsoleteSdkInt")
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
error("createFile-sdk", "unsupported SDK version=${Build.VERSION.SDK_INT}", null)
return
}
val name = args["name"] as String? val name = args["name"] as String?
val mimeType = args["mimeType"] as String? val mimeType = args["mimeType"] as String?
val bytes = args["bytes"] as ByteArray? val bytes = args["bytes"] as ByteArray?
@ -155,12 +148,6 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
} }
private suspend fun openFile() { private suspend fun openFile() {
@SuppressLint("ObsoleteSdkInt")
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
error("openFile-sdk", "unsupported SDK version=${Build.VERSION.SDK_INT}", null)
return
}
val mimeType = args["mimeType"] as String? // optional val mimeType = args["mimeType"] as String? // optional
fun onGranted(uri: Uri) { fun onGranted(uri: Uri) {
@ -219,7 +206,6 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
} }
} }
@Suppress("SameParameterValue")
private fun error(errorCode: String, errorMessage: String, errorDetails: Any?) { private fun error(errorCode: String, errorMessage: String, errorDetails: Any?) {
handler.post { handler.post {
try { try {

View file

@ -28,6 +28,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.InputStream import java.io.InputStream
class ImageByteStreamHandler(private val context: Context, private val arguments: Any?) : EventChannel.StreamHandler { class ImageByteStreamHandler(private val context: Context, private val arguments: Any?) : EventChannel.StreamHandler {
@ -144,8 +145,7 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
.load(model) .load(model)
.submit() .submit()
try { try {
@Suppress("BlockingMethodInNonBlockingContext") var bitmap = withContext(Dispatchers.IO) { target.get() }
var bitmap = target.get()
if (needRotationAfterGlide(mimeType)) { if (needRotationAfterGlide(mimeType)) {
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped) bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
} }
@ -173,8 +173,7 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
.load(VideoThumbnail(context, uri)) .load(VideoThumbnail(context, uri))
.submit() .submit()
try { try {
@Suppress("BlockingMethodInNonBlockingContext") val bitmap = withContext(Dispatchers.IO) { target.get() }
val bitmap = target.get()
if (bitmap != null) { if (bitmap != null) {
val bytes = bitmap.getBytes(canHaveAlpha = false, recycle = false) val bytes = bitmap.getBytes(canHaveAlpha = false, recycle = false)
if (MemoryUtils.canAllocate(sizeBytes)) { if (MemoryUtils.canAllocate(sizeBytes)) {

View file

@ -1,10 +1,8 @@
package deckers.thibault.aves.channel.streams package deckers.thibault.aves.channel.streams
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.database.ContentObserver import android.database.ContentObserver
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.provider.Settings import android.provider.Settings
@ -34,14 +32,12 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
override fun onChange(selfChange: Boolean, uri: Uri?) { override fun onChange(selfChange: Boolean, uri: Uri?) {
if (update()) { if (update()) {
val settings: FieldMap = hashMapOf( success(
Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation, hashMapOf(
Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation,
Settings.Global.TRANSITION_ANIMATION_SCALE to transitionAnimationScale,
)
) )
@SuppressLint("ObsoleteSdkInt")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
settings[Settings.Global.TRANSITION_ANIMATION_SCALE] = transitionAnimationScale
}
success(settings)
} }
} }
@ -53,13 +49,10 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
accelerometerRotation = newAccelerometerRotation accelerometerRotation = newAccelerometerRotation
changed = true changed = true
} }
@SuppressLint("ObsoleteSdkInt") val newTransitionAnimationScale = Settings.Global.getFloat(context.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { if (transitionAnimationScale != newTransitionAnimationScale) {
val newTransitionAnimationScale = Settings.Global.getFloat(context.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE) transitionAnimationScale = newTransitionAnimationScale
if (transitionAnimationScale != newTransitionAnimationScale) { changed = true
transitionAnimationScale = newTransitionAnimationScale
changed = true
}
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null) Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)

View file

@ -81,4 +81,4 @@ class GoogleDeviceContainer {
fun itemMimeType(index: Int) = item(index)?.mimeType fun itemMimeType(index: Int) = item(index)?.mimeType
} }
class GoogleDeviceContainerItem(val mimeType: String, val length: Long, val dataUri: String) {} class GoogleDeviceContainerItem(val mimeType: String, val length: Long, val dataUri: String)

View file

@ -1,11 +1,12 @@
package deckers.thibault.aves.metadata package deckers.thibault.aves.metadata
import android.annotation.SuppressLint
import android.media.MediaFormat import android.media.MediaFormat
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.os.Build import android.os.Build
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.Date
import java.util.Locale
import java.util.TimeZone
object MediaMetadataRetrieverHelper { object MediaMetadataRetrieverHelper {
val allKeys = hashMapOf( val allKeys = hashMapOf(
@ -31,11 +32,8 @@ object MediaMetadataRetrieverHelper {
MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH to "Video Width", MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH to "Video Width",
MediaMetadataRetriever.METADATA_KEY_WRITER to "Writer", MediaMetadataRetriever.METADATA_KEY_WRITER to "Writer",
MediaMetadataRetriever.METADATA_KEY_YEAR to "Year", MediaMetadataRetriever.METADATA_KEY_YEAR to "Year",
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION to "Video Rotation",
).apply { ).apply {
@SuppressLint("ObsoleteSdkInt")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
put(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION, "Video Rotation")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
put(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE, "Capture Framerate") put(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE, "Capture Framerate")
} }

View file

@ -134,6 +134,8 @@ object Metadata {
private fun getSafeUri(context: Context, uri: Uri, mimeType: String, sizeBytes: Long?): Uri { private fun getSafeUri(context: Context, uri: Uri, mimeType: String, sizeBytes: Long?): Uri {
return when (mimeType) { return when (mimeType) {
// formats known to yield OOM for large files // formats known to yield OOM for large files
MimeTypes.HEIC,
MimeTypes.HEIF,
MimeTypes.MP4, MimeTypes.MP4,
MimeTypes.PSD_VND, MimeTypes.PSD_VND,
MimeTypes.PSD_X, MimeTypes.PSD_X,

View file

@ -1,6 +1,5 @@
package deckers.thibault.aves.metadata package deckers.thibault.aves.metadata
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.media.MediaExtractor import android.media.MediaExtractor
import android.media.MediaFormat import android.media.MediaFormat
@ -63,10 +62,7 @@ object MultiPage {
format.getSafeInt(MediaFormat.KEY_WIDTH) { track[KEY_WIDTH] = it } format.getSafeInt(MediaFormat.KEY_WIDTH) { track[KEY_WIDTH] = it }
format.getSafeInt(MediaFormat.KEY_HEIGHT) { track[KEY_HEIGHT] = it } format.getSafeInt(MediaFormat.KEY_HEIGHT) { track[KEY_HEIGHT] = it }
@SuppressLint("ObsoleteSdkInt") format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { track[KEY_IS_DEFAULT] = it != 0 }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { track[KEY_IS_DEFAULT] = it != 0 }
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
format.getSafeInt(MediaFormat.KEY_ROTATION) { track[KEY_ROTATION_DEGREES] = it } format.getSafeInt(MediaFormat.KEY_ROTATION) { track[KEY_ROTATION_DEGREES] = it }
} }

View file

@ -67,14 +67,16 @@ object Helper {
val metadata = when (fileType) { val metadata = when (fileType) {
FileType.Jpeg -> safeReadJpeg(inputStream) FileType.Jpeg -> safeReadJpeg(inputStream)
FileType.Mp4 -> safeReadMp4(inputStream)
FileType.Png -> safeReadPng(inputStream) FileType.Png -> safeReadPng(inputStream)
FileType.Psd -> safeReadPsd(inputStream)
FileType.Tiff, FileType.Tiff,
FileType.Arw, FileType.Arw,
FileType.Cr2, FileType.Cr2,
FileType.Nef, FileType.Nef,
FileType.Orf, FileType.Orf,
FileType.Rw2 -> safeReadTiff(inputStream) FileType.Rw2 -> safeReadTiff(inputStream)
FileType.Mp4 -> safeReadMp4(inputStream)
else -> ImageMetadataReader.readMetadata(inputStream, safeReadStreamLength, fileType) else -> ImageMetadataReader.readMetadata(inputStream, safeReadStreamLength, fileType)
} }
@ -100,6 +102,10 @@ object Helper {
return SafePngMetadataReader.readMetadata(input) return SafePngMetadataReader.readMetadata(input)
} }
private fun safeReadPsd(input: InputStream): com.drew.metadata.Metadata {
return SafePsdMetadataReader.readMetadata(input)
}
@Throws(IOException::class, TiffProcessingException::class) @Throws(IOException::class, TiffProcessingException::class)
fun safeReadTiff(input: InputStream): com.drew.metadata.Metadata { fun safeReadTiff(input: InputStream): com.drew.metadata.Metadata {
val reader = RandomAccessStreamReader(input, RandomAccessStreamReader.DEFAULT_CHUNK_LENGTH, safeReadStreamLength) val reader = RandomAccessStreamReader(input, RandomAccessStreamReader.DEFAULT_CHUNK_LENGTH, safeReadStreamLength)
@ -262,6 +268,7 @@ object Helper {
ExifReader().extract(ByteArrayReader(dataBytes), metadata, ExifReader.JPEG_SEGMENT_PREAMBLE.length) ExifReader().extract(ByteArrayReader(dataBytes), metadata, ExifReader.JPEG_SEGMENT_PREAMBLE.length)
} }
} }
PNG_RAW_PROFILE_IPTC -> { PNG_RAW_PROFILE_IPTC -> {
val start = dataBytes.indexOf(Metadata.IPTC_MARKER_BYTE) val start = dataBytes.indexOf(Metadata.IPTC_MARKER_BYTE)
if (start != -1) { if (start != -1) {

View file

@ -0,0 +1,22 @@
package deckers.thibault.aves.metadata.metadataextractor
import com.drew.imaging.png.PngChunkType
import com.drew.metadata.png.PngDirectory
class PngActlDirectory : PngDirectory(chunkType) {
override fun getTagNameMap(): HashMap<Int, String> {
return tagNames
}
companion object {
val chunkType = PngChunkType("acTL")
// tags should be distinct from those already defined in `PngDirectory`
const val TAG_NUM_FRAMES = 101
const val TAG_NUM_PLAYS = 102
private val tagNames = hashMapOf(
TAG_NUM_FRAMES to "Number Of Frames",
TAG_NUM_PLAYS to "Number Of Plays",
)
}
}

View file

@ -1,8 +1,6 @@
package deckers.thibault.aves.metadata.metadataextractor package deckers.thibault.aves.metadata.metadataextractor
import com.drew.imaging.mp4.Mp4Handler import com.drew.imaging.mp4.Mp4Handler
import com.drew.lang.annotations.NotNull
import com.drew.lang.annotations.Nullable
import com.drew.metadata.Metadata import com.drew.metadata.Metadata
import com.drew.metadata.mp4.Mp4BoxHandler import com.drew.metadata.mp4.Mp4BoxHandler
import com.drew.metadata.mp4.Mp4BoxTypes import com.drew.metadata.mp4.Mp4BoxTypes
@ -11,7 +9,7 @@ import java.io.IOException
class SafeMp4BoxHandler(metadata: Metadata) : Mp4BoxHandler(metadata) { class SafeMp4BoxHandler(metadata: Metadata) : Mp4BoxHandler(metadata) {
@Throws(IOException::class) @Throws(IOException::class)
override fun processBox(@NotNull type: String, @Nullable payload: ByteArray?, boxSize: Long, context: Mp4Context?): Mp4Handler<*>? { override fun processBox(type: String, payload: ByteArray?, boxSize: Long, context: Mp4Context?): Mp4Handler<*>? {
if (payload != null && type == Mp4BoxTypes.BOX_USER_DEFINED) { if (payload != null && type == Mp4BoxTypes.BOX_USER_DEFINED) {
val userBoxHandler = SafeMp4UuidBoxHandler(metadata) val userBoxHandler = SafeMp4UuidBoxHandler(metadata)
userBoxHandler.processBox(type, payload, boxSize, context) userBoxHandler.processBox(type, payload, boxSize, context)

View file

@ -0,0 +1,116 @@
package deckers.thibault.aves.metadata.metadataextractor
import com.drew.imaging.ImageProcessingException
import com.drew.lang.ByteArrayReader
import com.drew.lang.SequentialByteArrayReader
import com.drew.lang.SequentialReader
import com.drew.metadata.Directory
import com.drew.metadata.Metadata
import com.drew.metadata.exif.ExifReader
import com.drew.metadata.icc.IccReader
import com.drew.metadata.iptc.IptcReader
import com.drew.metadata.photoshop.PhotoshopDirectory
import com.drew.metadata.photoshop.PhotoshopReader
import java.util.Arrays
// adapted from `PhotoshopReader` to prevent OOM from reading large XMP
// as of `metadata-extractor` v2.18.0, there is no way to customize the Photoshop reader
// without copying the whole `extract` function
class SafePhotoshopReader : PhotoshopReader() {
override fun extract(reader: SequentialReader, length: Int, metadata: Metadata, parentDirectory: Directory?) {
val directory = PhotoshopDirectory()
metadata.addDirectory(directory)
if (parentDirectory != null) {
directory.parent = parentDirectory
}
// Data contains a sequence of Image Resource Blocks (IRBs):
//
// 4 bytes - Signature; mostly "8BIM" but "PHUT", "AgHg" and "DCSR" are also found
// 2 bytes - Resource identifier
// String - Pascal string, padded to make length even
// 4 bytes - Size of resource data which follows
// Data - The resource data, padded to make size even
//
// http://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#50577409_pgfId-1037504
var pos = 0
var clippingPathCount = 0
while (pos < length) {
try {
// 4 bytes for the signature ("8BIM", "PHUT", etc.)
val signature = reader.getString(4)
pos += 4
// 2 bytes for the resource identifier (tag type).
val tagType = reader.uInt16 // segment type
pos += 2
// A variable number of bytes holding a pascal string (two leading bytes for length).
var descriptionLength = reader.uInt8.toInt()
pos += 1
// Some basic bounds checking
if (descriptionLength < 0 || descriptionLength + pos > length) {
throw ImageProcessingException("Invalid string length")
}
// Get name (important for paths)
val description = StringBuilder()
descriptionLength += pos
// Loop through each byte and append to string
while (pos < descriptionLength) {
description.append(Char(reader.uInt8.toUShort()))
pos++
}
// The number of bytes is padded with a trailing zero, if needed, to make the size even.
if (pos % 2 != 0) {
reader.skip(1)
pos++
}
// 4 bytes for the size of the resource data that follows.
val byteCount = reader.int32
pos += 4
// The resource data.
var tagBytes = reader.getBytes(byteCount)
pos += byteCount
// The number of bytes is padded with a trailing zero, if needed, to make the size even.
if (pos % 2 != 0) {
reader.skip(1)
pos++
}
if (signature == "8BIM") {
when (tagType) {
PhotoshopDirectory.TAG_IPTC -> IptcReader().extract(SequentialByteArrayReader(tagBytes), metadata, tagBytes.size.toLong(), directory)
PhotoshopDirectory.TAG_ICC_PROFILE_BYTES -> IccReader().extract(ByteArrayReader(tagBytes), metadata, directory)
PhotoshopDirectory.TAG_EXIF_DATA_1,
PhotoshopDirectory.TAG_EXIF_DATA_3 -> ExifReader().extract(ByteArrayReader(tagBytes), metadata, 0, directory)
PhotoshopDirectory.TAG_XMP_DATA -> SafeXmpReader().extract(tagBytes, metadata, directory)
in 0x07D0..0x0BB6 -> {
clippingPathCount++
tagBytes = Arrays.copyOf(tagBytes, tagBytes.size + description.length + 1)
// Append description(name) to end of byte array with 1 byte before the description representing the length
for (i in tagBytes.size - description.length - 1 until tagBytes.size) {
if (i % (tagBytes.size - description.length - 1 + description.length) == 0) tagBytes[i] = description.length.toByte() else tagBytes[i] = description[i - (tagBytes.size - description.length - 1)].code.toByte()
}
// PhotoshopDirectory._tagNameMap[0x07CF + clippingPathCount] = "Path Info $clippingPathCount"
directory.setByteArray(0x07CF + clippingPathCount, tagBytes)
}
else -> directory.setByteArray(tagType, tagBytes)
}
// if (tagType in 0x0fa0..0x1387) {
// PhotoshopDirectory._tagNameMap[tagType] = String.format("Plug-in %d Data", tagType - 0x0fa0 + 1)
// }
}
} catch (ex: Exception) {
directory.addError(ex.message)
return
}
}
}
}

View file

@ -1,11 +1,24 @@
package deckers.thibault.aves.metadata.metadataextractor package deckers.thibault.aves.metadata.metadataextractor
import android.util.Log import android.util.Log
import com.drew.imaging.png.* import com.drew.imaging.png.PngChromaticities
import com.drew.imaging.png.PngChunk
import com.drew.imaging.png.PngChunkReader
import com.drew.imaging.png.PngChunkType
import com.drew.imaging.png.PngHeader
import com.drew.imaging.png.PngProcessingException
import com.drew.imaging.tiff.TiffProcessingException import com.drew.imaging.tiff.TiffProcessingException
import com.drew.imaging.tiff.TiffReader import com.drew.imaging.tiff.TiffReader
import com.drew.lang.* import com.drew.lang.ByteArrayReader
import com.drew.lang.annotations.NotNull import com.drew.lang.ByteConvert
import com.drew.lang.Charsets
import com.drew.lang.DateUtil
import com.drew.lang.KeyValuePair
import com.drew.lang.RandomAccessStreamReader
import com.drew.lang.SequentialByteArrayReader
import com.drew.lang.SequentialReader
import com.drew.lang.StreamReader
import com.drew.lang.StreamUtil
import com.drew.metadata.ErrorDirectory import com.drew.metadata.ErrorDirectory
import com.drew.metadata.Metadata import com.drew.metadata.Metadata
import com.drew.metadata.StringValue import com.drew.metadata.StringValue
@ -21,9 +34,10 @@ import java.io.InputStream
import java.util.zip.InflaterInputStream import java.util.zip.InflaterInputStream
import java.util.zip.ZipException import java.util.zip.ZipException
// adapted from `PngMetadataReader` to prevent reading OOM from large chunks // adapted from `PngMetadataReader` to:
// as of `metadata-extractor` v2.18.0, there is no way to customize the reader // - prevent OOM from reading large chunks. As of `metadata-extractor` v2.18.0, there is no way to customize the reader
// without copying `desiredChunkTypes` and the whole `processChunk` function // without copying `desiredChunkTypes` and the whole `processChunk` function.
// - parse `acTL` chunk to identify animated PNGs.
object SafePngMetadataReader { object SafePngMetadataReader {
private val LOG_TAG = LogUtils.createTag<SafePngMetadataReader>() private val LOG_TAG = LogUtils.createTag<SafePngMetadataReader>()
@ -47,6 +61,7 @@ object SafePngMetadataReader {
PngChunkType.pHYs, PngChunkType.pHYs,
PngChunkType.sBIT, PngChunkType.sBIT,
PngChunkType.eXIf, PngChunkType.eXIf,
PngActlDirectory.chunkType,
) )
@Throws(IOException::class, PngProcessingException::class) @Throws(IOException::class, PngProcessingException::class)
@ -64,7 +79,7 @@ object SafePngMetadataReader {
} }
@Throws(PngProcessingException::class, IOException::class) @Throws(PngProcessingException::class, IOException::class)
private fun processChunk(@NotNull metadata: Metadata, @NotNull chunk: PngChunk) { private fun processChunk(metadata: Metadata, chunk: PngChunk) {
val chunkType = chunk.type val chunkType = chunk.type
val bytes = chunk.bytes val bytes = chunk.bytes
@ -86,6 +101,21 @@ object SafePngMetadataReader {
directory.setInt(PngDirectory.TAG_FILTER_METHOD, header.filterMethod.toInt()) directory.setInt(PngDirectory.TAG_FILTER_METHOD, header.filterMethod.toInt())
directory.setInt(PngDirectory.TAG_INTERLACE_METHOD, header.interlaceMethod.toInt()) directory.setInt(PngDirectory.TAG_INTERLACE_METHOD, header.interlaceMethod.toInt())
metadata.addDirectory(directory) metadata.addDirectory(directory)
// TLAD insert start
} else if (chunkType == PngActlDirectory.chunkType) {
if (bytes.size != 8) {
throw PngProcessingException("Invalid number of bytes")
}
val reader = SequentialByteArrayReader(bytes)
try {
metadata.addDirectory(PngActlDirectory().apply {
setInt(PngActlDirectory.TAG_NUM_FRAMES, reader.int32)
setInt(PngActlDirectory.TAG_NUM_PLAYS, reader.int32)
})
} catch (ex: IOException) {
throw PngProcessingException(ex)
}
// TLAD insert end
} else if (chunkType == PngChunkType.PLTE) { } else if (chunkType == PngChunkType.PLTE) {
val directory = PngDirectory(PngChunkType.PLTE) val directory = PngDirectory(PngChunkType.PLTE)
directory.setInt(PngDirectory.TAG_PALETTE_SIZE, bytes.size / 3) directory.setInt(PngDirectory.TAG_PALETTE_SIZE, bytes.size / 3)

View file

@ -0,0 +1,13 @@
package deckers.thibault.aves.metadata.metadataextractor
import com.drew.lang.StreamReader
import com.drew.metadata.Metadata
import java.io.InputStream
object SafePsdMetadataReader {
fun readMetadata(inputStream: InputStream): Metadata {
val metadata = Metadata()
SafePsdReader().extract(StreamReader(inputStream), metadata)
return metadata
}
}

View file

@ -0,0 +1,93 @@
package deckers.thibault.aves.metadata.metadataextractor
import com.drew.lang.SequentialReader
import com.drew.metadata.Metadata
import com.drew.metadata.photoshop.PsdHeaderDirectory
import java.io.IOException
// adapted from `PsdReader` to prevent OOM from reading large XMP
// as of `metadata-extractor` v2.18.0, there is no way to customize the Photoshop reader
// without copying the whole `extract` function
class SafePsdReader {
fun extract(reader: SequentialReader, metadata: Metadata) {
val directory = PsdHeaderDirectory()
metadata.addDirectory(directory)
// FILE HEADER SECTION
try {
val signature = reader.int32
if (signature != 0x38425053) // "8BPS"
{
directory.addError("Invalid PSD file signature")
return
}
val version = reader.uInt16
if (version != 1 && version != 2) {
directory.addError("Invalid PSD file version (must be 1 or 2)")
return
}
// 6 reserved bytes are skipped here. They should be zero.
reader.skip(6)
val channelCount = reader.uInt16
directory.setInt(PsdHeaderDirectory.TAG_CHANNEL_COUNT, channelCount)
// even though this is probably an unsigned int, the max height in practice is 300,000
val imageHeight = reader.int32
directory.setInt(PsdHeaderDirectory.TAG_IMAGE_HEIGHT, imageHeight)
// even though this is probably an unsigned int, the max width in practice is 300,000
val imageWidth = reader.int32
directory.setInt(PsdHeaderDirectory.TAG_IMAGE_WIDTH, imageWidth)
val bitsPerChannel = reader.uInt16
directory.setInt(PsdHeaderDirectory.TAG_BITS_PER_CHANNEL, bitsPerChannel)
val colorMode = reader.uInt16
directory.setInt(PsdHeaderDirectory.TAG_COLOR_MODE, colorMode)
} catch (e: IOException) {
directory.addError("Unable to read PSD header")
return
}
// COLOR MODE DATA SECTION
try {
val sectionLength = reader.uInt32
/*
* Only indexed color and duotone (see the mode field in the File header section) have color mode data.
* For all other modes, this section is just the 4-byte length field, which is set to zero.
*
* Indexed color images: length is 768; color data contains the color table for the image,
* in non-interleaved order.
* Duotone images: color data contains the duotone specification (the format of which is not documented).
* Other applications that read Photoshop files can treat a duotone image as a gray image,
* and just preserve the contents of the duotone information when reading and writing the
* file.
*/
reader.skip(sectionLength)
} catch (e: IOException) {
return
}
// IMAGE RESOURCES SECTION
try {
val sectionLength = reader.uInt32
assert(sectionLength <= Int.MAX_VALUE)
SafePhotoshopReader().extract(reader, sectionLength.toInt(), metadata)
} catch (e: IOException) {
// ignore
}
// LAYER AND MASK INFORMATION SECTION (skipped)
// IMAGE DATA SECTION (skipped)
}
}

View file

@ -10,8 +10,6 @@ import com.adobe.internal.xmp.properties.XMPPropertyInfo
import com.drew.imaging.jpeg.JpegSegmentType import com.drew.imaging.jpeg.JpegSegmentType
import com.drew.lang.SequentialByteArrayReader import com.drew.lang.SequentialByteArrayReader
import com.drew.lang.SequentialReader import com.drew.lang.SequentialReader
import com.drew.lang.annotations.NotNull
import com.drew.lang.annotations.Nullable
import com.drew.metadata.Directory import com.drew.metadata.Directory
import com.drew.metadata.Metadata import com.drew.metadata.Metadata
import com.drew.metadata.xmp.XmpDirectory import com.drew.metadata.xmp.XmpDirectory
@ -63,12 +61,19 @@ class SafeXmpReader : XmpReader() {
} }
// adapted from `XmpReader` to provide different parsing options // adapted from `XmpReader` to provide different parsing options
override fun extract(@NotNull xmpBytes: ByteArray, offset: Int, length: Int, @NotNull metadata: Metadata, @Nullable parentDirectory: Directory?) { // and to detect large XMP when extracted directly (e.g. from Photoshop reader)
override fun extract(xmpBytes: ByteArray, offset: Int, length: Int, metadata: Metadata, parentDirectory: Directory?) {
val totalSize = xmpBytes.size
if (totalSize > SEGMENT_TYPE_SIZE_DANGER_THRESHOLD) {
logError(metadata, totalSize)
return
}
val directory = XmpDirectory() val directory = XmpDirectory()
if (parentDirectory != null) directory.parent = parentDirectory if (parentDirectory != null) directory.parent = parentDirectory
try { try {
val xmpMeta: XMPMeta = if (offset == 0 && length == xmpBytes.size) { val xmpMeta: XMPMeta = if (offset == 0 && length == totalSize) {
XMPMetaFactory.parseFromBuffer(xmpBytes, PARSE_OPTIONS) XMPMetaFactory.parseFromBuffer(xmpBytes, PARSE_OPTIONS)
} else { } else {
val buffer = ByteBuffer(xmpBytes, offset, length) val buffer = ByteBuffer(xmpBytes, offset, length)

View file

@ -1,12 +1,10 @@
package deckers.thibault.aves.model package deckers.thibault.aves.model
import android.annotation.SuppressLint
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.os.Build
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.drew.metadata.avi.AviDirectory import com.drew.metadata.avi.AviDirectory
import com.drew.metadata.exif.ExifIFD0Directory import com.drew.metadata.exif.ExifIFD0Directory
@ -147,10 +145,7 @@ class SourceEntry {
retriever.getSafeLong(MediaMetadataRetriever.METADATA_KEY_DURATION) { durationMillis = it } retriever.getSafeLong(MediaMetadataRetriever.METADATA_KEY_DURATION) { durationMillis = it }
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { sourceDateTakenMillis = it } retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { sourceDateTakenMillis = it }
retriever.getSafeString(MediaMetadataRetriever.METADATA_KEY_TITLE) { title = it } retriever.getSafeString(MediaMetadataRetriever.METADATA_KEY_TITLE) { title = it }
@SuppressLint("ObsoleteSdkInt") retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { sourceRotationDegrees = it }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { sourceRotationDegrees = it }
}
} catch (e: Exception) { } catch (e: Exception) {
// ignore // ignore
} finally { } finally {

View file

@ -45,11 +45,14 @@ import deckers.thibault.aves.utils.MimeTypes.canReadWithPixyMeta
import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata
import deckers.thibault.aves.utils.MimeTypes.extensionFor import deckers.thibault.aves.utils.MimeTypes.extensionFor
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import pixy.meta.meta.Metadata import pixy.meta.meta.Metadata
import pixy.meta.meta.MetadataType import pixy.meta.meta.MetadataType
import java.io.* import java.io.*
import java.nio.channels.Channels import java.nio.channels.Channels
import java.util.* import java.util.*
import kotlin.math.absoluteValue
abstract class ImageProvider { abstract class ImageProvider {
open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) {
@ -308,8 +311,7 @@ abstract class ImageProvider {
.apply(glideOptions) .apply(glideOptions)
.load(model) .load(model)
.submit(targetWidthPx, targetHeightPx) .submit(targetWidthPx, targetHeightPx)
@Suppress("BlockingMethodInNonBlockingContext") var bitmap = withContext(Dispatchers.IO) { target.get() }
var bitmap = target.get()
if (MimeTypes.needRotationAfterGlide(sourceMimeType)) { if (MimeTypes.needRotationAfterGlide(sourceMimeType)) {
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped) bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
} }
@ -457,7 +459,6 @@ abstract class ImageProvider {
editableFile.delete() editableFile.delete()
} }
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun captureFrame( suspend fun captureFrame(
contextWrapper: ContextWrapper, contextWrapper: ContextWrapper,
desiredNameWithoutExtension: String, desiredNameWithoutExtension: String,
@ -512,7 +513,7 @@ abstract class ImageProvider {
output.write(bytes) output.write(bytes)
} }
} else { } else {
val editableFile = File.createTempFile("aves", null).apply { val editableFile = withContext(Dispatchers.IO) { File.createTempFile("aves", null) }.apply {
deleteOnExit() deleteOnExit()
transferFrom(ByteArrayInputStream(bytes), bytes.size.toLong()) transferFrom(ByteArrayInputStream(bytes), bytes.size.toLong())
} }
@ -538,11 +539,7 @@ abstract class ImageProvider {
exif.setAttribute(ExifInterface.TAG_DATETIME, dateString) exif.setAttribute(ExifInterface.TAG_DATETIME, dateString)
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, dateString) exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, dateString)
val offsetInMinutes = TimeZone.getDefault().getOffset(dateTimeMillis) / 60000 val timeZoneString = getTimeZoneString(TimeZone.getDefault(), dateTimeMillis)
val offsetSign = if (offsetInMinutes < 0) "-" else "+"
val offsetHours = "${offsetInMinutes / 60}".padStart(2, '0')
val offsetMinutes = "${offsetInMinutes % 60}".padStart(2, '0')
val timeZoneString = "$offsetSign$offsetHours:$offsetMinutes"
exif.setAttribute(ExifInterface.TAG_OFFSET_TIME, timeZoneString) exif.setAttribute(ExifInterface.TAG_OFFSET_TIME, timeZoneString)
exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, timeZoneString) exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, timeZoneString)
@ -1387,6 +1384,15 @@ abstract class ImageProvider {
false false
} }
} }
fun getTimeZoneString(timeZone: TimeZone, dateTimeMillis: Long): String {
val offset = timeZone.getOffset(dateTimeMillis)
val offsetInMinutes = offset.absoluteValue / 60000
val offsetSign = if (offset < 0) "-" else "+"
val offsetHours = "${offsetInMinutes / 60}".padStart(2, '0')
val offsetMinutes = "${offsetInMinutes % 60}".padStart(2, '0')
return "$offsetSign$offsetHours:$offsetMinutes"
}
} }
} }

View file

@ -328,7 +328,6 @@ class MediaStoreImageProvider : ImageProvider() {
Log.d(LOG_TAG, "delete [permission:doc, file exists after content delete] document at uri=$uri path=$path") Log.d(LOG_TAG, "delete [permission:doc, file exists after content delete] document at uri=$uri path=$path")
val df = StorageUtils.getDocumentFile(contextWrapper, path, uri) val df = StorageUtils.getDocumentFile(contextWrapper, path, uri)
@Suppress("BlockingMethodInNonBlockingContext")
if (df != null && df.delete()) { if (df != null && df.delete()) {
scanObsoletePath(contextWrapper, uri, path, mimeType) scanObsoletePath(contextWrapper, uri, path, mimeType)
return return
@ -726,7 +725,6 @@ class MediaStoreImageProvider : ImageProvider() {
val df = StorageUtils.getDocumentFile(activity, oldPath, oldMediaUri) val df = StorageUtils.getDocumentFile(activity, oldPath, oldMediaUri)
df ?: throw Exception("failed to get document at path=$oldPath") df ?: throw Exception("failed to get document at path=$oldPath")
@Suppress("BlockingMethodInNonBlockingContext")
val renamed = df.renameTo(newFile.name) val renamed = df.renameTo(newFile.name)
if (!renamed) { if (!renamed) {
throw Exception("failed to rename document at path=$oldPath") throw Exception("failed to rename document at path=$oldPath")

View file

@ -5,9 +5,14 @@ import android.content.Intent
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.ResolveInfo import android.content.pm.ResolveInfo
import android.graphics.BitmapRegionDecoder
import android.location.Address
import android.location.Geocoder
import android.os.Build import android.os.Build
import android.os.Parcelable import android.os.Parcelable
import android.view.Display import android.view.Display
import java.io.IOException
import java.io.InputStream
inline fun <reified T> Intent.getParcelableExtraCompat(name: String): T? { inline fun <reified T> Intent.getParcelableExtraCompat(name: String): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@ -44,3 +49,44 @@ fun PackageManager.queryIntentActivitiesCompat(intent: Intent, flags: Int): List
queryIntentActivities(intent, flags) queryIntentActivities(intent, flags)
} }
} }
fun Geocoder.getFromLocationCompat(
latitude: Double,
longitude: Double,
maxResults: Int,
processAddresses: (addresses: List<Address>) -> Unit,
onError: (errorCode: String, errorMessage: String?, errorDetails: Any?) -> Unit,
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getFromLocation(latitude, longitude, maxResults, object : Geocoder.GeocodeListener {
override fun onGeocode(addresses: List<Address?>) = processAddresses(addresses.filterNotNull())
override fun onError(errorMessage: String?) {
onError("getAddress-asyncerror", "failed to get address", errorMessage)
}
})
} else {
try {
@Suppress("deprecation")
val addresses = getFromLocation(latitude, longitude, maxResults) ?: ArrayList()
processAddresses(addresses)
} catch (e: IOException) {
// `grpc failed`, etc.
onError("getAddress-network", "failed to get address because of network issues", e.message)
} catch (e: Exception) {
onError("getAddress-exception", "failed to get address", e.message)
}
}
}
object BitmapRegionDecoderCompat {
@Throws(IOException::class)
fun newInstance(input: InputStream): BitmapRegionDecoder? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
BitmapRegionDecoder.newInstance(input)
} else {
@Suppress("deprecation")
BitmapRegionDecoder.newInstance(input, false)
}
}
}

View file

@ -1,6 +1,5 @@
package deckers.thibault.aves.utils package deckers.thibault.aves.utils
import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@ -177,14 +176,10 @@ object PermissionManager {
val accessibleDirs = HashSet(getGrantedDirs(context)) val accessibleDirs = HashSet(getGrantedDirs(context))
accessibleDirs.addAll(context.getExternalFilesDirs(null).filterNotNull().map { it.path }) accessibleDirs.addAll(context.getExternalFilesDirs(null).filterNotNull().map { it.path })
// until API 18 / Android 4.3 / Jelly Bean MR2, removable storage is accessible by default like primary storage
// from API 19 / Android 4.4 / KitKat, removable storage requires access permission, at the file level // from API 19 / Android 4.4 / KitKat, removable storage requires access permission, at the file level
// from API 21 / Android 5.0 / Lollipop, removable storage requires access permission, but directory access grant is possible // from API 21 / Android 5.0 / Lollipop, removable storage requires access permission, but directory access grant is possible
// from API 30 / Android 11 / R, any storage requires access permission // from API 30 / Android 11 / R, any storage requires access permission
@SuppressLint("ObsoleteSdkInt") if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.JELLY_BEAN_MR2) {
accessibleDirs.addAll(StorageUtils.getVolumePaths(context))
} else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
accessibleDirs.add(StorageUtils.getPrimaryVolumePath(context)) accessibleDirs.add(StorageUtils.getPrimaryVolumePath(context))
} }
return accessibleDirs return accessibleDirs

View file

@ -5,7 +5,8 @@
<string name="search_shortcut_short_label">البحث</string> <string name="search_shortcut_short_label">البحث</string>
<string name="videos_shortcut_short_label">الفيديوهات</string> <string name="videos_shortcut_short_label">الفيديوهات</string>
<string name="analysis_channel_name">فحص الوسائط</string> <string name="analysis_channel_name">فحص الوسائط</string>
<string name="analysis_service_description">فحص الصور والفيديوهات</string>
<string name="analysis_notification_default_title">يتم فحص الوسائط</string> <string name="analysis_notification_default_title">يتم فحص الوسائط</string>
<string name="analysis_notification_action_stop">إيقاف</string> <string name="analysis_notification_action_stop">إيقاف</string>
<string name="safe_mode_shortcut_short_label">الوضع الآمن</string>
<string name="app_name">أيفيس</string>
</resources> </resources>

View file

@ -6,7 +6,6 @@
<string name="search_shortcut_short_label">گەڕان</string> <string name="search_shortcut_short_label">گەڕان</string>
<string name="videos_shortcut_short_label">ڤیدیۆ</string> <string name="videos_shortcut_short_label">ڤیدیۆ</string>
<string name="analysis_channel_name">گەڕان بۆ فایل</string> <string name="analysis_channel_name">گەڕان بۆ فایل</string>
<string name="analysis_service_description">گەڕان بۆ وێنە و ڤیدیۆ</string>
<string name="analysis_notification_default_title">گەڕان بۆ فایلەکان</string> <string name="analysis_notification_default_title">گەڕان بۆ فایلەکان</string>
<string name="analysis_notification_action_stop">وەستاندن</string> <string name="analysis_notification_action_stop">وەستاندن</string>
</resources> </resources>

View file

@ -5,7 +5,6 @@
<string name="search_shortcut_short_label">Hledat</string> <string name="search_shortcut_short_label">Hledat</string>
<string name="videos_shortcut_short_label">Videa</string> <string name="videos_shortcut_short_label">Videa</string>
<string name="analysis_channel_name">Prohledat média</string> <string name="analysis_channel_name">Prohledat média</string>
<string name="analysis_service_description">Prohledat obrázky a videa</string>
<string name="analysis_notification_default_title">Prohledávání médií</string> <string name="analysis_notification_default_title">Prohledávání médií</string>
<string name="analysis_notification_action_stop">Zastavit</string> <string name="analysis_notification_action_stop">Zastavit</string>
<string name="app_widget_label">Fotorámeček</string> <string name="app_widget_label">Fotorámeček</string>

View file

@ -6,7 +6,6 @@
<string name="search_shortcut_short_label">Suche</string> <string name="search_shortcut_short_label">Suche</string>
<string name="videos_shortcut_short_label">Videos</string> <string name="videos_shortcut_short_label">Videos</string>
<string name="analysis_channel_name">Analyse von Medien</string> <string name="analysis_channel_name">Analyse von Medien</string>
<string name="analysis_service_description">Bilder &amp; Videos scannen</string>
<string name="analysis_notification_default_title">Medien scannen</string> <string name="analysis_notification_default_title">Medien scannen</string>
<string name="analysis_notification_action_stop">Abbrechen</string> <string name="analysis_notification_action_stop">Abbrechen</string>
<string name="safe_mode_shortcut_short_label">Sicherer Modus</string> <string name="safe_mode_shortcut_short_label">Sicherer Modus</string>

View file

@ -6,7 +6,7 @@
<string name="search_shortcut_short_label">Αναζήτηση</string> <string name="search_shortcut_short_label">Αναζήτηση</string>
<string name="videos_shortcut_short_label">Βίντεο</string> <string name="videos_shortcut_short_label">Βίντεο</string>
<string name="analysis_channel_name">Σάρωση πολυμέσων</string> <string name="analysis_channel_name">Σάρωση πολυμέσων</string>
<string name="analysis_service_description">Σάρωση εικόνων &amp; Βίντεο</string>
<string name="analysis_notification_default_title">Σάρωση στοιχείων</string> <string name="analysis_notification_default_title">Σάρωση στοιχείων</string>
<string name="analysis_notification_action_stop">Διακοπή</string> <string name="analysis_notification_action_stop">Διακοπή</string>
<string name="safe_mode_shortcut_short_label">Ασφαλής κατάσταση λειτουργίας</string>
</resources> </resources>

View file

@ -6,7 +6,6 @@
<string name="search_shortcut_short_label">Búsqueda</string> <string name="search_shortcut_short_label">Búsqueda</string>
<string name="videos_shortcut_short_label">Vídeos</string> <string name="videos_shortcut_short_label">Vídeos</string>
<string name="analysis_channel_name">Explorar medios</string> <string name="analysis_channel_name">Explorar medios</string>
<string name="analysis_service_description">Explorar imágenes &amp; videos</string>
<string name="analysis_notification_default_title">Explorando medios</string> <string name="analysis_notification_default_title">Explorando medios</string>
<string name="analysis_notification_action_stop">Anular</string> <string name="analysis_notification_action_stop">Anular</string>
<string name="safe_mode_shortcut_short_label">Modo seguro</string> <string name="safe_mode_shortcut_short_label">Modo seguro</string>

View file

@ -3,7 +3,6 @@
<string name="search_shortcut_short_label">Bilatu</string> <string name="search_shortcut_short_label">Bilatu</string>
<string name="videos_shortcut_short_label">Bideoak</string> <string name="videos_shortcut_short_label">Bideoak</string>
<string name="app_widget_label">Argazki-markoa</string> <string name="app_widget_label">Argazki-markoa</string>
<string name="analysis_service_description">Irudiak eta bideoak eskaneatu</string>
<string name="wallpaper">Horma-papera</string> <string name="wallpaper">Horma-papera</string>
<string name="analysis_channel_name">Media eskaneatu</string> <string name="analysis_channel_name">Media eskaneatu</string>
<string name="analysis_notification_action_stop">Gelditu</string> <string name="analysis_notification_action_stop">Gelditu</string>

View file

@ -2,7 +2,6 @@
<resources> <resources>
<string name="videos_shortcut_short_label">ویدئو ها</string> <string name="videos_shortcut_short_label">ویدئو ها</string>
<string name="analysis_channel_name">کنکاش رسانه</string> <string name="analysis_channel_name">کنکاش رسانه</string>
<string name="analysis_service_description">کنکاش تصاویر و ویدئو ها</string>
<string name="search_shortcut_short_label">جستجو</string> <string name="search_shortcut_short_label">جستجو</string>
<string name="wallpaper">کاغذدیواری</string> <string name="wallpaper">کاغذدیواری</string>
<string name="analysis_notification_default_title">در حال کنکاش رسانه‌ها</string> <string name="analysis_notification_default_title">در حال کنکاش رسانه‌ها</string>

View file

@ -6,7 +6,6 @@
<string name="search_shortcut_short_label">Recherche</string> <string name="search_shortcut_short_label">Recherche</string>
<string name="videos_shortcut_short_label">Vidéos</string> <string name="videos_shortcut_short_label">Vidéos</string>
<string name="analysis_channel_name">Analyse des images</string> <string name="analysis_channel_name">Analyse des images</string>
<string name="analysis_service_description">Analyse des images &amp; vidéos</string>
<string name="analysis_notification_default_title">Analyse des images</string> <string name="analysis_notification_default_title">Analyse des images</string>
<string name="analysis_notification_action_stop">Annuler</string> <string name="analysis_notification_action_stop">Annuler</string>
<string name="safe_mode_shortcut_short_label">Mode sans échec</string> <string name="safe_mode_shortcut_short_label">Mode sans échec</string>

View file

@ -6,7 +6,6 @@
<string name="search_shortcut_short_label">Procura</string> <string name="search_shortcut_short_label">Procura</string>
<string name="videos_shortcut_short_label">Vídeos</string> <string name="videos_shortcut_short_label">Vídeos</string>
<string name="analysis_channel_name">Escaneo multimedia</string> <string name="analysis_channel_name">Escaneo multimedia</string>
<string name="analysis_service_description">Escanealas imaxes e os vídeos</string>
<string name="analysis_notification_default_title">Escaneando medios</string> <string name="analysis_notification_default_title">Escaneando medios</string>
<string name="analysis_notification_action_stop">Pare</string> <string name="analysis_notification_action_stop">Pare</string>
</resources> </resources>

View file

@ -8,5 +8,4 @@
<string name="analysis_channel_name">मीडिया जाँचे</string> <string name="analysis_channel_name">मीडिया जाँचे</string>
<string name="app_name">ऐवीज</string> <string name="app_name">ऐवीज</string>
<string name="videos_shortcut_short_label">वीडियो</string> <string name="videos_shortcut_short_label">वीडियो</string>
<string name="analysis_service_description">छवि &amp; वीडियो जाँचे</string>
</resources> </resources>

View file

@ -8,6 +8,5 @@
<string name="app_widget_label">Fotó keret</string> <string name="app_widget_label">Fotó keret</string>
<string name="safe_mode_shortcut_short_label">Biztonsági üzemmód</string> <string name="safe_mode_shortcut_short_label">Biztonsági üzemmód</string>
<string name="analysis_channel_name">Tartalom keresése</string> <string name="analysis_channel_name">Tartalom keresése</string>
<string name="analysis_service_description">Képek és videók keresése</string>
<string name="analysis_notification_default_title">Média beolvasása</string> <string name="analysis_notification_default_title">Média beolvasása</string>
</resources> </resources>

View file

@ -6,7 +6,6 @@
<string name="search_shortcut_short_label">Cari</string> <string name="search_shortcut_short_label">Cari</string>
<string name="videos_shortcut_short_label">Video</string> <string name="videos_shortcut_short_label">Video</string>
<string name="analysis_channel_name">Pindai media</string> <string name="analysis_channel_name">Pindai media</string>
<string name="analysis_service_description">Pindai gambar &amp; video</string>
<string name="analysis_notification_default_title">Memindai media</string> <string name="analysis_notification_default_title">Memindai media</string>
<string name="analysis_notification_action_stop">Berhenti</string> <string name="analysis_notification_action_stop">Berhenti</string>
<string name="safe_mode_shortcut_short_label">Mode aman</string> <string name="safe_mode_shortcut_short_label">Mode aman</string>

View file

@ -6,7 +6,6 @@
<string name="search_shortcut_short_label">Ricerca</string> <string name="search_shortcut_short_label">Ricerca</string>
<string name="videos_shortcut_short_label">Video</string> <string name="videos_shortcut_short_label">Video</string>
<string name="analysis_channel_name">Scansione media</string> <string name="analysis_channel_name">Scansione media</string>
<string name="analysis_service_description">Scansione immagini &amp; videos</string>
<string name="analysis_notification_default_title">Scansione in corso</string> <string name="analysis_notification_default_title">Scansione in corso</string>
<string name="analysis_notification_action_stop">Annulla</string> <string name="analysis_notification_action_stop">Annulla</string>
<string name="safe_mode_shortcut_short_label">Modalità provvisoria</string> <string name="safe_mode_shortcut_short_label">Modalità provvisoria</string>

View file

@ -6,7 +6,6 @@
<string name="search_shortcut_short_label">חיפוש</string> <string name="search_shortcut_short_label">חיפוש</string>
<string name="videos_shortcut_short_label">סרטים</string> <string name="videos_shortcut_short_label">סרטים</string>
<string name="analysis_channel_name">סריקת מדיה</string> <string name="analysis_channel_name">סריקת מדיה</string>
<string name="analysis_service_description">סרוק תמונות וסרטים</string>
<string name="analysis_notification_default_title">סורק מדיה</string> <string name="analysis_notification_default_title">סורק מדיה</string>
<string name="analysis_notification_action_stop">הפסק</string> <string name="analysis_notification_action_stop">הפסק</string>
</resources> </resources>

View file

@ -6,7 +6,6 @@
<string name="search_shortcut_short_label">検索</string> <string name="search_shortcut_short_label">検索</string>
<string name="videos_shortcut_short_label">動画</string> <string name="videos_shortcut_short_label">動画</string>
<string name="analysis_channel_name">メディアスキャン</string> <string name="analysis_channel_name">メディアスキャン</string>
<string name="analysis_service_description">画像と動画をスキャン</string>
<string name="analysis_notification_default_title">メディアをスキャン中</string> <string name="analysis_notification_default_title">メディアをスキャン中</string>
<string name="analysis_notification_action_stop">停止</string> <string name="analysis_notification_action_stop">停止</string>
<string name="safe_mode_shortcut_short_label">セーフモード</string> <string name="safe_mode_shortcut_short_label">セーフモード</string>

View file

@ -6,7 +6,6 @@
<string name="search_shortcut_short_label">검색</string> <string name="search_shortcut_short_label">검색</string>
<string name="videos_shortcut_short_label">동영상</string> <string name="videos_shortcut_short_label">동영상</string>
<string name="analysis_channel_name">미디어 분석</string> <string name="analysis_channel_name">미디어 분석</string>
<string name="analysis_service_description">사진과 동영상 분석</string>
<string name="analysis_notification_default_title">미디어 분석</string> <string name="analysis_notification_default_title">미디어 분석</string>
<string name="analysis_notification_action_stop">취소</string> <string name="analysis_notification_action_stop">취소</string>
<string name="safe_mode_shortcut_short_label">안전 모드</string> <string name="safe_mode_shortcut_short_label">안전 모드</string>

View file

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="analysis_service_description">Nuskaityti paveikslėlius ir vaizdo įrašus</string>
<string name="wallpaper">Ekrano paveikslėlis</string> <string name="wallpaper">Ekrano paveikslėlis</string>
<string name="videos_shortcut_short_label">Vaizdo įrašai</string> <string name="videos_shortcut_short_label">Vaizdo įrašai</string>
<string name="app_name">Aves</string> <string name="app_name">Aves</string>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">ഏവ്സ്</string>
<string name="wallpaper">ചുമർ ചിത്രം</string>
<string name="search_shortcut_short_label">തിരയുക</string>
<string name="videos_shortcut_short_label">വീഡിയോകൾ</string>
<string name="analysis_channel_name">മാധ്യമ സൂക്ഷ്മപരിശോധന</string>
<string name="analysis_notification_action_stop">നിർത്തുക</string>
</resources>

View file

@ -3,7 +3,6 @@
<string name="app_name">Aves</string> <string name="app_name">Aves</string>
<string name="videos_shortcut_short_label">Videoer</string> <string name="videos_shortcut_short_label">Videoer</string>
<string name="analysis_channel_name">Mediaskanning</string> <string name="analysis_channel_name">Mediaskanning</string>
<string name="analysis_service_description">Skann bilder og videoer</string>
<string name="analysis_notification_default_title">Skanning av media</string> <string name="analysis_notification_default_title">Skanning av media</string>
<string name="app_widget_label">Bilderamme</string> <string name="app_widget_label">Bilderamme</string>
<string name="wallpaper">Bakgrunnsbilde</string> <string name="wallpaper">Bakgrunnsbilde</string>

View file

@ -6,7 +6,6 @@
<string name="search_shortcut_short_label">Zoeken</string> <string name="search_shortcut_short_label">Zoeken</string>
<string name="videos_shortcut_short_label">Videos</string> <string name="videos_shortcut_short_label">Videos</string>
<string name="analysis_channel_name">Media indexeren</string> <string name="analysis_channel_name">Media indexeren</string>
<string name="analysis_service_description">Indexeren van afdbeeldingen &amp; videos</string>
<string name="analysis_notification_default_title">Indexeren van media</string> <string name="analysis_notification_default_title">Indexeren van media</string>
<string name="analysis_notification_action_stop">Stop</string> <string name="analysis_notification_action_stop">Stop</string>
</resources> </resources>

View file

@ -6,7 +6,6 @@
<string name="search_shortcut_short_label">Søk</string> <string name="search_shortcut_short_label">Søk</string>
<string name="videos_shortcut_short_label">Videoar</string> <string name="videos_shortcut_short_label">Videoar</string>
<string name="analysis_channel_name">Mediasøking</string> <string name="analysis_channel_name">Mediasøking</string>
<string name="analysis_service_description">Søk igjennom bilete og videoar</string>
<string name="analysis_notification_default_title">Søkjer igjennom media</string> <string name="analysis_notification_default_title">Søkjer igjennom media</string>
<string name="analysis_notification_action_stop">Stogg</string> <string name="analysis_notification_action_stop">Stogg</string>
</resources> </resources>

View file

@ -4,7 +4,6 @@
<string name="search_shortcut_short_label">Szukaj</string> <string name="search_shortcut_short_label">Szukaj</string>
<string name="videos_shortcut_short_label">Wideo</string> <string name="videos_shortcut_short_label">Wideo</string>
<string name="analysis_channel_name">Przeskanuj multimedia</string> <string name="analysis_channel_name">Przeskanuj multimedia</string>
<string name="analysis_service_description">Przeskanuj obrazy oraz wideo</string>
<string name="analysis_notification_default_title">Skanowanie multimediów</string> <string name="analysis_notification_default_title">Skanowanie multimediów</string>
<string name="analysis_notification_action_stop">Zatrzymaj</string> <string name="analysis_notification_action_stop">Zatrzymaj</string>
<string name="app_name">Aves</string> <string name="app_name">Aves</string>

View file

@ -6,7 +6,6 @@
<string name="search_shortcut_short_label">Procurar</string> <string name="search_shortcut_short_label">Procurar</string>
<string name="videos_shortcut_short_label">Vídeos</string> <string name="videos_shortcut_short_label">Vídeos</string>
<string name="analysis_channel_name">Digitalização de mídia</string> <string name="analysis_channel_name">Digitalização de mídia</string>
<string name="analysis_service_description">Digitalizar imagens &amp; vídeos</string>
<string name="analysis_notification_default_title">Digitalizando mídia</string> <string name="analysis_notification_default_title">Digitalizando mídia</string>
<string name="analysis_notification_action_stop">Pare</string> <string name="analysis_notification_action_stop">Pare</string>
</resources> </resources>

View file

@ -5,7 +5,6 @@
<string name="wallpaper">Tapet</string> <string name="wallpaper">Tapet</string>
<string name="videos_shortcut_short_label">Videoclipuri</string> <string name="videos_shortcut_short_label">Videoclipuri</string>
<string name="analysis_channel_name">Scanare media</string> <string name="analysis_channel_name">Scanare media</string>
<string name="analysis_service_description">Scanați imagini și videoclipuri</string>
<string name="analysis_notification_default_title">Scanarea suporturilor</string> <string name="analysis_notification_default_title">Scanarea suporturilor</string>
<string name="analysis_notification_action_stop">Stop</string> <string name="analysis_notification_action_stop">Stop</string>
<string name="search_shortcut_short_label">Căutare</string> <string name="search_shortcut_short_label">Căutare</string>

View file

@ -6,7 +6,6 @@
<string name="search_shortcut_short_label">Поиск</string> <string name="search_shortcut_short_label">Поиск</string>
<string name="videos_shortcut_short_label">Видео</string> <string name="videos_shortcut_short_label">Видео</string>
<string name="analysis_channel_name">Сканировать медия</string> <string name="analysis_channel_name">Сканировать медия</string>
<string name="analysis_service_description">Сканировать изображения и видео</string>
<string name="analysis_notification_default_title">Сканирование медиа</string> <string name="analysis_notification_default_title">Сканирование медиа</string>
<string name="analysis_notification_action_stop">Стоп</string> <string name="analysis_notification_action_stop">Стоп</string>
<string name="safe_mode_shortcut_short_label">Безопасный режим</string> <string name="safe_mode_shortcut_short_label">Безопасный режим</string>

View file

@ -7,6 +7,5 @@
<string name="videos_shortcut_short_label">Videá</string> <string name="videos_shortcut_short_label">Videá</string>
<string name="analysis_notification_action_stop">Zastaviť</string> <string name="analysis_notification_action_stop">Zastaviť</string>
<string name="analysis_channel_name">Skenovanie médií</string> <string name="analysis_channel_name">Skenovanie médií</string>
<string name="analysis_service_description">Skenovanie obrázkov &amp; videí</string>
<string name="analysis_notification_default_title">Skenovanie média</string> <string name="analysis_notification_default_title">Skenovanie média</string>
</resources> </resources>

View file

@ -6,7 +6,6 @@
<string name="search_shortcut_short_label">ค้นหา</string> <string name="search_shortcut_short_label">ค้นหา</string>
<string name="videos_shortcut_short_label">วิดีโอ</string> <string name="videos_shortcut_short_label">วิดีโอ</string>
<string name="analysis_channel_name">สแกนสื่อบันเทิง</string> <string name="analysis_channel_name">สแกนสื่อบันเทิง</string>
<string name="analysis_service_description">สแกนรูปภาพและวิดีโอ</string>
<string name="analysis_notification_default_title">กำลังสแกนสื่อบันเทิง</string> <string name="analysis_notification_default_title">กำลังสแกนสื่อบันเทิง</string>
<string name="analysis_notification_action_stop">หยุด</string> <string name="analysis_notification_action_stop">หยุด</string>
</resources> </resources>

View file

@ -6,7 +6,6 @@
<string name="search_shortcut_short_label">Arama</string> <string name="search_shortcut_short_label">Arama</string>
<string name="videos_shortcut_short_label">Videolar</string> <string name="videos_shortcut_short_label">Videolar</string>
<string name="analysis_channel_name">Medya tarama</string> <string name="analysis_channel_name">Medya tarama</string>
<string name="analysis_service_description">Görüntüleri ve videoları tarayın</string>
<string name="analysis_notification_default_title">Medya taranıyor</string> <string name="analysis_notification_default_title">Medya taranıyor</string>
<string name="analysis_notification_action_stop">Durdur</string> <string name="analysis_notification_action_stop">Durdur</string>
</resources> </resources>

View file

@ -5,7 +5,6 @@
<string name="search_shortcut_short_label">Пошук</string> <string name="search_shortcut_short_label">Пошук</string>
<string name="videos_shortcut_short_label">Відео</string> <string name="videos_shortcut_short_label">Відео</string>
<string name="analysis_channel_name">Сканувати медіа</string> <string name="analysis_channel_name">Сканувати медіа</string>
<string name="analysis_service_description">Сканувати зображення та відео</string>
<string name="analysis_notification_action_stop">Стоп</string> <string name="analysis_notification_action_stop">Стоп</string>
<string name="app_widget_label">Фоторамка</string> <string name="app_widget_label">Фоторамка</string>
<string name="analysis_notification_default_title">Сканування медіа</string> <string name="analysis_notification_default_title">Сканування медіа</string>

View file

@ -7,6 +7,5 @@
<string name="app_widget_label">相框</string> <string name="app_widget_label">相框</string>
<string name="search_shortcut_short_label">搜尋</string> <string name="search_shortcut_short_label">搜尋</string>
<string name="analysis_channel_name">媒體掃描</string> <string name="analysis_channel_name">媒體掃描</string>
<string name="analysis_service_description">掃描圖片和影片</string>
<string name="analysis_notification_action_stop">停止</string> <string name="analysis_notification_action_stop">停止</string>
</resources> </resources>

View file

@ -6,7 +6,6 @@
<string name="search_shortcut_short_label">搜索</string> <string name="search_shortcut_short_label">搜索</string>
<string name="videos_shortcut_short_label">视频</string> <string name="videos_shortcut_short_label">视频</string>
<string name="analysis_channel_name">媒体扫描</string> <string name="analysis_channel_name">媒体扫描</string>
<string name="analysis_service_description">扫描图像 &amp; 视频</string>
<string name="analysis_notification_default_title">正在扫描媒体库</string> <string name="analysis_notification_default_title">正在扫描媒体库</string>
<string name="analysis_notification_action_stop">停止</string> <string name="analysis_notification_action_stop">停止</string>
</resources> </resources>

View file

@ -7,7 +7,6 @@
<string name="search_shortcut_short_label">Search</string> <string name="search_shortcut_short_label">Search</string>
<string name="videos_shortcut_short_label">Videos</string> <string name="videos_shortcut_short_label">Videos</string>
<string name="analysis_channel_name">Media scan</string> <string name="analysis_channel_name">Media scan</string>
<string name="analysis_service_description">Scan images &amp; videos</string>
<string name="analysis_notification_default_title">Scanning media</string> <string name="analysis_notification_default_title">Scanning media</string>
<string name="analysis_notification_action_stop">Stop</string> <string name="analysis_notification_action_stop">Stop</string>
</resources> </resources>

View file

@ -0,0 +1,18 @@
package deckers.thibault.aves.model.provider
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.Month
import java.util.TimeZone
class ImageProviderTest {
@Test
fun imageProvider_CorrectEmailSimple_ReturnsTrue() {
val date = LocalDate.of(1990, Month.FEBRUARY, 11).toEpochDay()
assertEquals(ImageProvider.getTimeZoneString(TimeZone.getTimeZone("Europe/Paris"), date), "+01:00")
assertEquals(ImageProvider.getTimeZoneString(TimeZone.getTimeZone("UTC"), date), "+00:00")
assertEquals(ImageProvider.getTimeZoneString(TimeZone.getTimeZone("Asia/Kolkata"), date), "+05:30")
assertEquals(ImageProvider.getTimeZoneString(TimeZone.getTimeZone("America/Chicago"), date), "-06:00")
}
}

View file

@ -1,6 +1,11 @@
buildscript { buildscript {
ext { ext {
kotlin_version = '1.8.0' kotlin_version = '1.8.21'
agp_version = '8.0.1'
glide_version = '4.15.1'
// AppGallery Connect plugin versions: https://developer.huawei.com/consumer/en/doc/development/AppGallery-connect-Guides/agc-sdk-changenotes-0000001058732550
// TODO TLAD AppGallery Connect plugin v1.9.0.300 does not support Gradle 8+
huawei_agconnect_version = '1.9.0.300'
abiCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86': 3, 'x86_64': 4] abiCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86': 3, 'x86_64': 4]
useCrashlytics = gradle.startParameter.taskNames.any { task -> task.containsIgnoreCase("play") } useCrashlytics = gradle.startParameter.taskNames.any { task -> task.containsIgnoreCase("play") }
useHms = gradle.startParameter.taskNames.any { task -> task.containsIgnoreCase("huawei") } useHms = gradle.startParameter.taskNames.any { task -> task.containsIgnoreCase("huawei") }
@ -17,7 +22,7 @@ buildscript {
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:7.4.2' classpath "com.android.tools.build:gradle:$agp_version"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
if (useCrashlytics) { if (useCrashlytics) {
@ -28,7 +33,7 @@ buildscript {
if (useHms) { if (useHms) {
// HMS (used by some flavors only) // HMS (used by some flavors only)
classpath 'com.huawei.agconnect:agcp:1.8.0.300' classpath "com.huawei.agconnect:agcp:$huawei_agconnect_version"
} }
} }
} }
@ -57,6 +62,6 @@ subprojects {
project.evaluationDependsOn(':app') project.evaluationDependsOn(':app')
} }
task clean(type: Delete) { tasks.register('clean', Delete) {
delete rootProject.buildDir delete rootProject.buildDir
} }

View file

@ -15,3 +15,10 @@ android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete": # Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official kotlin.code.style=official
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false
android.nonFinalResIds=false
# fix for AppGallery Connect plugin which does not support yet Gradle 8
# cf https://developer.huawei.com/consumer/en/doc/development/AppGallery-connect-Guides/agc-common-faq-0000001063210244#section17273113244910
apmsInstrumentationEnabled=false

View file

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip

View file

@ -0,0 +1,5 @@
In v1.8.7:
- play your animated PNGs
- set your home to the Tags page
- enjoy the app in Norwegian (Nynorsk)
Full changelog available on GitHub

View file

@ -0,0 +1,5 @@
In v1.8.7:
- play your animated PNGs
- set your home to the Tags page
- enjoy the app in Norwegian (Nynorsk)
Full changelog available on GitHub

View file

@ -1,5 +1,5 @@
<i>Aves</i> aplikazioak mota guztitako irudi eta bideoak, nahiz ohiko zure JPEG eta MP4 fitxategiak eta exotikoagoak diren <b>orri ugaritako TIFF, SVG, AVI zaharrak eta are gehiago</b> maneiatzen ditu! Zure media-bilduma eskaneatzen du <b>mugimendu-argazkiak</b>,<b>panoramikak</b> (argazki esferikoak bezala ere ezagunak), <b>360°-ko bideoak</b>, baita <b>GeoTIFF</b> fitxategiak ere. <i>Aves</i> aplikazioak mota guztitako irudi eta bideoak, nahiz ohiko zure JPEG eta MP4 fitxategiak eta exotikoagoak diren <b>orri ugaritako TIFF, SVG, AVI zaharrak eta are gehiago</b> maneiatzen ditu! Zure media-bilduma eskaneatzen du <b>mugimendu-argazkiak</b>, <b>panoramikak</b> (argazki esferikoak bezala ere ezagunak), <b>360°-ko bideoak</b>, baita <b>GeoTIFF</b> fitxategiak ere.
<b>Nabigazioa eta bilaketa</b> <i>Aves</i> aplikazioaren zati garrantzitsu bat da. Helburua, erabiltzaileek albumetatik argazkietara, etiketetara, mapetara, etab. modu errazean mugi ahal izatea da. <b>Nabigazioa eta bilaketa</b> <i>Aves</i> aplikazioaren zati garrantzitsu bat da. Helburua, erabiltzaileek albumetatik argazkietara, etiketetara, mapetara, etab. modu errazean mugi ahal izatea da.
<i>Aves</i> Androidera (KitKatetik Android 13ra, Android TV barne) egiten da ezaugarri ugarirekin: <b>widgetak</b>, <b>aplikazioko lasterbideak</b>, <b>pantaila-babeslea</b> eta <b>bilaketa globala</b>. Baita ere, <b>media-bisore edo -hautagailu</b> bezala ere erabil daiteke. <i>Aves</i> Androidera (KitKatetik Android 13ra, Android TV barne) egiten da ezaugarri ugarirekin: <b>widgetak</b>, <b>aplikazioko lasterbideak</b>, <b>pantaila-babeslea</b> eta <b>bilaketa globala</b>. Baita ere, <b>media-bisore edo -hautagailu</b> bezala erabil daiteke.

View file

@ -0,0 +1,5 @@
<i>Aves</i> can handle all sorts of images and videos, including your typical JPEGs and MP4s, but also more exotic things like <b>multi-page TIFFs, SVGs, old AVIs and more</b>! It scans your media collection to identify <b>motion photos</b>, <b>panoramas</b> (aka photo spheres), <b>360° videos</b>, as well as <b>GeoTIFF</b> files.
<b>Navigation and search</b> is an important part of <i>Aves</i>. The goal is for users to easily flow from albums to photos to tags to maps, etc.
<i>Aves</i> integrates with Android (from KitKat to Android 13, including Android TV) with features such as <b>widgets</b>, <b>app shortcuts</b>, <b>screen saver</b> and <b>global search</b> handling. It also works as a <b>media viewer and picker</b>.

View file

@ -0,0 +1 @@
Gallery and metadata explorer

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

View file

@ -9,6 +9,7 @@ enum AppMode {
setWallpaper, setWallpaper,
slideshow, slideshow,
view, view,
edit,
} }
extension ExtraAppMode on AppMode { extension ExtraAppMode on AppMode {

View file

@ -27,7 +27,7 @@ class AppIconImage extends ImageProvider<AppIconImageKey> {
} }
@override @override
ImageStreamCompleter loadBuffer(AppIconImageKey key, DecoderBufferCallback decode) { ImageStreamCompleter loadImage(AppIconImageKey key, ImageDecoderCallback decode) {
return MultiFrameImageStreamCompleter( return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode), codec: _loadAsync(key, decode),
scale: key.scale, scale: key.scale,
@ -37,7 +37,7 @@ class AppIconImage extends ImageProvider<AppIconImageKey> {
); );
} }
Future<ui.Codec> _loadAsync(AppIconImageKey key, DecoderBufferCallback decode) async { Future<ui.Codec> _loadAsync(AppIconImageKey key, ImageDecoderCallback decode) async {
try { try {
final bytes = await appService.getAppIcon(key.packageName, key.size); final bytes = await appService.getAppIcon(key.packageName, key.size);
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes.isEmpty ? kTransparentImage : bytes); final buffer = await ui.ImmutableBuffer.fromUint8List(bytes.isEmpty ? kTransparentImage : bytes);

View file

@ -18,7 +18,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
} }
@override @override
ImageStreamCompleter loadBuffer(RegionProviderKey key, DecoderBufferCallback decode) { ImageStreamCompleter loadImage(RegionProviderKey key, ImageDecoderCallback decode) {
return MultiFrameImageStreamCompleter( return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode), codec: _loadAsync(key, decode),
scale: 1.0, scale: 1.0,
@ -28,7 +28,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
); );
} }
Future<ui.Codec> _loadAsync(RegionProviderKey key, DecoderBufferCallback decode) async { Future<ui.Codec> _loadAsync(RegionProviderKey key, ImageDecoderCallback decode) async {
final uri = key.uri; final uri = key.uri;
final mimeType = key.mimeType; final mimeType = key.mimeType;
final pageId = key.pageId; final pageId = key.pageId;

View file

@ -19,7 +19,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
} }
@override @override
ImageStreamCompleter loadBuffer(ThumbnailProviderKey key, DecoderBufferCallback decode) { ImageStreamCompleter loadImage(ThumbnailProviderKey key, ImageDecoderCallback decode) {
return MultiFrameImageStreamCompleter( return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode), codec: _loadAsync(key, decode),
scale: 1.0, scale: 1.0,
@ -30,7 +30,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
); );
} }
Future<ui.Codec> _loadAsync(ThumbnailProviderKey key, DecoderBufferCallback decode) async { Future<ui.Codec> _loadAsync(ThumbnailProviderKey key, ImageDecoderCallback decode) async {
final uri = key.uri; final uri = key.uri;
final mimeType = key.mimeType; final mimeType = key.mimeType;
final pageId = key.pageId; final pageId = key.pageId;

View file

@ -32,7 +32,7 @@ class UriImage extends ImageProvider<UriImage> with EquatableMixin {
} }
@override @override
ImageStreamCompleter loadBuffer(UriImage key, DecoderBufferCallback decode) { ImageStreamCompleter loadImage(UriImage key, ImageDecoderCallback decode) {
final chunkEvents = StreamController<ImageChunkEvent>(); final chunkEvents = StreamController<ImageChunkEvent>();
return MultiFrameImageStreamCompleter( return MultiFrameImageStreamCompleter(
@ -45,7 +45,7 @@ class UriImage extends ImageProvider<UriImage> with EquatableMixin {
); );
} }
Future<ui.Codec> _loadAsync(UriImage key, DecoderBufferCallback decode, StreamController<ImageChunkEvent> chunkEvents) async { Future<ui.Codec> _loadAsync(UriImage key, ImageDecoderCallback decode, StreamController<ImageChunkEvent> chunkEvents) async {
assert(key == this); assert(key == this);
try { try {

View file

@ -39,7 +39,7 @@
"@changeTooltip": {}, "@changeTooltip": {},
"actionRemove": "إزالة", "actionRemove": "إزالة",
"@actionRemove": {}, "@actionRemove": {},
"appName": "Aves", "appName": "أيفيس",
"@appName": {}, "@appName": {},
"welcomeOptional": "اختياري", "welcomeOptional": "اختياري",
"@welcomeOptional": {}, "@welcomeOptional": {},
@ -51,7 +51,7 @@
"@cancelTooltip": {}, "@cancelTooltip": {},
"previousTooltip": "السابق", "previousTooltip": "السابق",
"@previousTooltip": {}, "@previousTooltip": {},
"welcomeMessage": "مرحبا بكم في Aves", "welcomeMessage": "مرحبا بكم في أيفيس",
"@welcomeMessage": {}, "@welcomeMessage": {},
"applyButtonLabel": "تطبيق", "applyButtonLabel": "تطبيق",
"@applyButtonLabel": {}, "@applyButtonLabel": {},
@ -68,5 +68,7 @@
"hideTooltip": "إخفاء", "hideTooltip": "إخفاء",
"@hideTooltip": {}, "@hideTooltip": {},
"tagEditorPageAddTagTooltip": "إضافة علامة", "tagEditorPageAddTagTooltip": "إضافة علامة",
"@tagEditorPageAddTagTooltip": {} "@tagEditorPageAddTagTooltip": {},
"albumScreenRecordings": "تسجيل الشاشة",
"@albumScreenRecordings": {}
} }

View file

@ -1452,5 +1452,39 @@
"tagPlaceholderState": "Stát", "tagPlaceholderState": "Stát",
"@tagPlaceholderState": {}, "@tagPlaceholderState": {},
"settingsVideoBackgroundModeDialogTitle": "Režim na pozadí", "settingsVideoBackgroundModeDialogTitle": "Režim na pozadí",
"@settingsVideoBackgroundModeDialogTitle": {} "@settingsVideoBackgroundModeDialogTitle": {},
"saveCopyButtonLabel": "ULOŽIT KOPII",
"@saveCopyButtonLabel": {},
"applyTooltip": "Použít",
"@applyTooltip": {},
"editorTransformCrop": "Oříznout",
"@editorTransformCrop": {},
"editorTransformRotate": "Otočit",
"@editorTransformRotate": {},
"cropAspectRatioOriginal": "Originál",
"@cropAspectRatioOriginal": {},
"widgetTapUpdateWidget": "Aktualizovat widget",
"@widgetTapUpdateWidget": {},
"settingsVideoResumptionModeDialogTitle": "Obnovit přehrávání",
"@settingsVideoResumptionModeDialogTitle": {},
"settingsAskEverytime": "Vždy se zeptat",
"@settingsAskEverytime": {},
"tagEditorDiscardDialogMessage": "Chcete zrušit změny?",
"@tagEditorDiscardDialogMessage": {},
"maxBrightnessNever": "Nikdy",
"@maxBrightnessNever": {},
"maxBrightnessAlways": "Vždy",
"@maxBrightnessAlways": {},
"videoResumptionModeNever": "Nikdy",
"@videoResumptionModeNever": {},
"videoResumptionModeAlways": "Vždy",
"@videoResumptionModeAlways": {},
"exportEntryDialogQuality": "Kvalita",
"@exportEntryDialogQuality": {},
"settingsVideoPlaybackTile": "Přehrávání",
"@settingsVideoPlaybackTile": {},
"settingsVideoPlaybackPageTitle": "Přehrávání",
"@settingsVideoPlaybackPageTitle": {},
"settingsVideoResumptionModeTile": "Obnovit přehrávání",
"@settingsVideoResumptionModeTile": {}
} }

View file

@ -303,7 +303,7 @@
"@wallpaperTargetHomeLock": {}, "@wallpaperTargetHomeLock": {},
"widgetOpenPageHome": "Startseite öffnen", "widgetOpenPageHome": "Startseite öffnen",
"@widgetOpenPageHome": {}, "@widgetOpenPageHome": {},
"widgetOpenPageViewer": "Viewer öffnen", "widgetOpenPageViewer": "Betrachter öffnen",
"@widgetOpenPageViewer": {}, "@widgetOpenPageViewer": {},
"albumTierNew": "Neu", "albumTierNew": "Neu",
"@albumTierNew": {}, "@albumTierNew": {},
@ -1294,5 +1294,45 @@
"vaultBinUsageDialogMessage": "Einige Tresore verwenden den Papierkorb.", "vaultBinUsageDialogMessage": "Einige Tresore verwenden den Papierkorb.",
"@vaultBinUsageDialogMessage": {}, "@vaultBinUsageDialogMessage": {},
"pinDialogEnter": "PIN eingeben", "pinDialogEnter": "PIN eingeben",
"@pinDialogEnter": {} "@pinDialogEnter": {},
"maxBrightnessNever": "Nie",
"@maxBrightnessNever": {},
"maxBrightnessAlways": "Immer",
"@maxBrightnessAlways": {},
"videoResumptionModeNever": "Nie",
"@videoResumptionModeNever": {},
"videoResumptionModeAlways": "Immer",
"@videoResumptionModeAlways": {},
"exportEntryDialogQuality": "Qualität",
"@exportEntryDialogQuality": {},
"settingsAskEverytime": "Jedes Mal nachfragen",
"@settingsAskEverytime": {},
"settingsVideoPlaybackTile": "Wiedergabe",
"@settingsVideoPlaybackTile": {},
"settingsVideoPlaybackPageTitle": "Wiedergabe",
"@settingsVideoPlaybackPageTitle": {},
"settingsVideoResumptionModeTile": "Wiedergabe fortsetzen",
"@settingsVideoResumptionModeTile": {},
"settingsVideoResumptionModeDialogTitle": "Wiedergabe fortsetzen",
"@settingsVideoResumptionModeDialogTitle": {},
"tagEditorDiscardDialogMessage": "Möchten Sie die Änderungen verwerfen?",
"@tagEditorDiscardDialogMessage": {},
"saveCopyButtonLabel": "KOPIE SPEICHERN",
"@saveCopyButtonLabel": {},
"applyTooltip": "Anwenden",
"@applyTooltip": {},
"editorActionTransform": "Umwandeln",
"@editorActionTransform": {},
"editorTransformCrop": "Zuschneiden",
"@editorTransformCrop": {},
"editorTransformRotate": "Drehen",
"@editorTransformRotate": {},
"cropAspectRatioFree": "Frei",
"@cropAspectRatioFree": {},
"cropAspectRatioOriginal": "Original",
"@cropAspectRatioOriginal": {},
"cropAspectRatioSquare": "Quadrat",
"@cropAspectRatioSquare": {},
"widgetTapUpdateWidget": "Widget öffnen",
"@widgetTapUpdateWidget": {}
} }

View file

@ -1316,5 +1316,23 @@
"videoResumptionModeAlways": "Πάντα", "videoResumptionModeAlways": "Πάντα",
"@videoResumptionModeAlways": {}, "@videoResumptionModeAlways": {},
"settingsAskEverytime": "Ρωτήστε με κάθε φορά", "settingsAskEverytime": "Ρωτήστε με κάθε φορά",
"@settingsAskEverytime": {} "@settingsAskEverytime": {},
"saveCopyButtonLabel": "ΑΠΟΘΗΚΕΥΣΗ ΑΝΤΙΓΡΑΦΟΥ",
"@saveCopyButtonLabel": {},
"applyTooltip": "Εφαρμογή",
"@applyTooltip": {},
"editorActionTransform": "Μετατροπή",
"@editorActionTransform": {},
"editorTransformCrop": "Περικοπή",
"@editorTransformCrop": {},
"editorTransformRotate": "Περιστροφή",
"@editorTransformRotate": {},
"cropAspectRatioFree": "Ελεύθερη μορφή",
"@cropAspectRatioFree": {},
"cropAspectRatioOriginal": "Αρχικό",
"@cropAspectRatioOriginal": {},
"cropAspectRatioSquare": "Τετράγωνο",
"@cropAspectRatioSquare": {},
"widgetTapUpdateWidget": "Ενημέρωση γραφικού στοιχείου",
"@widgetTapUpdateWidget": {}
} }

View file

@ -50,7 +50,9 @@
"showButtonLabel": "SHOW", "showButtonLabel": "SHOW",
"hideButtonLabel": "HIDE", "hideButtonLabel": "HIDE",
"continueButtonLabel": "CONTINUE", "continueButtonLabel": "CONTINUE",
"saveCopyButtonLabel": "SAVE COPY",
"applyTooltip": "Apply",
"cancelTooltip": "Cancel", "cancelTooltip": "Cancel",
"changeTooltip": "Change", "changeTooltip": "Change",
"clearTooltip": "Clear", "clearTooltip": "Clear",
@ -141,6 +143,15 @@
"entryInfoActionExportMetadata": "Export metadata", "entryInfoActionExportMetadata": "Export metadata",
"entryInfoActionRemoveLocation": "Remove location", "entryInfoActionRemoveLocation": "Remove location",
"editorActionTransform": "Transform",
"editorTransformCrop": "Crop",
"editorTransformRotate": "Rotate",
"cropAspectRatioFree": "Free",
"cropAspectRatioOriginal": "Original",
"cropAspectRatioSquare": "Square",
"filterAspectRatioLandscapeLabel": "Landscape", "filterAspectRatioLandscapeLabel": "Landscape",
"filterAspectRatioPortraitLabel": "Portrait", "filterAspectRatioPortraitLabel": "Portrait",
"filterBinLabel": "Recycle bin", "filterBinLabel": "Recycle bin",
@ -270,6 +281,7 @@
"widgetOpenPageHome": "Open home", "widgetOpenPageHome": "Open home",
"widgetOpenPageCollection": "Open collection", "widgetOpenPageCollection": "Open collection",
"widgetOpenPageViewer": "Open viewer", "widgetOpenPageViewer": "Open viewer",
"widgetTapUpdateWidget": "Update widget",
"storageVolumeDescriptionFallbackPrimary": "Internal storage", "storageVolumeDescriptionFallbackPrimary": "Internal storage",
"storageVolumeDescriptionFallbackNonPrimary": "SD card", "storageVolumeDescriptionFallbackNonPrimary": "SD card",

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