Merge branch 'develop'
2
.flutter
|
@ -1 +1 @@
|
|||
Subproject commit 4d9e56e694b656610ab87fcf2efbcd226e0ed8cf
|
||||
Subproject commit 9cd3d0d9ff05768afa249e036acc66e8abe93bff
|
36
CHANGELOG.md
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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 ->
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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 & 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>
|
||||
|
|
|
@ -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">Σάρωση εικόνων & Βίντεο</string>
|
||||
<string name="analysis_notification_default_title">Σάρωση στοιχείων</string>
|
||||
<string name="analysis_notification_action_stop">Διακοπή</string>
|
||||
<string name="safe_mode_shortcut_short_label">Ασφαλής κατάσταση λειτουργίας</string>
|
||||
</resources>
|
|
@ -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 & 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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 & 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>
|
||||
|
|
|
@ -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>
|
|
@ -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">छवि & वीडियो जाँचे</string>
|
||||
</resources>
|
|
@ -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>
|
|
@ -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 & 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>
|
||||
|
|
|
@ -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 & 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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
9
android/app/src/main/res/values-ml/strings.xml
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -6,7 +6,6 @@
|
|||
<string name="search_shortcut_short_label">Zoeken</string>
|
||||
<string name="videos_shortcut_short_label">Video’s</string>
|
||||
<string name="analysis_channel_name">Media indexeren</string>
|
||||
<string name="analysis_service_description">Indexeren van afdbeeldingen & video’s</string>
|
||||
<string name="analysis_notification_default_title">Indexeren van media</string>
|
||||
<string name="analysis_notification_action_stop">Stop</string>
|
||||
</resources>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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 & vídeos</string>
|
||||
<string name="analysis_notification_default_title">Digitalizando mídia</string>
|
||||
<string name="analysis_notification_action_stop">Pare</string>
|
||||
</resources>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 & videí</string>
|
||||
<string name="analysis_notification_default_title">Skenovanie média</string>
|
||||
</resources>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 & videos</string>
|
||||
<string name="analysis_notification_default_title">Scanning media</string>
|
||||
<string name="analysis_notification_action_stop">Stop</string>
|
||||
</resources>
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
5
fastlane/metadata/android/en-US/changelogs/98.txt
Normal 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
|
5
fastlane/metadata/android/en-US/changelogs/9801.txt
Normal 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
|
|
@ -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.
|
5
fastlane/metadata/android/ml/full_description.txt
Normal 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>.
|
1
fastlane/metadata/android/ml/short_description.txt
Normal file
|
@ -0,0 +1 @@
|
|||
Gallery and metadata explorer
|
BIN
fastlane/metadata/android/nn/images/featureGraphic.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
fastlane/metadata/android/nn/images/phoneScreenshots/1.png
Normal file
After Width: | Height: | Size: 281 KiB |
BIN
fastlane/metadata/android/nn/images/phoneScreenshots/2.png
Normal file
After Width: | Height: | Size: 499 KiB |
BIN
fastlane/metadata/android/nn/images/phoneScreenshots/3.png
Normal file
After Width: | Height: | Size: 202 KiB |
BIN
fastlane/metadata/android/nn/images/phoneScreenshots/4.png
Normal file
After Width: | Height: | Size: 108 KiB |
BIN
fastlane/metadata/android/nn/images/phoneScreenshots/5.png
Normal file
After Width: | Height: | Size: 71 KiB |
BIN
fastlane/metadata/android/nn/images/phoneScreenshots/6.png
Normal file
After Width: | Height: | Size: 340 KiB |
BIN
fastlane/metadata/android/nn/images/phoneScreenshots/7.png
Normal file
After Width: | Height: | Size: 346 KiB |
|
@ -9,6 +9,7 @@ enum AppMode {
|
|||
setWallpaper,
|
||||
slideshow,
|
||||
view,
|
||||
edit,
|
||||
}
|
||||
|
||||
extension ExtraAppMode on AppMode {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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": {}
|
||||
}
|
||||
|
|
|
@ -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": {}
|
||||
}
|
||||
|
|
|
@ -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": {}
|
||||
}
|
||||
|
|
|
@ -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": {}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|