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="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
### Added
@ -330,8 +352,8 @@ All notable changes to this project will be documented in this file.
- Albums / Countries / Tags: live title filter
- option to hide confirmation message after moving items to the bin
- Collection / Info: edit description via Exif / IPTC / XMP
- Info: read XMP from HEIC on Android >=11
- Collection: support HEIC motion photos on Android >=11
- Info: read XMP from HEIF on Android >=11
- Collection: support HEIF motion photos on Android >=11
- Search: `recently added` filter
- Dutch translation (thanks Martijn Fabrie, Koen Koppens)
@ -742,7 +764,7 @@ All notable changes to this project will be documented in this file.
### Fixed
- 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]
@ -837,7 +859,7 @@ All notable changes to this project will be documented in this file.
### Added
- Motion photo support
- Viewer: play videos in multi-track HEIC
- Viewer: play videos in multi-track HEIF
- Handle share intent
### Changed
@ -846,7 +868,7 @@ All notable changes to this project will be documented in this file.
### Fixed
- fixed crash when cataloguing large MP4/PSD
- crash when cataloguing large MP4/PSD
- prevent videos playing in the background when quickly switching entries
## [v1.4.0] - 2021-04-16
@ -964,7 +986,7 @@ All notable changes to this project will be documented in this file.
### 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
### 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: 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
### Removed

View file

@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id 'com.android.application'
id 'kotlin-android'
@ -36,17 +38,17 @@ if (keystorePropertiesFile.exists()) {
// for release using credentials in environment variables set up by GitHub Actions
// warning: in property file, single quotes should be escaped with a backslash
// but they should not be escaped when stored in env variables
keystoreProperties['storeFile'] = System.getenv('AVES_STORE_FILE') ?: '<NONE>'
keystoreProperties['storePassword'] = System.getenv('AVES_STORE_PASSWORD') ?: '<NONE>'
keystoreProperties['keyAlias'] = System.getenv('AVES_KEY_ALIAS') ?: '<NONE>'
keystoreProperties['keyPassword'] = System.getenv('AVES_KEY_PASSWORD') ?: '<NONE>'
keystoreProperties['googleApiKey'] = System.getenv('AVES_GOOGLE_API_KEY') ?: '<NONE>'
keystoreProperties['huaweiApiKey'] = System.getenv('AVES_HUAWEI_API_KEY') ?: '<NONE>'
keystoreProperties["storeFile"] = System.getenv("AVES_STORE_FILE") ?: "<NONE>"
keystoreProperties["storePassword"] = System.getenv("AVES_STORE_PASSWORD") ?: "<NONE>"
keystoreProperties["keyAlias"] = System.getenv("AVES_KEY_ALIAS") ?: "<NONE>"
keystoreProperties["keyPassword"] = System.getenv("AVES_KEY_PASSWORD") ?: "<NONE>"
keystoreProperties["googleApiKey"] = System.getenv("AVES_GOOGLE_API_KEY") ?: "<NONE>"
keystoreProperties["huaweiApiKey"] = System.getenv("AVES_HUAWEI_API_KEY") ?: "<NONE>"
}
android {
namespace 'deckers.thibault.aves'
compileSdkVersion 33
compileSdk 33
ndkVersion flutter.ndkVersion
compileOptions {
@ -60,10 +62,6 @@ android {
disable 'InvalidPackage'
}
kotlinOptions {
jvmTarget = '1.8'
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
@ -80,21 +78,21 @@ android {
targetSdkVersion 33
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
manifestPlaceholders = [googleApiKey: keystoreProperties['googleApiKey'] ?: '<NONE>',
huaweiApiKey: keystoreProperties['huaweiApiKey'] ?: '<NONE>']
manifestPlaceholders = [googleApiKey: keystoreProperties["googleApiKey"] ?: "<NONE>",
huaweiApiKey: keystoreProperties["huaweiApiKey"] ?: "<NONE>"]
multiDexEnabled true
}
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
storePassword keystoreProperties['storePassword']
keyAlias keystoreProperties["keyAlias"]
keyPassword keystoreProperties["keyPassword"]
storeFile keystoreProperties["storeFile"] ? file(keystoreProperties["storeFile"]) : null
storePassword keystoreProperties["storePassword"]
}
}
flavorDimensions "store"
flavorDimensions = ["store"]
productFlavors {
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 {
source '../..'
}
@ -195,20 +202,21 @@ repositories {
}
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.core:core-ktx:1.10.0'
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.exifinterface:exifinterface:1.3.6'
implementation 'androidx.lifecycle:lifecycle-process:2.6.1'
implementation 'androidx.media:media:1.6.0'
implementation 'androidx.multidex:multidex:2.0.1'
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.commonsware.cwac:document:0.5.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`
implementation 'org.slf4j:slf4j-simple:2.0.7'
@ -217,15 +225,17 @@ dependencies {
// - https://jitpack.io/p/deckerst/mp4parser
// - https://jitpack.io/p/deckerst/pixymeta-android
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a'
implementation 'com.github.deckerst.mp4parser:isoparser:42f2cdc087'
implementation 'com.github.deckerst.mp4parser:muxer:42f2cdc087'
implementation 'com.github.deckerst.mp4parser:isoparser:b7b853f2e3'
implementation 'com.github.deckerst.mp4parser:muxer:b7b853f2e3'
implementation 'com.github.deckerst:pixymeta-android:706bd73d6e'
// 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 'com.github.bumptech.glide:compiler:4.15.1'
kapt "com.github.bumptech.glide:compiler:$glide_version"
compileOnly rootProject.findProject(':streams_channel')
}

View file

@ -1,14 +1,7 @@
<?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"
xmlns:tools="http://schemas.android.com/tools"
package="deckers.thibault.aves"
android:installLocation="auto">
<uses-feature
@ -19,14 +12,18 @@ This change eventually prevents building the app with Flutter v3.7.11.
android:required="false" />
<!--
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`
TODO TLAD [Android 14 (API 34)] request/handle READ_MEDIA_VISUAL_USER_SELECTED permission
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_VIDEO" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
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
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
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/video" />
</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>
<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>
</service>
<service
android:name=".AnalysisService"
android:description="@string/analysis_service_description"
android:exported="false" />
<service
android:name=".ScreenSaverService"
android:exported="true"
@ -294,7 +295,7 @@ This change eventually prevents building the app with Flutter v3.7.11.
<meta-data
android:name="flutterEmbedding"
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
android:name="io.flutter.embedding.android.EnableImpeller"
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.streams.ImageByteStreamHandler
import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.FlutterUtils
import deckers.thibault.aves.utils.LogUtils
import io.flutter.FlutterInjector
@ -40,11 +41,11 @@ class HomeWidgetProvider : AppWidgetProvider() {
val widgetInfo = appWidgetManager.getAppWidgetOptions(widgetId)
defaultScope.launch {
val backgroundBytes = getBytes(context, widgetId, widgetInfo, drawEntryImage = false)
updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, backgroundBytes)
val backgroundProps = getProps(context, widgetId, widgetInfo, drawEntryImage = false)
updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, backgroundProps)
val imageBytes = getBytes(context, widgetId, widgetInfo, drawEntryImage = true, reuseEntry = false)
updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, imageBytes)
val imageProps = getProps(context, widgetId, widgetInfo, drawEntryImage = true, reuseEntry = false)
updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, imageProps)
}
}
}
@ -59,8 +60,8 @@ class HomeWidgetProvider : AppWidgetProvider() {
}
imageByteFetchJob = defaultScope.launch {
delay(500)
val imageBytes = getBytes(context, widgetId, widgetInfo, drawEntryImage = true, reuseEntry = true)
updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, imageBytes)
val imageProps = getProps(context, widgetId, widgetInfo, drawEntryImage = true, reuseEntry = true)
updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, imageProps)
}
}
@ -76,13 +77,13 @@ class HomeWidgetProvider : AppWidgetProvider() {
return Pair(widthPx, heightPx)
}
private suspend fun getBytes(
private suspend fun getProps(
context: Context,
widgetId: Int,
widgetInfo: Bundle,
drawEntryImage: Boolean,
reuseEntry: Boolean = false,
): ByteArray? {
): FieldMap? {
val (widthPx, heightPx) = getWidgetSizePx(context, widgetInfo)
if (widthPx == 0 || heightPx == 0) return null
@ -90,7 +91,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
val messenger = flutterEngine!!.dartExecutor
val channel = MethodChannel(messenger, WIDGET_DRAW_CHANNEL)
try {
val bytes = suspendCoroutine<Any?> { cont ->
val props = suspendCoroutine<Any?> { cont ->
defaultScope.launch {
FlutterUtils.runOnUiThread {
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) {
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,
widgetId: Int,
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)
if (widthPx == 0 || heightPx == 0) return
@ -139,11 +148,25 @@ class HomeWidgetProvider : AppWidgetProvider() {
val bitmap = Bitmap.createBitmap(widthPx, heightPx, Bitmap.Config.ARGB_8888)
bitmap.copyPixelsFromBuffer(ByteBuffer.wrap(bytes))
// 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)
val pendingIntent = if (updateOnTap) buildUpdateIntent(context, widgetId) else buildOpenAppIntent(context, widgetId)
val activity = PendingIntent.getActivity(
val views = RemoteViews(context.packageName, R.layout.app_widget).apply {
setImageViewBitmap(R.id.widget_img, bitmap)
setOnClickPendingIntent(R.id.widget_img, pendingIntent)
}
appWidgetManager.updateAppWidget(widgetId, views)
bitmap.recycle()
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to draw widget", e)
}
}
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,
@ -153,17 +176,23 @@ class HomeWidgetProvider : AppWidgetProvider() {
PendingIntent.FLAG_UPDATE_CURRENT
}
)
val views = RemoteViews(context.packageName, R.layout.app_widget).apply {
setImageViewBitmap(R.id.widget_img, bitmap)
setOnClickPendingIntent(R.id.widget_img, activity)
}
appWidgetManager.updateAppWidget(widgetId, views)
bitmap.recycle()
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to draw widget", e)
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 {

View file

@ -142,6 +142,7 @@ open class MainActivity : FlutterFragmentActivity() {
result.success(intentDataMap)
intentDataMap.clear()
}
"submitPickedItems" -> submitPickedItems(call)
"submitPickedCollectionFilters" -> submitPickedCollectionFilters(call)
}
@ -169,7 +170,6 @@ open class MainActivity : FlutterFragmentActivity() {
override fun onStop() {
Log.i(LOG_TAG, "onStop")
analysisHandler.detachFromActivity()
super.onStop()
}
@ -204,8 +204,10 @@ open class MainActivity : FlutterFragmentActivity() {
DOCUMENT_TREE_ACCESS_REQUEST -> onDocumentTreeAccessResult(requestCode, resultCode, data)
DELETE_SINGLE_PERMISSION_REQUEST,
MEDIA_WRITE_BULK_PERMISSION_REQUEST -> onScopedStoragePermissionResult(resultCode)
CREATE_FILE_REQUEST,
OPEN_FILE_REQUEST -> onStorageAccessResult(requestCode, data?.data)
PICK_COLLECTION_FILTERS_REQUEST -> onCollectionFiltersPickResult(resultCode, data)
}
}
@ -222,9 +224,8 @@ open class MainActivity : FlutterFragmentActivity() {
return
}
@SuppressLint("WrongConstant", "ObsoleteSdkInt")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
val canPersist = (intent.flags and Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) != 0
@SuppressLint("WrongConstant")
if (canPersist) {
// save access permissions across reboots
val takeFlags = (intent.flags
@ -236,7 +237,6 @@ open class MainActivity : FlutterFragmentActivity() {
Log.w(LOG_TAG, "failed to take persistable URI permission for uri=$treeUri", e)
}
}
}
// resume pending action
onStorageAccessResult(requestCode, treeUri)
@ -264,6 +264,7 @@ open class MainActivity : FlutterFragmentActivity() {
)
}
}
Intent.ACTION_VIEW, Intent.ACTION_SEND, "com.android.camera.action.REVIEW" -> {
(intent.data ?: intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM))?.let { uri ->
// 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 -> {
return hashMapOf(
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.ACTION_SEARCH -> {
val viewUri = intent.dataString
return if (viewUri != null) hashMapOf(
@ -293,6 +308,7 @@ open class MainActivity : FlutterFragmentActivity() {
INTENT_DATA_KEY_QUERY to intent.getStringExtra(SearchManager.QUERY),
)
}
INTENT_ACTION_PICK_COLLECTION_FILTERS -> {
val initialFilters = extractFiltersFromIntent(intent)
return hashMapOf(
@ -300,6 +316,7 @@ open class MainActivity : FlutterFragmentActivity() {
INTENT_DATA_KEY_FILTERS to initialFilters,
)
}
INTENT_ACTION_WIDGET_OPEN -> {
val widgetId = intent.getIntExtra(EXTRA_KEY_WIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
if (widgetId != AppWidgetManager.INVALID_APPWIDGET_ID) {
@ -309,9 +326,11 @@ open class MainActivity : FlutterFragmentActivity() {
)
}
}
Intent.ACTION_RUN -> {
// flutter run
}
else -> {
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 PICK_COLLECTION_FILTERS_REQUEST = 7
const val INTENT_ACTION_EDIT = "edit"
const val INTENT_ACTION_PICK_ITEMS = "pick_items"
const val INTENT_ACTION_PICK_COLLECTION_FILTERS = "pick_collection_filters"
const val INTENT_ACTION_SCREEN_SAVER = "screen_saver"

View file

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

View file

@ -1,38 +1,35 @@
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.Intent
import android.content.ServiceConnection
import android.os.Build
import android.os.IBinder
import android.util.Log
import deckers.thibault.aves.AnalysisService
import deckers.thibault.aves.AnalysisServiceBinder
import deckers.thibault.aves.AnalysisServiceListener
import deckers.thibault.aves.utils.ContextUtils.isMyServiceRunning
import deckers.thibault.aves.utils.LogUtils
import androidx.core.app.ComponentActivity
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.workDataOf
import deckers.thibault.aves.AnalysisWorker
import deckers.thibault.aves.utils.FlutterUtils
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
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)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"registerCallback" -> ioScope.launch { Coresult.safe(call, result, ::registerCallback) }
"startService" -> Coresult.safe(call, result, ::startAnalysis)
"startAnalysis" -> Coresult.safe(call, result, ::startAnalysis)
else -> result.notImplemented()
}
}
@SuppressLint("CommitPrefEdits")
private fun registerCallback(call: MethodCall, result: MethodChannel.Result) {
val callbackHandle = call.argument<Number>("callbackHandle")?.toLong()
if (callbackHandle == null) {
@ -40,9 +37,9 @@ class AnalysisHandler(private val activity: Activity, private val onAnalysisComp
return
}
activity.getSharedPreferences(AnalysisService.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
activity.getSharedPreferences(AnalysisWorker.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
.edit()
.putLong(AnalysisService.CALLBACK_HANDLE_KEY, callbackHandle)
.putLong(AnalysisWorker.CALLBACK_HANDLE_KEY, callbackHandle)
.apply()
result.success(true)
}
@ -55,22 +52,35 @@ class AnalysisHandler(private val activity: Activity, private val onAnalysisComp
}
// 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)) {
val intent = Intent(activity, AnalysisService::class.java)
.putExtra(AnalysisService.KEY_COMMAND, AnalysisService.COMMAND_START)
.putExtra(AnalysisService.KEY_ENTRY_IDS, entryIds?.toIntArray())
.putExtra(AnalysisService.KEY_FORCE, force)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Foreground services cannot start from background, but the service here may start fine
// while the current lifecycle state (via `ProcessLifecycleOwner.get().lifecycle.currentState`)
// is only `INITIALIZED`, so we should not preemptively return when the state is below `STARTED`.
activity.startForegroundService(intent)
} else {
activity.startService(intent)
// work `Data` cannot occupy more than 10240 bytes when serialized
// so we split it when we have a long list of entry IDs
val chunked = allEntryIds?.chunked(WORK_DATA_CHUNK_SIZE) ?: listOf(null)
fun buildRequest(entryIds: List<Int>?, progressOffset: Int): OneTimeWorkRequest {
val workData = workDataOf(
AnalysisWorker.KEY_ENTRY_IDS to entryIds?.toIntArray(),
AnalysisWorker.KEY_FORCE to force,
AnalysisWorker.KEY_PROGRESS_TOTAL to progressTotal,
AnalysisWorker.KEY_PROGRESS_OFFSET to progressOffset,
)
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()
result.success(null)
}
@ -78,44 +88,23 @@ class AnalysisHandler(private val activity: Activity, private val onAnalysisComp
private var attached = false
fun attachToActivity() {
if (activity.isMyServiceRunning(AnalysisService::class.java)) {
val intent = Intent(activity, AnalysisService::class.java)
activity.bindService(intent, connection, Context.BIND_AUTO_CREATE)
if (!attached) {
attached = true
}
}
override fun detachFromActivity() {
if (attached) {
attached = false
activity.unbindService(connection)
}
}
override fun refreshApp() {
if (attached) {
WorkManager.getInstance(activity).getWorkInfosForUniqueWorkLiveData(ANALYSIS_WORK_NAME).observe(activity) { list ->
if (list.any { it.state == WorkInfo.State.SUCCEEDED }) {
runBlocking {
FlutterUtils.runOnUiThread {
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 {
private val LOG_TAG = LogUtils.createTag<AnalysisHandler>()
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
import android.annotation.SuppressLint
import android.content.*
import android.content.pm.ApplicationInfo
import android.content.res.Configuration
@ -40,6 +39,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.*
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
// so we get their names in English as well as the current locale
val englishConfig = Configuration().apply {
@SuppressLint("ObsoleteSdkInt")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
setLocale(Locale.ENGLISH)
} else {
@Suppress("deprecation")
locale = Locale.ENGLISH
}
}
val pm = context.packageManager
@ -169,8 +163,8 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
.submit(size, size)
try {
@Suppress("BlockingMethodInNonBlockingContext")
data = target.get()?.getBytes(canHaveAlpha = true, recycle = false)
val bitmap = withContext(Dispatchers.IO) { target.get() }
data = bitmap?.getBytes(canHaveAlpha = true, recycle = false)
} catch (e: Exception) {
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(
"canGrantDirectoryAccess" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP),
"canPinShortcut" to ShortcutManagerCompat.isRequestPinShortcutSupported(context),
"canPrint" to (sdkInt >= Build.VERSION_CODES.KITKAT),
"canRenderFlagEmojis" to (sdkInt >= Build.VERSION_CODES.M),
"canRenderSubdivisionFlagEmojis" to (sdkInt >= Build.VERSION_CODES.O),
"canRequestManageMedia" to (sdkInt >= Build.VERSION_CODES.S),

View file

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

View file

@ -3,8 +3,8 @@ package deckers.thibault.aves.channel.calls
import android.content.Context
import android.location.Address
import android.location.Geocoder
import android.os.Build
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.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
@ -12,8 +12,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.io.IOException
import java.util.*
import java.util.Locale
// as of 2021/03/10, geocoding packages exist but:
// - `geocoder` is unmaintained
@ -76,26 +75,9 @@ class GeocodingHandler(private val context: Context) : MethodCallHandler {
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
geocoder!!.getFromLocation(latitude, longitude, maxResults, object : Geocoder.GeocodeListener {
override fun onGeocode(addresses: List<Address?>) = processAddresses(addresses.filterNotNull())
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)
}
}
geocoder!!.getFromLocationCompat(
latitude, longitude, maxResults, ::processAddresses,
) { code, message, details -> result.error(code, message, details) }
}
companion object {

View file

@ -1,13 +1,15 @@
package deckers.thibault.aves.channel.calls
import android.annotation.SuppressLint
import android.content.Context
import deckers.thibault.aves.SearchSuggestionsProvider
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
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 {
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) {
val callbackHandle = call.argument<Number>("callbackHandle")?.toLong()
if (callbackHandle == null) {

View file

@ -1,6 +1,5 @@
package deckers.thibault.aves.channel.calls
import android.annotation.SuppressLint
import android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri
@ -28,20 +27,30 @@ import com.drew.metadata.png.PngDirectory
import com.drew.metadata.webp.WebpDirectory
import com.drew.metadata.xmp.XmpDirectory
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.getSafeDateMillis
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDouble
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
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.getSafeDescription
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_EXIF_GEOTIFF
import deckers.thibault.aves.metadata.Metadata.DIR_PNG_TEXTUAL_DATA
import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
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.getPropArrayItemValues
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.getSafeString
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.utils.ContextUtils.queryContentPropValue
import deckers.thibault.aves.utils.LogUtils
@ -84,7 +94,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.json.JSONObject
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
import java.text.DecimalFormat
import java.text.ParseException
@ -305,12 +314,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
val key = kv.key
// `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) {
@SuppressLint("ObsoleteSdkInt")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
StandardCharsets.UTF_8
} else {
Charset.forName("UTF-8")
}
} else {
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 -> {
@ -747,10 +756,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int
try {
@SuppressLint("ObsoleteSdkInt")
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)) {
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.Rect
import android.net.Uri
import android.os.Build
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.decoder.MultiTrackImage
import deckers.thibault.aves.utils.BitmapRegionDecoderCompat
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils
@ -68,13 +68,7 @@ class RegionFetcher internal constructor(
try {
if (currentDecoderRef == null) {
val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input ->
@Suppress("BlockingMethodInNonBlockingContext")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
BitmapRegionDecoder.newInstance(input)
} else {
@Suppress("deprecation")
BitmapRegionDecoder.newInstance(input, false)
}
BitmapRegionDecoderCompat.newInstance(input)
}
if (newDecoder == 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
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)
}
@ -49,11 +49,11 @@ class ActivityWindowHandler(private val activity: Activity) : WindowHandler(acti
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)
}
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) {
result.error("getCutoutInsets-sdk", "unsupported SDK version=${Build.VERSION.SDK_INT}", null)
return

View file

@ -5,7 +5,7 @@ import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
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)
}
@ -21,7 +21,7 @@ class ServiceWindowHandler(service: Service) : WindowHandler(service) {
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)
}

View file

@ -40,7 +40,7 @@ abstract class WindowHandler(private val contextWrapper: ContextWrapper) : Metho
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)

View file

@ -1,6 +1,5 @@
package deckers.thibault.aves.channel.streams
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.net.Uri
@ -77,7 +76,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
private fun requestMediaFileAccess() {
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 }
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)
return
}
@ -112,12 +111,6 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
}
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 mimeType = args["mimeType"] as String?
val bytes = args["bytes"] as ByteArray?
@ -155,12 +148,6 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
}
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
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?) {
handler.post {
try {

View file

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

View file

@ -1,10 +1,8 @@
package deckers.thibault.aves.channel.streams
import android.annotation.SuppressLint
import android.content.Context
import android.database.ContentObserver
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.Settings
@ -34,14 +32,12 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
override fun onChange(selfChange: Boolean, uri: Uri?) {
if (update()) {
val settings: FieldMap = hashMapOf(
success(
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,14 +49,11 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
accelerometerRotation = newAccelerometerRotation
changed = true
}
@SuppressLint("ObsoleteSdkInt")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
val newTransitionAnimationScale = Settings.Global.getFloat(context.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE)
if (transitionAnimationScale != newTransitionAnimationScale) {
transitionAnimationScale = newTransitionAnimationScale
changed = true
}
}
} catch (e: Exception) {
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
}
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
import android.annotation.SuppressLint
import android.media.MediaFormat
import android.media.MediaMetadataRetriever
import android.os.Build
import java.text.SimpleDateFormat
import java.util.*
import java.util.Date
import java.util.Locale
import java.util.TimeZone
object MediaMetadataRetrieverHelper {
val allKeys = hashMapOf(
@ -31,11 +32,8 @@ object MediaMetadataRetrieverHelper {
MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH to "Video Width",
MediaMetadataRetriever.METADATA_KEY_WRITER to "Writer",
MediaMetadataRetriever.METADATA_KEY_YEAR to "Year",
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION to "Video Rotation",
).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) {
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 {
return when (mimeType) {
// formats known to yield OOM for large files
MimeTypes.HEIC,
MimeTypes.HEIF,
MimeTypes.MP4,
MimeTypes.PSD_VND,
MimeTypes.PSD_X,

View file

@ -1,6 +1,5 @@
package deckers.thibault.aves.metadata
import android.annotation.SuppressLint
import android.content.Context
import android.media.MediaExtractor
import android.media.MediaFormat
@ -63,10 +62,7 @@ object MultiPage {
format.getSafeInt(MediaFormat.KEY_WIDTH) { track[KEY_WIDTH] = it }
format.getSafeInt(MediaFormat.KEY_HEIGHT) { track[KEY_HEIGHT] = it }
@SuppressLint("ObsoleteSdkInt")
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) {
format.getSafeInt(MediaFormat.KEY_ROTATION) { track[KEY_ROTATION_DEGREES] = it }
}

View file

@ -67,14 +67,16 @@ object Helper {
val metadata = when (fileType) {
FileType.Jpeg -> safeReadJpeg(inputStream)
FileType.Mp4 -> safeReadMp4(inputStream)
FileType.Png -> safeReadPng(inputStream)
FileType.Psd -> safeReadPsd(inputStream)
FileType.Tiff,
FileType.Arw,
FileType.Cr2,
FileType.Nef,
FileType.Orf,
FileType.Rw2 -> safeReadTiff(inputStream)
FileType.Mp4 -> safeReadMp4(inputStream)
else -> ImageMetadataReader.readMetadata(inputStream, safeReadStreamLength, fileType)
}
@ -100,6 +102,10 @@ object Helper {
return SafePngMetadataReader.readMetadata(input)
}
private fun safeReadPsd(input: InputStream): com.drew.metadata.Metadata {
return SafePsdMetadataReader.readMetadata(input)
}
@Throws(IOException::class, TiffProcessingException::class)
fun safeReadTiff(input: InputStream): com.drew.metadata.Metadata {
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)
}
}
PNG_RAW_PROFILE_IPTC -> {
val start = dataBytes.indexOf(Metadata.IPTC_MARKER_BYTE)
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
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.mp4.Mp4BoxHandler
import com.drew.metadata.mp4.Mp4BoxTypes
@ -11,7 +9,7 @@ import java.io.IOException
class SafeMp4BoxHandler(metadata: Metadata) : Mp4BoxHandler(metadata) {
@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) {
val userBoxHandler = SafeMp4UuidBoxHandler(metadata)
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
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.TiffReader
import com.drew.lang.*
import com.drew.lang.annotations.NotNull
import com.drew.lang.ByteArrayReader
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.Metadata
import com.drew.metadata.StringValue
@ -21,9 +34,10 @@ import java.io.InputStream
import java.util.zip.InflaterInputStream
import java.util.zip.ZipException
// adapted from `PngMetadataReader` to prevent reading OOM from 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
// adapted from `PngMetadataReader` to:
// - 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.
// - parse `acTL` chunk to identify animated PNGs.
object SafePngMetadataReader {
private val LOG_TAG = LogUtils.createTag<SafePngMetadataReader>()
@ -47,6 +61,7 @@ object SafePngMetadataReader {
PngChunkType.pHYs,
PngChunkType.sBIT,
PngChunkType.eXIf,
PngActlDirectory.chunkType,
)
@Throws(IOException::class, PngProcessingException::class)
@ -64,7 +79,7 @@ object SafePngMetadataReader {
}
@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 bytes = chunk.bytes
@ -86,6 +101,21 @@ object SafePngMetadataReader {
directory.setInt(PngDirectory.TAG_FILTER_METHOD, header.filterMethod.toInt())
directory.setInt(PngDirectory.TAG_INTERLACE_METHOD, header.interlaceMethod.toInt())
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) {
val directory = PngDirectory(PngChunkType.PLTE)
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.lang.SequentialByteArrayReader
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.Metadata
import com.drew.metadata.xmp.XmpDirectory
@ -63,12 +61,19 @@ class SafeXmpReader : XmpReader() {
}
// 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()
if (parentDirectory != null) directory.parent = parentDirectory
try {
val xmpMeta: XMPMeta = if (offset == 0 && length == xmpBytes.size) {
val xmpMeta: XMPMeta = if (offset == 0 && length == totalSize) {
XMPMetaFactory.parseFromBuffer(xmpBytes, PARSE_OPTIONS)
} else {
val buffer = ByteBuffer(xmpBytes, offset, length)

View file

@ -1,12 +1,10 @@
package deckers.thibault.aves.model
import android.annotation.SuppressLint
import android.content.ContentResolver
import android.content.Context
import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import androidx.exifinterface.media.ExifInterface
import com.drew.metadata.avi.AviDirectory
import com.drew.metadata.exif.ExifIFD0Directory
@ -147,10 +145,7 @@ class SourceEntry {
retriever.getSafeLong(MediaMetadataRetriever.METADATA_KEY_DURATION) { durationMillis = it }
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { sourceDateTakenMillis = it }
retriever.getSafeString(MediaMetadataRetriever.METADATA_KEY_TITLE) { title = it }
@SuppressLint("ObsoleteSdkInt")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { sourceRotationDegrees = it }
}
} catch (e: Exception) {
// ignore
} 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.extensionFor
import deckers.thibault.aves.utils.MimeTypes.isVideo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import pixy.meta.meta.Metadata
import pixy.meta.meta.MetadataType
import java.io.*
import java.nio.channels.Channels
import java.util.*
import kotlin.math.absoluteValue
abstract class ImageProvider {
open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) {
@ -308,8 +311,7 @@ abstract class ImageProvider {
.apply(glideOptions)
.load(model)
.submit(targetWidthPx, targetHeightPx)
@Suppress("BlockingMethodInNonBlockingContext")
var bitmap = target.get()
var bitmap = withContext(Dispatchers.IO) { target.get() }
if (MimeTypes.needRotationAfterGlide(sourceMimeType)) {
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
}
@ -457,7 +459,6 @@ abstract class ImageProvider {
editableFile.delete()
}
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun captureFrame(
contextWrapper: ContextWrapper,
desiredNameWithoutExtension: String,
@ -512,7 +513,7 @@ abstract class ImageProvider {
output.write(bytes)
}
} else {
val editableFile = File.createTempFile("aves", null).apply {
val editableFile = withContext(Dispatchers.IO) { File.createTempFile("aves", null) }.apply {
deleteOnExit()
transferFrom(ByteArrayInputStream(bytes), bytes.size.toLong())
}
@ -538,11 +539,7 @@ abstract class ImageProvider {
exif.setAttribute(ExifInterface.TAG_DATETIME, dateString)
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, dateString)
val offsetInMinutes = TimeZone.getDefault().getOffset(dateTimeMillis) / 60000
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"
val timeZoneString = getTimeZoneString(TimeZone.getDefault(), dateTimeMillis)
exif.setAttribute(ExifInterface.TAG_OFFSET_TIME, timeZoneString)
exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, timeZoneString)
@ -1387,6 +1384,15 @@ abstract class ImageProvider {
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")
val df = StorageUtils.getDocumentFile(contextWrapper, path, uri)
@Suppress("BlockingMethodInNonBlockingContext")
if (df != null && df.delete()) {
scanObsoletePath(contextWrapper, uri, path, mimeType)
return
@ -726,7 +725,6 @@ class MediaStoreImageProvider : ImageProvider() {
val df = StorageUtils.getDocumentFile(activity, oldPath, oldMediaUri)
df ?: throw Exception("failed to get document at path=$oldPath")
@Suppress("BlockingMethodInNonBlockingContext")
val renamed = df.renameTo(newFile.name)
if (!renamed) {
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.PackageManager
import android.content.pm.ResolveInfo
import android.graphics.BitmapRegionDecoder
import android.location.Address
import android.location.Geocoder
import android.os.Build
import android.os.Parcelable
import android.view.Display
import java.io.IOException
import java.io.InputStream
inline fun <reified T> Intent.getParcelableExtraCompat(name: String): T? {
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)
}
}
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
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
@ -177,14 +176,10 @@ object PermissionManager {
val accessibleDirs = HashSet(getGrantedDirs(context))
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 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
@SuppressLint("ObsoleteSdkInt")
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) {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
accessibleDirs.add(StorageUtils.getPrimaryVolumePath(context))
}
return accessibleDirs

View file

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

View file

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

View file

@ -5,7 +5,6 @@
<string name="search_shortcut_short_label">Hledat</string>
<string name="videos_shortcut_short_label">Videa</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_action_stop">Zastavit</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="videos_shortcut_short_label">Videos</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_action_stop">Abbrechen</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="videos_shortcut_short_label">Βίντεο</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_action_stop">Διακοπή</string>
<string name="safe_mode_shortcut_short_label">Ασφαλής κατάσταση λειτουργίας</string>
</resources>

View file

@ -6,7 +6,6 @@
<string name="search_shortcut_short_label">Búsqueda</string>
<string name="videos_shortcut_short_label">Vídeos</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_action_stop">Anular</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="videos_shortcut_short_label">Bideoak</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="analysis_channel_name">Media eskaneatu</string>
<string name="analysis_notification_action_stop">Gelditu</string>

View file

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

View file

@ -6,7 +6,6 @@
<string name="search_shortcut_short_label">Recherche</string>
<string name="videos_shortcut_short_label">Vidéos</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_action_stop">Annuler</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="videos_shortcut_short_label">Vídeos</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_action_stop">Pare</string>
</resources>

View file

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

View file

@ -8,6 +8,5 @@
<string name="app_widget_label">Fotó keret</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_service_description">Képek és videók keresése</string>
<string name="analysis_notification_default_title">Média beolvasása</string>
</resources>

View file

@ -6,7 +6,6 @@
<string name="search_shortcut_short_label">Cari</string>
<string name="videos_shortcut_short_label">Video</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_action_stop">Berhenti</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="videos_shortcut_short_label">Video</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_action_stop">Annulla</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="videos_shortcut_short_label">סרטים</string>
<string name="analysis_channel_name">סריקת מדיה</string>
<string name="analysis_service_description">סרוק תמונות וסרטים</string>
<string name="analysis_notification_default_title">סורק מדיה</string>
<string name="analysis_notification_action_stop">הפסק</string>
</resources>

View file

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

View file

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

View file

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="analysis_service_description">Nuskaityti paveikslėlius ir vaizdo įrašus</string>
<string name="wallpaper">Ekrano paveikslėlis</string>
<string name="videos_shortcut_short_label">Vaizdo įrašai</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="videos_shortcut_short_label">Videoer</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="app_widget_label">Bilderamme</string>
<string name="wallpaper">Bakgrunnsbilde</string>

View file

@ -6,7 +6,6 @@
<string name="search_shortcut_short_label">Zoeken</string>
<string name="videos_shortcut_short_label">Videos</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_action_stop">Stop</string>
</resources>

View file

@ -6,7 +6,6 @@
<string name="search_shortcut_short_label">Søk</string>
<string name="videos_shortcut_short_label">Videoar</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_action_stop">Stogg</string>
</resources>

View file

@ -4,7 +4,6 @@
<string name="search_shortcut_short_label">Szukaj</string>
<string name="videos_shortcut_short_label">Wideo</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_action_stop">Zatrzymaj</string>
<string name="app_name">Aves</string>

View file

@ -6,7 +6,6 @@
<string name="search_shortcut_short_label">Procurar</string>
<string name="videos_shortcut_short_label">Vídeos</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_action_stop">Pare</string>
</resources>

View file

@ -5,7 +5,6 @@
<string name="wallpaper">Tapet</string>
<string name="videos_shortcut_short_label">Videoclipuri</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_action_stop">Stop</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="videos_shortcut_short_label">Видео</string>
<string name="analysis_channel_name">Сканировать медия</string>
<string name="analysis_service_description">Сканировать изображения и видео</string>
<string name="analysis_notification_default_title">Сканирование медиа</string>
<string name="analysis_notification_action_stop">Стоп</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="analysis_notification_action_stop">Zastaviť</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>
</resources>

View file

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

View file

@ -6,7 +6,6 @@
<string name="search_shortcut_short_label">Arama</string>
<string name="videos_shortcut_short_label">Videolar</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_action_stop">Durdur</string>
</resources>

View file

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

View file

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

View file

@ -6,7 +6,6 @@
<string name="search_shortcut_short_label">搜索</string>
<string name="videos_shortcut_short_label">视频</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_action_stop">停止</string>
</resources>

View file

@ -7,7 +7,6 @@
<string name="search_shortcut_short_label">Search</string>
<string name="videos_shortcut_short_label">Videos</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_action_stop">Stop</string>
</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 {
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]
useCrashlytics = gradle.startParameter.taskNames.any { task -> task.containsIgnoreCase("play") }
useHms = gradle.startParameter.taskNames.any { task -> task.containsIgnoreCase("huawei") }
@ -17,7 +22,7 @@ buildscript {
}
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"
if (useCrashlytics) {
@ -28,7 +33,7 @@ buildscript {
if (useHms) {
// 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')
}
task clean(type: Delete) {
tasks.register('clean', Delete) {
delete rootProject.buildDir
}

View file

@ -15,3 +15,10 @@ android.useAndroidX=true
android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete":
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
zipStoreBase=GRADLE_USER_HOME
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.
<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,
slideshow,
view,
edit,
}
extension ExtraAppMode on AppMode {

View file

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

View file

@ -18,7 +18,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
}
@override
ImageStreamCompleter loadBuffer(RegionProviderKey key, DecoderBufferCallback decode) {
ImageStreamCompleter loadImage(RegionProviderKey key, ImageDecoderCallback decode) {
return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode),
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 mimeType = key.mimeType;
final pageId = key.pageId;

View file

@ -19,7 +19,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
}
@override
ImageStreamCompleter loadBuffer(ThumbnailProviderKey key, DecoderBufferCallback decode) {
ImageStreamCompleter loadImage(ThumbnailProviderKey key, ImageDecoderCallback decode) {
return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode),
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 mimeType = key.mimeType;
final pageId = key.pageId;

View file

@ -32,7 +32,7 @@ class UriImage extends ImageProvider<UriImage> with EquatableMixin {
}
@override
ImageStreamCompleter loadBuffer(UriImage key, DecoderBufferCallback decode) {
ImageStreamCompleter loadImage(UriImage key, ImageDecoderCallback decode) {
final chunkEvents = StreamController<ImageChunkEvent>();
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);
try {

View file

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

View file

@ -1452,5 +1452,39 @@
"tagPlaceholderState": "Stát",
"@tagPlaceholderState": {},
"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": {},
"widgetOpenPageHome": "Startseite öffnen",
"@widgetOpenPageHome": {},
"widgetOpenPageViewer": "Viewer öffnen",
"widgetOpenPageViewer": "Betrachter öffnen",
"@widgetOpenPageViewer": {},
"albumTierNew": "Neu",
"@albumTierNew": {},
@ -1294,5 +1294,45 @@
"vaultBinUsageDialogMessage": "Einige Tresore verwenden den Papierkorb.",
"@vaultBinUsageDialogMessage": {},
"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": {},
"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",
"hideButtonLabel": "HIDE",
"continueButtonLabel": "CONTINUE",
"saveCopyButtonLabel": "SAVE COPY",
"applyTooltip": "Apply",
"cancelTooltip": "Cancel",
"changeTooltip": "Change",
"clearTooltip": "Clear",
@ -141,6 +143,15 @@
"entryInfoActionExportMetadata": "Export metadata",
"entryInfoActionRemoveLocation": "Remove location",
"editorActionTransform": "Transform",
"editorTransformCrop": "Crop",
"editorTransformRotate": "Rotate",
"cropAspectRatioFree": "Free",
"cropAspectRatioOriginal": "Original",
"cropAspectRatioSquare": "Square",
"filterAspectRatioLandscapeLabel": "Landscape",
"filterAspectRatioPortraitLabel": "Portrait",
"filterBinLabel": "Recycle bin",
@ -270,6 +281,7 @@
"widgetOpenPageHome": "Open home",
"widgetOpenPageCollection": "Open collection",
"widgetOpenPageViewer": "Open viewer",
"widgetTapUpdateWidget": "Update widget",
"storageVolumeDescriptionFallbackPrimary": "Internal storage",
"storageVolumeDescriptionFallbackNonPrimary": "SD card",

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