Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2022-07-24 23:08:06 +02:00
commit 968f6da395
238 changed files with 4807 additions and 1683 deletions

View file

@ -17,8 +17,8 @@ jobs:
# Available versions may lag behind https://github.com/flutter/flutter.git
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.0.2'
channel: 'stable'
flutter-version: '3.3.0-0.0.pre'
channel: 'beta'
- name: Clone the repository.
uses: actions/checkout@v2

View file

@ -19,8 +19,8 @@ jobs:
# Available versions may lag behind https://github.com/flutter/flutter.git
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.0.2'
channel: 'stable'
flutter-version: '3.3.0-0.0.pre'
channel: 'beta'
# Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1):
# https://issuetracker.google.com/issues/144111441
@ -56,15 +56,15 @@ jobs:
rm release.keystore.asc
mkdir outputs
(cd scripts/; ./apply_flavor_play.sh)
flutter build appbundle -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_3.0.2.sksl.json
flutter build appbundle -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_3.3.0-0.0.pre.sksl.json
cp build/app/outputs/bundle/playRelease/*.aab outputs
flutter build apk -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_3.0.2.sksl.json
flutter build apk -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_3.3.0-0.0.pre.sksl.json
cp build/app/outputs/apk/play/release/*.apk outputs
(cd scripts/; ./apply_flavor_huawei.sh)
flutter build apk -t lib/main_huawei.dart --flavor huawei --bundle-sksl-path shaders_3.0.2.sksl.json
flutter build apk -t lib/main_huawei.dart --flavor huawei --bundle-sksl-path shaders_3.3.0-0.0.pre.sksl.json
cp build/app/outputs/apk/huawei/release/*.apk outputs
(cd scripts/; ./apply_flavor_izzy.sh)
flutter build apk -t lib/main_izzy.dart --flavor izzy --split-per-abi --bundle-sksl-path shaders_3.0.2.sksl.json
flutter build apk -t lib/main_izzy.dart --flavor izzy --split-per-abi --bundle-sksl-path shaders_3.3.0-0.0.pre.sksl.json
cp build/app/outputs/apk/izzy/release/*.apk outputs
rm $AVES_STORE_FILE
env:

View file

@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file.
## <a id="unreleased"></a>[Unreleased]
## <a id="v1.6.10"></a>[v1.6.10] - 2022-07-24
### Added
- Search: `on this day` and month filters in date filter section
- Stats: histogram and contextual date filters
- Screen saver
- Widget: photo frame
### Changed
- viewer: black background when overlay is disabled with light theme
- filter chip long press menu shows full label
- upgraded Flutter to beta v3.3.0-0.0.pre
### Fixed
- analysis service stuck when storage has ambiguous directories
## <a id="v1.6.9"></a>[v1.6.9] - 2022-06-18
### Added

View file

@ -37,7 +37,7 @@ It scans your media collection to identify **motion photos**, **panoramas** (aka
**Navigation and search** is an important part of Aves. The goal is for users to easily flow from albums to photos to tags to maps, etc.
Aves integrates with Android (from **API 19 to 32**, i.e. from KitKat to Android 12L) with features such as **app shortcuts** and **global search** handling. It also works as a **media viewer and picker**.
Aves integrates with Android (from **API 19 to 33**, i.e. from KitKat to Android 13) with features such as **widgets**, **app shortcuts**, **screen saver** and **global search** handling. It also works as a **media viewer and picker**.
## Screenshots
@ -101,7 +101,7 @@ Some users have expressed the wish to financially support the project. Thanks!
[<img src="https://raw.githubusercontent.com/deckerst/common/main/assets/paypal-badge-cropped.png"
alt='Donate with PayPal'
height="40">](https://paypal.me/ThibaultDeckers)
height="40">](https://paypal.me/ThibaultDeckersFr)
[<img src="https://liberapay.com/assets/widgets/donate.svg"
alt='Donate using Liberapay'
height="40">](https://liberapay.com/deckerst/donate)

View file

@ -5,18 +5,25 @@
"DE":"connect-dre.dbankcloud.cn",
"DE_back":"connect-dre.hispace.hicloud.com",
"RU":"connect-drru.hispace.dbankcloud.ru",
"RU_back":"connect-drru.hispace.dbankcloud.ru",
"RU_back":"connect-drru.hispace.dbankcloud.cn",
"SG":"connect-dra.dbankcloud.cn",
"SG_back":"connect-dra.hispace.hicloud.com"
},
"websocketgw_all":{
"CN":"connect-ws-drcn.hispace.dbankcloud.cn",
"CN_back":"connect-ws-drcn.hispace.dbankcloud.com",
"DE":"connect-ws-dre.hispace.dbankcloud.cn",
"DE_back":"connect-ws-dre.hispace.dbankcloud.com",
"RU":"connect-ws-drru.hispace.dbankcloud.ru",
"RU_back":"connect-ws-drru.hispace.dbankcloud.cn",
"SG":"connect-ws-dra.hispace.dbankcloud.cn",
"SG_back":"connect-ws-dra.hispace.dbankcloud.com"
},
"client":{
"cp_id":"2640082000020010713",
"product_id":"99536292102197525",
"client_id":"874325707927340288",
"client_secret":"DCAFAE5C0440ABDBD6DDB2B6EBD7D9B0870C10FCA64759CCD63020D168803AB5",
"project_id":"99536292102197525",
"app_id":"106014023",
"api_key":"DAEDAEzScQA5ri36P2NEiVPSFrOJeYZ0DbEJZMGJrBadW+QudBr5BGHD3vO0tsL1VeBy0RPZefPic3hAWUijcBxCv0zRv0iBjQEptQ==",
"package_name":"deckers.thibault.aves"
},
"oauth_client":{
@ -30,17 +37,17 @@
"configuration_version":"3.0",
"appInfos":[
{
"package_name":"deckers.thibault.aves.profile",
"package_name":"deckers.thibault.aves",
"client":{
"app_id":"106031461"
"app_id":"106014023"
},
"app_info":{
"package_name":"deckers.thibault.aves.profile",
"app_id":"106031461"
"package_name":"deckers.thibault.aves",
"app_id":"106014023"
},
"oauth_client":{
"client_type":1,
"client_id":"106031461"
"client_id":"106014023"
}
},
{
@ -58,17 +65,17 @@
}
},
{
"package_name":"deckers.thibault.aves",
"package_name":"deckers.thibault.aves.profile",
"client":{
"app_id":"106014023"
"app_id":"106031461"
},
"app_info":{
"package_name":"deckers.thibault.aves",
"app_id":"106014023"
"package_name":"deckers.thibault.aves.profile",
"app_id":"106031461"
},
"oauth_client":{
"client_type":1,
"client_id":"106014023"
"client_id":"106031461"
}
}
]

View file

@ -38,6 +38,7 @@ if (keystorePropertiesFile.exists()) {
keystoreProperties['keyAlias'] = System.getenv('AVES_KEY_ALIAS')
keystoreProperties['keyPassword'] = System.getenv('AVES_KEY_PASSWORD')
keystoreProperties['googleApiKey'] = System.getenv('AVES_GOOGLE_API_KEY')
keystoreProperties['huaweiApiKey'] = System.getenv('AVES_HUAWEI_API_KEY')
}
android {
@ -47,7 +48,6 @@ android {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
applicationId appId
// minSdkVersion constraints:
@ -60,7 +60,8 @@ android {
targetSdkVersion 33
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
manifestPlaceholders = [googleApiKey: keystoreProperties['googleApiKey']]
manifestPlaceholders = [googleApiKey: keystoreProperties['googleApiKey'],
huaweiApiKey: keystoreProperties['huaweiApiKey']]
multiDexEnabled true
resValue 'string', 'search_provider', "${appId}.search_provider"
}
@ -140,7 +141,6 @@ android {
lint {
disable 'InvalidPackage'
}
namespace 'deckers.thibault.aves'
}
flutter {
@ -169,7 +169,7 @@ dependencies {
// huawei flavor only
huaweiImplementation 'com.huawei.agconnect:agconnect-core:1.5.2.300'
kapt 'androidx.annotation:annotation:1.3.0'
kapt 'androidx.annotation:annotation:1.4.0'
kapt 'com.github.bumptech.glide:compiler:4.13.0'
compileOnly rootProject.findProject(':streams_channel')

View file

@ -0,0 +1,2 @@
<dream xmlns:android="http://schemas.android.com/apk/res/android"
android:settingsActivity="deckers.thibault.aves.debug/deckers.thibault.aves.ScreenSaverSettingsActivity" />

View file

@ -1,5 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Android Studio Chipmunk (2021.2.1) recommends:
- removing "package" from AndroidManifest.xml
- adding it as "namespace" in app/build.gradle
This change eventually prevents building the app with Flutter v3.0.2.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="deckers.thibault.aves"
android:installLocation="auto">
<!--
@ -134,6 +143,11 @@
android:resource="@xml/searchable" />
</activity>
<activity
android:name=".ScreenSaverSettingsActivity"
android:exported="true"
android:theme="@style/NormalTheme" />
<activity
android:name=".WallpaperActivity"
android:exported="true"
@ -153,11 +167,49 @@
</intent-filter>
</activity>
<activity
android:name=".HomeWidgetSettingsActivity"
android:exported="false"
android:theme="@style/NormalTheme">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<receiver
android:name=".HomeWidgetProvider"
android:exported="false"
android:label="@string/app_widget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/app_widget_info" />
</receiver>
<service
android:name=".AnalysisService"
android:description="@string/analysis_service_description"
android:exported="false" />
<service
android:name=".ScreenSaverService"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:permission="android.permission.BIND_DREAM_SERVICE">
<intent-filter>
<action android:name="android.service.dreams.DreamService" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<meta-data
android:name="android.service.dream"
android:resource="@xml/screen_saver" />
</service>
<!-- file provider to share files having a file:// URI -->
<provider
android:name="androidx.core.content.FileProvider"
@ -178,6 +230,9 @@
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="${googleApiKey}" />
<meta-data
android:name="deckers.thibault.aves.huawei.API_KEY"
android:value="${huaweiApiKey}" />
<meta-data
android:name="firebase_crashlytics_collection_enabled"
android:value="false" />

View file

@ -11,10 +11,7 @@ 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.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.*
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler
import deckers.thibault.aves.utils.FlutterUtils
@ -25,7 +22,7 @@ import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.runBlocking
class AnalysisService : MethodChannel.MethodCallHandler, Service() {
private var backgroundFlutterEngine: FlutterEngine? = null
private var flutterEngine: FlutterEngine? = null
private var backgroundChannel: MethodChannel? = null
private var serviceLooper: Looper? = null
private var serviceHandler: ServiceHandler? = null
@ -37,18 +34,27 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() {
runBlocking {
FlutterUtils.initFlutterEngine(context, SHARED_PREFERENCES_KEY, CALLBACK_HANDLE_KEY) {
backgroundFlutterEngine = it
flutterEngine = it
}
}
val messenger = backgroundFlutterEngine!!.dartExecutor.binaryMessenger
val messenger = flutterEngine!!.dartExecutor
// channels for analysis
// dart -> platform -> dart
// - need Context
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this))
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
// result streaming: dart -> platform ->->-> dart
// - need Context
StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) }
StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) }
// channel for service management
backgroundChannel = MethodChannel(messenger, BACKGROUND_CHANNEL).apply {
setMethodCallHandler(context)
@ -67,7 +73,7 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() {
override fun onBind(intent: Intent) = analysisServiceBinder
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
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)
@ -76,7 +82,7 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() {
startForeground(NOTIFICATION_ID, buildNotification())
val msgData = Bundle()
intent.extras?.let {
intent?.extras?.let {
msgData.putAll(it)
}
serviceHandler?.obtainMessage()?.let { msg ->

View file

@ -0,0 +1,61 @@
package deckers.thibault.aves
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.os.Bundle
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class HomeWidgetSettingsActivity : MainActivity() {
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// cancel if user does not complete widget setup
setResult(RESULT_CANCELED)
intent.extras?.let {
appWidgetId = it.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
intentDataMap = extractIntentData(intent)
}
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
finish()
return
}
val messenger = flutterEngine!!.dartExecutor
MethodChannel(messenger, CHANNEL).setMethodCallHandler { call: MethodCall, result: MethodChannel.Result ->
when (call.method) {
"configure" -> {
result.success(null)
saveWidget()
}
else -> result.notImplemented()
}
}
}
private fun saveWidget() {
val appWidgetManager = AppWidgetManager.getInstance(context)
val widgetInfo = appWidgetManager.getAppWidgetOptions(appWidgetId)
HomeWidgetProvider().onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, widgetInfo)
val intent = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
setResult(RESULT_OK, intent)
finish()
}
override fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
return hashMapOf(
INTENT_DATA_KEY_ACTION to INTENT_ACTION_WIDGET_SETTINGS,
INTENT_DATA_KEY_WIDGET_ID to appWidgetId,
)
}
companion object {
private const val CHANNEL = "deckers.thibault/aves/widget_configure"
}
}

View file

@ -0,0 +1,202 @@
package deckers.thibault.aves
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.widget.RemoteViews
import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.calls.DeviceHandler
import deckers.thibault.aves.channel.calls.MediaFetchHandler
import deckers.thibault.aves.channel.calls.MediaStoreHandler
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.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.*
import java.nio.ByteBuffer
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
import kotlin.math.roundToInt
class HomeWidgetProvider : AppWidgetProvider() {
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
Log.d(LOG_TAG, "Widget onUpdate widgetIds=${appWidgetIds.contentToString()}")
for (widgetId in appWidgetIds) {
val widgetInfo = appWidgetManager.getAppWidgetOptions(widgetId)
defaultScope.launch {
val backgroundBytes = getBytes(context, widgetId, widgetInfo, drawEntryImage = false)
updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, backgroundBytes)
val imageBytes = getBytes(context, widgetId, widgetInfo, drawEntryImage = true, reuseEntry = false)
updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, imageBytes)
}
}
}
override fun onAppWidgetOptionsChanged(context: Context, appWidgetManager: AppWidgetManager?, widgetId: Int, widgetInfo: Bundle?) {
Log.d(LOG_TAG, "Widget onAppWidgetOptionsChanged widgetId=$widgetId")
appWidgetManager ?: return
widgetInfo ?: return
if (imageByteFetchJob != null) {
imageByteFetchJob?.cancel()
}
imageByteFetchJob = defaultScope.launch {
delay(500)
val imageBytes = getBytes(context, widgetId, widgetInfo, drawEntryImage = true, reuseEntry = true)
updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, imageBytes)
}
}
private suspend fun getBytes(
context: Context,
widgetId: Int,
widgetInfo: Bundle,
drawEntryImage: Boolean,
reuseEntry: Boolean = false,
): ByteArray? {
val devicePixelRatio = context.resources.displayMetrics.density
val widthPx = (widgetInfo.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) * devicePixelRatio).roundToInt()
val heightPx = (widgetInfo.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) * devicePixelRatio).roundToInt()
if (widthPx == 0 || heightPx == 0) return null
initFlutterEngine(context)
val messenger = flutterEngine!!.dartExecutor
val channel = MethodChannel(messenger, WIDGET_DRAW_CHANNEL)
try {
val bytes = suspendCoroutine { cont ->
defaultScope.launch {
FlutterUtils.runOnUiThread {
channel.invokeMethod("drawWidget", hashMapOf(
"widgetId" to widgetId,
"widthPx" to widthPx,
"heightPx" to heightPx,
"devicePixelRatio" to devicePixelRatio,
"drawEntryImage" to drawEntryImage,
"reuseEntry" to reuseEntry,
), object : MethodChannel.Result {
override fun success(result: Any?) {
cont.resume(result)
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
cont.resumeWithException(Exception("$errorCode: $errorMessage\n$errorDetails"))
}
override fun notImplemented() {
cont.resumeWithException(Exception("not implemented"))
}
})
}
}
}
if (bytes is ByteArray) return bytes
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to draw widget for widgetId=$widgetId widthPx=$widthPx heightPx=$heightPx", e)
}
return null
}
private fun updateWidgetImage(
context: Context,
appWidgetManager: AppWidgetManager,
widgetId: Int,
widgetInfo: Bundle,
bytes: ByteArray?,
) {
bytes ?: return
val devicePixelRatio = context.resources.displayMetrics.density
val widthPx = (widgetInfo.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) * devicePixelRatio).roundToInt()
val heightPx = (widgetInfo.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) * devicePixelRatio).roundToInt()
try {
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 activity = PendingIntent.getActivity(
context,
0,
intent,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
)
val views = RemoteViews(context.packageName, R.layout.app_widget).apply {
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)
}
}
companion object {
private val LOG_TAG = LogUtils.createTag<HomeWidgetProvider>()
private const val WIDGET_DART_ENTRYPOINT = "widgetMain"
private const val WIDGET_DRAW_CHANNEL = "deckers.thibault/aves/widget_draw"
private var flutterEngine: FlutterEngine? = null
private var imageByteFetchJob: Job? = null
private suspend fun initFlutterEngine(context: Context) {
if (flutterEngine != null) return
FlutterUtils.runOnUiThread {
flutterEngine = FlutterEngine(context.applicationContext)
}
initChannels(context)
flutterEngine!!.apply {
if (!dartExecutor.isExecutingDart) {
val appBundlePathOverride = FlutterInjector.instance().flutterLoader().findAppBundlePath()
val entrypoint = DartExecutor.DartEntrypoint(appBundlePathOverride, WIDGET_DART_ENTRYPOINT)
FlutterUtils.runOnUiThread {
dartExecutor.executeDartEntrypoint(entrypoint)
}
}
}
}
private fun initChannels(context: Context) {
val messenger = flutterEngine!!.dartExecutor
// dart -> platform -> dart
// - need Context
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(context))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(context))
MethodChannel(messenger, MediaFetchHandler.CHANNEL).setMethodCallHandler(MediaFetchHandler(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) }
}
}
}

View file

@ -2,10 +2,14 @@ package deckers.thibault.aves
import android.annotation.SuppressLint
import android.app.SearchManager
import android.appwidget.AppWidgetManager
import android.content.ClipData
import android.content.Intent
import android.net.Uri
import android.os.*
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.content.pm.ShortcutInfoCompat
@ -13,6 +17,8 @@ import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.calls.*
import deckers.thibault.aves.channel.calls.window.ActivityWindowHandler
import deckers.thibault.aves.channel.calls.window.WindowHandler
import deckers.thibault.aves.channel.streams.*
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.getParcelableExtraCompat
@ -23,12 +29,12 @@ import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
class MainActivity : FlutterActivity() {
open class MainActivity : FlutterActivity() {
private lateinit var mediaStoreChangeStreamHandler: MediaStoreChangeStreamHandler
private lateinit var settingsChangeStreamHandler: SettingsChangeStreamHandler
private lateinit var intentStreamHandler: IntentStreamHandler
private lateinit var analysisStreamHandler: AnalysisStreamHandler
private lateinit var intentDataMap: MutableMap<String, Any?>
internal lateinit var intentDataMap: MutableMap<String, Any?>
private lateinit var analysisHandler: AnalysisHandler
override fun onCreate(savedInstanceState: Bundle?) {
@ -51,7 +57,7 @@ class MainActivity : FlutterActivity() {
// )
super.onCreate(savedInstanceState)
val messenger = flutterEngine!!.dartExecutor.binaryMessenger
val messenger = flutterEngine!!.dartExecutor
// dart -> platform -> dart
// - need Context
@ -63,14 +69,17 @@ class MainActivity : FlutterActivity() {
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this))
MethodChannel(messenger, HomeWidgetHandler.CHANNEL).setMethodCallHandler(HomeWidgetHandler(this))
MethodChannel(messenger, MediaFetchHandler.CHANNEL).setMethodCallHandler(MediaFetchHandler(this))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
// - need Activity
// - need ContextWrapper
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
MethodChannel(messenger, MediaFileHandler.CHANNEL).setMethodCallHandler(MediaFileHandler(this))
MethodChannel(messenger, MediaEditHandler.CHANNEL).setMethodCallHandler(MediaEditHandler(this))
MethodChannel(messenger, MetadataEditHandler.CHANNEL).setMethodCallHandler(MetadataEditHandler(this))
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(WindowHandler(this))
// - need Activity
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(ActivityWindowHandler(this))
// result streaming: dart -> platform ->->-> dart
// - need Context
@ -78,7 +87,7 @@ class MainActivity : FlutterActivity() {
StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) }
// - need Activity
StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageOpStreamHandler(this, args) }
StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL).setStreamHandlerFactory { args -> StorageAccessStreamHandler(this, args) }
StreamsChannel(messenger, ActivityResultStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ActivityResultStreamHandler(this, args) }
// change monitoring: platform -> dart
mediaStoreChangeStreamHandler = MediaStoreChangeStreamHandler(this).apply {
@ -93,15 +102,16 @@ class MainActivity : FlutterActivity() {
intentStreamHandler = IntentStreamHandler().apply {
EventChannel(messenger, IntentStreamHandler.CHANNEL).setStreamHandler(this)
}
// detail fetch: dart -> platform
// intent detail & result: dart -> platform
intentDataMap = extractIntentData(intent)
MethodChannel(messenger, VIEWER_CHANNEL).setMethodCallHandler { call, result ->
MethodChannel(messenger, INTENT_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"getIntentData" -> {
result.success(intentDataMap)
intentDataMap.clear()
}
"pick" -> pick(call)
"submitPickedItems" -> submitPickedItems(call)
"submitPickedCollectionFilters" -> submitPickedCollectionFilters(call)
}
}
@ -156,27 +166,33 @@ class MainActivity : FlutterActivity() {
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
DOCUMENT_TREE_ACCESS_REQUEST -> onDocumentTreeAccessResult(data, resultCode, requestCode)
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)
}
}
@SuppressLint("WrongConstant", "ObsoleteSdkInt")
private fun onDocumentTreeAccessResult(data: Intent?, resultCode: Int, requestCode: Int) {
val treeUri = data?.data
private fun onCollectionFiltersPickResult(resultCode: Int, intent: Intent?) {
val filters = if (resultCode == RESULT_OK) extractFiltersFromIntent(intent) else null
pendingCollectionFilterPickHandler?.let { it(filters) }
}
private fun onDocumentTreeAccessResult(requestCode: Int, resultCode: Int, intent: Intent?) {
val treeUri = intent?.data
if (resultCode != RESULT_OK || treeUri == null) {
onStorageAccessResult(requestCode, null)
return
}
@SuppressLint("WrongConstant", "ObsoleteSdkInt")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
val canPersist = (data.flags and Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) != 0
val canPersist = (intent.flags and Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) != 0
if (canPersist) {
// save access permissions across reboots
val takeFlags = (data.flags
val takeFlags = (intent.flags
and (Intent.FLAG_GRANT_READ_URI_PERMISSION
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION))
try {
@ -197,18 +213,11 @@ class MainActivity : FlutterActivity() {
}
}
private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
when (intent?.action) {
open fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
when (val action = intent?.action) {
Intent.ACTION_MAIN -> {
intent.getStringExtra(SHORTCUT_KEY_PAGE)?.let { page ->
var filters = intent.getStringArrayExtra(SHORTCUT_KEY_FILTERS_ARRAY)?.toList()
if (filters == null) {
// fallback for shortcuts created on API < 26
val filterString = intent.getStringExtra(SHORTCUT_KEY_FILTERS_STRING)
if (filterString != null) {
filters = filterString.split(EXTRA_STRING_ARRAY_SEPARATOR)
}
}
intent.getStringExtra(EXTRA_KEY_PAGE)?.let { page ->
val filters = extractFiltersFromIntent(intent)
return hashMapOf(
INTENT_DATA_KEY_PAGE to page,
INTENT_DATA_KEY_FILTERS to filters,
@ -228,7 +237,7 @@ class MainActivity : FlutterActivity() {
}
Intent.ACTION_GET_CONTENT, Intent.ACTION_PICK -> {
return hashMapOf(
INTENT_DATA_KEY_ACTION to INTENT_ACTION_PICK,
INTENT_DATA_KEY_ACTION to INTENT_ACTION_PICK_ITEMS,
INTENT_DATA_KEY_MIME_TYPE to intent.type,
INTENT_DATA_KEY_ALLOW_MULTIPLE to (intent.extras?.getBoolean(Intent.EXTRA_ALLOW_MULTIPLE) ?: false),
)
@ -244,6 +253,22 @@ class MainActivity : FlutterActivity() {
INTENT_DATA_KEY_QUERY to intent.getStringExtra(SearchManager.QUERY),
)
}
INTENT_ACTION_PICK_COLLECTION_FILTERS -> {
val initialFilters = extractFiltersFromIntent(intent)
return hashMapOf(
INTENT_DATA_KEY_ACTION to action,
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) {
return hashMapOf(
INTENT_DATA_KEY_ACTION to action,
INTENT_DATA_KEY_WIDGET_ID to widgetId,
)
}
}
Intent.ACTION_RUN -> {
// flutter run
}
@ -254,7 +279,22 @@ class MainActivity : FlutterActivity() {
return HashMap()
}
private fun pick(call: MethodCall) {
private fun extractFiltersFromIntent(intent: Intent?): List<String>? {
intent ?: return null
val filters = intent.getStringArrayExtra(EXTRA_KEY_FILTERS_ARRAY)?.toList()
if (filters != null) return filters
// fallback for shortcuts created on API < 26
val filterString = intent.getStringExtra(EXTRA_KEY_FILTERS_STRING)
if (filterString != null) {
return filterString.split(EXTRA_STRING_ARRAY_SEPARATOR)
}
return null
}
private fun submitPickedItems(call: MethodCall) {
val pickedUris = call.argument<List<String>>("uris")
if (pickedUris != null && pickedUris.isNotEmpty()) {
val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(context, Uri.parse(uriString)) }
@ -278,6 +318,19 @@ class MainActivity : FlutterActivity() {
finish()
}
private fun submitPickedCollectionFilters(call: MethodCall) {
val filters = call.argument<List<String>>("filters")
if (filters != null) {
val intent = Intent()
.putExtra(EXTRA_KEY_FILTERS_ARRAY, filters.toTypedArray())
.putExtra(EXTRA_KEY_FILTERS_STRING, filters.joinToString(EXTRA_STRING_ARRAY_SEPARATOR))
setResult(RESULT_OK, intent)
} else {
setResult(RESULT_CANCELED)
}
finish()
}
@RequiresApi(Build.VERSION_CODES.N_MR1)
private fun setupShortcuts() {
// do not use 'route' as extra key, as the Flutter framework acts on it
@ -291,7 +344,7 @@ class MainActivity : FlutterActivity() {
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_search else R.drawable.ic_shortcut_search))
.setIntent(
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
.putExtra(SHORTCUT_KEY_PAGE, "/search")
.putExtra(EXTRA_KEY_PAGE, "/search")
)
.build()
@ -300,7 +353,7 @@ class MainActivity : FlutterActivity() {
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_movie else R.drawable.ic_shortcut_movie))
.setIntent(
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
.putExtra(SHORTCUT_KEY_PAGE, "/collection")
.putExtra(EXTRA_KEY_PAGE, "/collection")
.putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}"))
)
.build()
@ -314,7 +367,7 @@ class MainActivity : FlutterActivity() {
companion object {
private val LOG_TAG = LogUtils.createTag<MainActivity>()
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
const val INTENT_CHANNEL = "deckers.thibault/aves/intent"
const val EXTRA_STRING_ARRAY_SEPARATOR = "###"
const val DOCUMENT_TREE_ACCESS_REQUEST = 1
const val OPEN_FROM_ANALYSIS_SERVICE = 2
@ -322,29 +375,39 @@ class MainActivity : FlutterActivity() {
const val OPEN_FILE_REQUEST = 4
const val DELETE_SINGLE_PERMISSION_REQUEST = 5
const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 6
const val PICK_COLLECTION_FILTERS_REQUEST = 7
const val INTENT_DATA_KEY_ACTION = "action"
const val INTENT_DATA_KEY_FILTERS = "filters"
const val INTENT_DATA_KEY_MIME_TYPE = "mimeType"
const val INTENT_DATA_KEY_ALLOW_MULTIPLE = "allowMultiple"
const val INTENT_DATA_KEY_PAGE = "page"
const val INTENT_DATA_KEY_URI = "uri"
const val INTENT_DATA_KEY_QUERY = "query"
const val INTENT_ACTION_PICK = "pick"
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"
const val INTENT_ACTION_SCREEN_SAVER_SETTINGS = "screen_saver_settings"
const val INTENT_ACTION_SEARCH = "search"
const val INTENT_ACTION_SET_WALLPAPER = "set_wallpaper"
const val INTENT_ACTION_VIEW = "view"
const val INTENT_ACTION_WIDGET_OPEN = "widget_open"
const val INTENT_ACTION_WIDGET_SETTINGS = "widget_settings"
const val SHORTCUT_KEY_PAGE = "page"
const val SHORTCUT_KEY_FILTERS_ARRAY = "filters"
const val SHORTCUT_KEY_FILTERS_STRING = "filtersString"
const val INTENT_DATA_KEY_ACTION = "action"
const val INTENT_DATA_KEY_ALLOW_MULTIPLE = "allowMultiple"
const val INTENT_DATA_KEY_FILTERS = "filters"
const val INTENT_DATA_KEY_MIME_TYPE = "mimeType"
const val INTENT_DATA_KEY_PAGE = "page"
const val INTENT_DATA_KEY_QUERY = "query"
const val INTENT_DATA_KEY_URI = "uri"
const val INTENT_DATA_KEY_WIDGET_ID = "widgetId"
const val EXTRA_KEY_PAGE = "page"
const val EXTRA_KEY_FILTERS_ARRAY = "filters"
const val EXTRA_KEY_FILTERS_STRING = "filtersString"
const val EXTRA_KEY_WIDGET_ID = "widgetId"
// request code to pending runnable
val pendingStorageAccessResultHandlers = ConcurrentHashMap<Int, PendingStorageAccessResultHandler>()
var pendingScopedStoragePermissionCompleter: CompletableFuture<Boolean>? = null
var pendingCollectionFilterPickHandler: ((filters: List<String>?) -> Unit)? = null
private fun onStorageAccessResult(requestCode: Int, uri: Uri?) {
Log.i(LOG_TAG, "onStorageAccessResult with requestCode=$requestCode, uri=$uri")
val handler = pendingStorageAccessResultHandlers.remove(requestCode) ?: return

View file

@ -0,0 +1,136 @@
package deckers.thibault.aves
import android.service.dreams.DreamService
import android.util.Log
import android.view.View
import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.calls.*
import deckers.thibault.aves.channel.calls.window.ServiceWindowHandler
import deckers.thibault.aves.channel.calls.window.WindowHandler
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler
import deckers.thibault.aves.utils.LogUtils
import io.flutter.FlutterInjector
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.android.FlutterSurfaceView
import io.flutter.embedding.android.FlutterView
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor.DartEntrypoint
import io.flutter.embedding.engine.plugins.util.GeneratedPluginRegister
import io.flutter.plugin.common.MethodChannel
// for FlutterView-level integration, cf https://docs.flutter.dev/development/add-to-app/android/add-flutter-view
class ScreenSaverService : DreamService() {
private var flutterEngine: FlutterEngine? = null
private var flutterView: FlutterView? = null
override fun onAttachedToWindow() {
Log.i(LOG_TAG, "onAttachedToWindow")
super.onAttachedToWindow()
initDream()
createEngine()
setContentView(createView())
}
override fun onDreamingStarted() {
Log.i(LOG_TAG, "onDreamingStarted")
super.onDreamingStarted()
onStart()
}
override fun onDreamingStopped() {
Log.i(LOG_TAG, "onDreamingStopped")
release()
super.onDreamingStopped()
}
override fun onDetachedFromWindow() {
Log.i(LOG_TAG, "onDetachedFromWindow")
destroyView()
super.onDetachedFromWindow()
}
private fun initDream() {
isInteractive = false
isFullscreen = true
}
private fun createEngine() {
flutterEngine = flutterEngine ?: FlutterEngine(this, null, false)
GeneratedPluginRegister.registerGeneratedPlugins(flutterEngine!!)
initChannels()
}
private fun createView(): View {
flutterView = FlutterView(this, FlutterSurfaceView(this)).apply {
id = FlutterActivity.FLUTTER_VIEW_ID
attachToFlutterEngine(flutterEngine!!)
}
return flutterView!!
}
private fun destroyView() {
flutterEngine?.lifecycleChannel?.appIsDetached()
flutterView?.detachFromFlutterEngine()
}
private fun release() {
destroyView()
flutterEngine = null
flutterView = null
}
private fun onStart() {
flutterEngine!!.apply {
if (!dartExecutor.isExecutingDart) {
navigationChannel.setInitialRoute(DEFAULT_INITIAL_ROUTE)
val appBundlePathOverride = FlutterInjector.instance().flutterLoader().findAppBundlePath()
val entrypoint = DartEntrypoint(appBundlePathOverride, DEFAULT_DART_ENTRYPOINT)
dartExecutor.executeDartEntrypoint(entrypoint)
}
lifecycleChannel.appIsResumed()
}
}
private fun initChannels() {
val messenger = flutterEngine!!.dartExecutor
// dart -> platform -> dart
// - need Context
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this))
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
MethodChannel(messenger, MediaFetchHandler.CHANNEL).setMethodCallHandler(MediaFetchHandler(this))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
// - need ContextWrapper
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
// - need Service
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(ServiceWindowHandler(this))
// result streaming: dart -> platform ->->-> dart
// - need Context
StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) }
StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) }
// intent handling
// detail fetch: dart -> platform
MethodChannel(messenger, MainActivity.INTENT_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"getIntentData" -> {
result.success(intentDataMap)
}
}
}
}
companion object {
private val LOG_TAG = LogUtils.createTag<ScreenSaverService>()
private val intentDataMap: Map<String, Any?> = hashMapOf(
MainActivity.INTENT_DATA_KEY_ACTION to MainActivity.INTENT_ACTION_SCREEN_SAVER,
)
// from `FlutterActivityLaunchConfigs`
const val DEFAULT_DART_ENTRYPOINT = "main"
const val DEFAULT_INITIAL_ROUTE = "/"
}
}

View file

@ -0,0 +1,11 @@
package deckers.thibault.aves
import android.content.Intent
class ScreenSaverSettingsActivity : MainActivity() {
override fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
return hashMapOf(
INTENT_DATA_KEY_ACTION to INTENT_ACTION_SCREEN_SAVER_SETTINGS,
)
}
}

View file

@ -23,7 +23,7 @@ import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvider() {
class SearchSuggestionsProvider : ContentProvider() {
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun query(uri: Uri, projection: Array<String>?, selection: String?, selectionArgs: Array<String>?, sortOrder: String?): Cursor? {
@ -67,15 +67,23 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid
}
private suspend fun getSuggestions(context: Context, query: String): List<FieldMap> {
if (backgroundFlutterEngine == null) {
if (flutterEngine == null) {
FlutterUtils.initFlutterEngine(context, SHARED_PREFERENCES_KEY, CALLBACK_HANDLE_KEY) {
backgroundFlutterEngine = it
flutterEngine = it
}
}
val messenger = backgroundFlutterEngine!!.dartExecutor.binaryMessenger
val messenger = flutterEngine!!.dartExecutor
val backgroundChannel = MethodChannel(messenger, BACKGROUND_CHANNEL)
backgroundChannel.setMethodCallHandler(this)
backgroundChannel.setMethodCallHandler { call: MethodCall, result: MethodChannel.Result ->
when (call.method) {
"initialized" -> {
Log.d(LOG_TAG, "background channel is ready")
result.success(null)
}
else -> result.notImplemented()
}
}
try {
return suspendCoroutine { cont ->
@ -96,7 +104,7 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid
}
override fun notImplemented() {
cont.resumeWithException(NotImplementedError("getSuggestions"))
cont.resumeWithException(Exception("not implemented"))
}
})
}
@ -108,16 +116,6 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid
}
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"initialized" -> {
Log.d(LOG_TAG, "background channel is ready")
result.success(null)
}
else -> result.notImplemented()
}
}
override fun onCreate(): Boolean = true
override fun getType(uri: Uri): String? = null
@ -137,6 +135,6 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid
const val SHARED_PREFERENCES_KEY = "platform_search"
const val CALLBACK_HANDLE_KEY = "callback_handle"
private var backgroundFlutterEngine: FlutterEngine? = null
private var flutterEngine: FlutterEngine? = null
}
}

View file

@ -2,10 +2,15 @@ package deckers.thibault.aves
import android.content.Intent
import android.net.Uri
import android.os.*
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.calls.*
import deckers.thibault.aves.channel.calls.window.ActivityWindowHandler
import deckers.thibault.aves.channel.calls.window.WindowHandler
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.getParcelableExtraCompat
@ -23,18 +28,19 @@ class WallpaperActivity : FlutterActivity() {
super.onCreate(savedInstanceState)
val messenger = flutterEngine!!.dartExecutor.binaryMessenger
val messenger = flutterEngine!!.dartExecutor
// dart -> platform -> dart
// - need Context
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this))
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
MethodChannel(messenger, MediaFetchHandler.CHANNEL).setMethodCallHandler(MediaFetchHandler(this))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
// - need Activity
// - need ContextWrapper
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
MethodChannel(messenger, MediaFileHandler.CHANNEL).setMethodCallHandler(MediaFileHandler(this))
MethodChannel(messenger, WallpaperHandler.CHANNEL).setMethodCallHandler(WallpaperHandler(this))
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(WindowHandler(this))
// - need Activity
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(ActivityWindowHandler(this))
// result streaming: dart -> platform ->->-> dart
// - need Context
@ -43,7 +49,7 @@ class WallpaperActivity : FlutterActivity() {
// intent handling
// detail fetch: dart -> platform
intentDataMap = extractIntentData(intent)
MethodChannel(messenger, VIEWER_CHANNEL).setMethodCallHandler { call, result ->
MethodChannel(messenger, MainActivity.INTENT_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"getIntentData" -> {
result.success(intentDataMap)
@ -67,16 +73,6 @@ class WallpaperActivity : FlutterActivity() {
}
}
override fun onStop() {
Log.i(LOG_TAG, "onStop")
super.onStop()
}
override fun onDestroy() {
Log.i(LOG_TAG, "onDestroy")
super.onDestroy()
}
private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
when (intent?.action) {
Intent.ACTION_ATTACH_DATA, Intent.ACTION_SET_WALLPAPER -> {
@ -102,6 +98,5 @@ class WallpaperActivity : FlutterActivity() {
companion object {
private val LOG_TAG = LogUtils.createTag<WallpaperActivity>()
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
}
}

View file

@ -1,8 +1,8 @@
package deckers.thibault.aves.channel.calls
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.os.Build
import android.provider.Settings
import android.util.Log
@ -13,7 +13,7 @@ import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
class AccessibilityHandler(private val activity: Activity) : MethodCallHandler {
class AccessibilityHandler(private val contextWrapper: ContextWrapper) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"areAnimationsRemoved" -> safe(call, result, ::areAnimationsRemoved)
@ -28,7 +28,7 @@ class AccessibilityHandler(private val activity: Activity) : MethodCallHandler {
@SuppressLint("ObsoleteSdkInt")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
try {
removed = Settings.Global.getFloat(activity.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE) == 0f
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)
}
@ -49,7 +49,7 @@ class AccessibilityHandler(private val activity: Activity) : MethodCallHandler {
val originalTimeoutMillis = call.argument<Int>("originalTimeoutMillis")
val content = call.argument<List<String>>("content")
if (originalTimeoutMillis == null || content == null) {
result.error("getRecommendedTimeoutMillis-args", "failed because of missing arguments", null)
result.error("getRecommendedTimeoutMillis-args", "missing arguments", null)
return
}
@ -66,7 +66,7 @@ class AccessibilityHandler(private val activity: Activity) : MethodCallHandler {
}
}
val am = activity.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager
val am = contextWrapper.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager
if (am == null) {
result.error("getRecommendedTimeoutMillis-service", "failed to get accessibility manager", null)
return

View file

@ -16,7 +16,10 @@ import deckers.thibault.aves.utils.ContextUtils.isMyServiceRunning
import deckers.thibault.aves.utils.LogUtils
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class AnalysisHandler(private val activity: Activity, private val onAnalysisCompleted: () -> Unit) : MethodChannel.MethodCallHandler, AnalysisServiceListener {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@ -33,7 +36,7 @@ class AnalysisHandler(private val activity: Activity, private val onAnalysisComp
private fun registerCallback(call: MethodCall, result: MethodChannel.Result) {
val callbackHandle = call.argument<Number>("callbackHandle")?.toLong()
if (callbackHandle == null) {
result.error("registerCallback-args", "failed because of missing arguments", null)
result.error("registerCallback-args", "missing arguments", null)
return
}
@ -47,7 +50,7 @@ class AnalysisHandler(private val activity: Activity, private val onAnalysisComp
private fun startAnalysis(call: MethodCall, result: MethodChannel.Result) {
val force = call.argument<Boolean>("force")
if (force == null) {
result.error("startAnalysis-args", "failed because of missing arguments", null)
result.error("startAnalysis-args", "missing arguments", null)
return
}
@ -56,9 +59,9 @@ class AnalysisHandler(private val activity: Activity, private val onAnalysisComp
if (!activity.isMyServiceRunning(AnalysisService::class.java)) {
val intent = Intent(activity, AnalysisService::class.java)
intent.putExtra(AnalysisService.KEY_COMMAND, AnalysisService.COMMAND_START)
intent.putExtra(AnalysisService.KEY_ENTRY_IDS, entryIds?.toIntArray())
intent.putExtra(AnalysisService.KEY_FORCE, force)
.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) {
activity.startForegroundService(intent)
} else {

View file

@ -20,9 +20,9 @@ import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.MainActivity.Companion.EXTRA_STRING_ARRAY_SEPARATOR
import deckers.thibault.aves.MainActivity.Companion.SHORTCUT_KEY_FILTERS_ARRAY
import deckers.thibault.aves.MainActivity.Companion.SHORTCUT_KEY_FILTERS_STRING
import deckers.thibault.aves.MainActivity.Companion.SHORTCUT_KEY_PAGE
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_FILTERS_ARRAY
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_FILTERS_STRING
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_PAGE
import deckers.thibault.aves.R
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
@ -142,7 +142,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val packageName = call.argument<String>("packageName")
val sizeDip = call.argument<Number>("sizeDip")?.toDouble()
if (packageName == null || sizeDip == null) {
result.error("getAppIcon-args", "failed because of missing arguments", null)
result.error("getAppIcon-args", "missing arguments", null)
return
}
@ -208,7 +208,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val label = call.argument<String>("label")
if (uri == null) {
result.error("copyToClipboard-args", "failed because of missing arguments", null)
result.error("copyToClipboard-args", "missing arguments", null)
return
}
@ -235,7 +235,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val mimeType = call.argument<String>("mimeType")
if (uri == null) {
result.error("edit-args", "failed because of missing arguments", null)
result.error("edit-args", "missing arguments", null)
return
}
@ -252,7 +252,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val mimeType = call.argument<String>("mimeType")
if (uri == null) {
result.error("open-args", "failed because of missing arguments", null)
result.error("open-args", "missing arguments", null)
return
}
@ -267,7 +267,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
private fun openMap(call: MethodCall, result: MethodChannel.Result) {
val geoUri = call.argument<String>("geoUri")?.let { Uri.parse(it) }
if (geoUri == null) {
result.error("openMap-args", "failed because of missing arguments", null)
result.error("openMap-args", "missing arguments", null)
return
}
@ -282,7 +282,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val mimeType = call.argument<String>("mimeType")
if (uri == null) {
result.error("setAs-args", "failed because of missing arguments", null)
result.error("setAs-args", "missing arguments", null)
return
}
@ -298,7 +298,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val title = call.argument<String>("title")
val urisByMimeType = call.argument<Map<String, List<String>>>("urisByMimeType")
if (urisByMimeType == null) {
result.error("setAs-args", "failed because of missing arguments", null)
result.error("setAs-args", "missing arguments", null)
return
}
@ -378,7 +378,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val filters = call.argument<List<String>>("filters")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (label == null || (filters == null && uri == null)) {
result.error("pin-args", "failed because of missing arguments", null)
result.error("pin-args", "missing arguments", null)
return
}
@ -407,11 +407,11 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val intent = when {
uri != null -> Intent(Intent.ACTION_VIEW, uri, context, MainActivity::class.java)
filters != null -> Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
.putExtra(SHORTCUT_KEY_PAGE, "/collection")
.putExtra(SHORTCUT_KEY_FILTERS_ARRAY, filters.toTypedArray())
.putExtra(EXTRA_KEY_PAGE, "/collection")
.putExtra(EXTRA_KEY_FILTERS_ARRAY, filters.toTypedArray())
// on API 25, `String[]` or `ArrayList` extras are null when using the shortcut
// so we use a joined `String` as fallback
.putExtra(SHORTCUT_KEY_FILTERS_STRING, filters.joinToString(EXTRA_STRING_ARRAY_SEPARATOR))
.putExtra(EXTRA_KEY_FILTERS_STRING, filters.joinToString(EXTRA_STRING_ARRAY_SEPARATOR))
else -> {
result.error("pin-intent", "failed to build intent", null)
return

View file

@ -138,7 +138,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
result.error("getBitmapDecoderInfo-args", "failed because of missing arguments", null)
result.error("getBitmapDecoderInfo-args", "missing arguments", null)
return
}
@ -167,7 +167,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (mimeType == null || uri == null) {
result.error("getContentResolverMetadata-args", "failed because of missing arguments", null)
result.error("getContentResolverMetadata-args", "missing arguments", null)
return
}
@ -224,7 +224,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getExifInterfaceMetadata-args", "failed because of missing arguments", null)
result.error("getExifInterfaceMetadata-args", "missing arguments", null)
return
}
@ -250,7 +250,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
private fun getMediaMetadataRetrieverMetadata(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
result.error("getMediaMetadataRetrieverMetadata-args", "failed because of missing arguments", null)
result.error("getMediaMetadataRetrieverMetadata-args", "missing arguments", null)
return
}
@ -276,7 +276,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getMetadataExtractorSummary-args", "failed because of missing arguments", null)
result.error("getMetadataExtractorSummary-args", "missing arguments", null)
return
}
@ -319,7 +319,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (mimeType == null || uri == null) {
result.error("getPixyMetadata-args", "failed because of missing arguments", null)
result.error("getPixyMetadata-args", "missing arguments", null)
return
}
@ -340,7 +340,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
private fun getTiffStructure(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
result.error("getTiffStructure-args", "failed because of missing arguments", null)
result.error("getTiffStructure-args", "missing arguments", null)
return
}

View file

@ -57,7 +57,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getExifThumbnails-args", "failed because of missing arguments", null)
result.error("getExifThumbnails-args", "missing arguments", null)
return
}
@ -88,7 +88,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val displayName = call.argument<String>("displayName")
if (mimeType == null || uri == null || sizeBytes == null) {
result.error("extractMotionPhotoVideo-args", "failed because of missing arguments", null)
result.error("extractMotionPhotoVideo-args", "missing arguments", null)
return
}
@ -108,7 +108,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val displayName = call.argument<String>("displayName")
if (uri == null) {
result.error("extractVideoEmbeddedPicture-args", "failed because of missing arguments", null)
result.error("extractVideoEmbeddedPicture-args", "missing arguments", null)
return
}
@ -143,7 +143,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
val dataPropPath = call.argument<String>("propPath")
val embedMimeType = call.argument<String>("propMimeType")
if (mimeType == null || uri == null || dataPropPath == null || embedMimeType == null) {
result.error("extractXmpDataProp-args", "failed because of missing arguments", null)
result.error("extractXmpDataProp-args", "missing arguments", null)
return
}

View file

@ -35,7 +35,7 @@ class GeocodingHandler(private val context: Context) : MethodCallHandler {
val localeString = call.argument<String>("locale")
val maxResults = call.argument<Int>("maxResults") ?: 1
if (latitude == null || longitude == null) {
result.error("getAddress-args", "failed because of missing arguments", null)
result.error("getAddress-args", "missing arguments", null)
return
}

View file

@ -23,7 +23,7 @@ class GlobalSearchHandler(private val context: Context) : MethodCallHandler {
private fun registerCallback(call: MethodCall, result: MethodChannel.Result) {
val callbackHandle = call.argument<Number>("callbackHandle")?.toLong()
if (callbackHandle == null) {
result.error("registerCallback-args", "failed because of missing arguments", null)
result.error("registerCallback-args", "missing arguments", null)
return
}

View file

@ -0,0 +1,32 @@
package deckers.thibault.aves.channel.calls
import android.appwidget.AppWidgetManager
import android.content.Context
import deckers.thibault.aves.HomeWidgetProvider
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class HomeWidgetHandler(private val context: Context) : MethodChannel.MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"update" -> Coresult.safe(call, result, ::update)
else -> result.notImplemented()
}
}
private fun update(call: MethodCall, result: MethodChannel.Result) {
val widgetId = call.argument<Int>("widgetId")
if (widgetId == null) {
result.error("update-args", "missing arguments", null)
return
}
val appWidgetManager = AppWidgetManager.getInstance(context)
HomeWidgetProvider().onUpdate(context, appWidgetManager, intArrayOf(widgetId))
result.success(null)
}
companion object {
const val CHANNEL = "deckers.thibault/aves/widget_update"
}
}

View file

@ -0,0 +1,77 @@
package deckers.thibault.aves.channel.calls
import android.content.ContextWrapper
import android.net.Uri
import android.util.Log
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.NameConflictStrategy
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class MediaEditHandler(private val contextWrapper: ContextWrapper) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"cancelFileOp" -> safe(call, result, ::cancelFileOp)
"captureFrame" -> ioScope.launch { safeSuspend(call, result, ::captureFrame) }
else -> result.notImplemented()
}
}
private fun cancelFileOp(call: MethodCall, result: MethodChannel.Result) {
val opId = call.argument<String>("opId")
if (opId == null) {
result.error("cancelFileOp-args", "missing arguments", null)
return
}
Log.i(LOG_TAG, "cancelling file op $opId")
cancelledOps.add(opId)
result.success(null)
}
private suspend fun captureFrame(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val desiredName = call.argument<String>("desiredName")
val exifFields = call.argument<FieldMap>("exif") ?: HashMap()
val bytes = call.argument<ByteArray>("bytes")
var destinationDir = call.argument<String>("destinationPath")
val nameConflictStrategy = NameConflictStrategy.get(call.argument<String>("nameConflictStrategy"))
if (uri == null || desiredName == null || bytes == null || destinationDir == null || nameConflictStrategy == null) {
result.error("captureFrame-args", "missing arguments", null)
return
}
val provider = getProvider(uri)
if (provider == null) {
result.error("captureFrame-provider", "failed to find provider for uri=$uri", null)
return
}
destinationDir = ensureTrailingSeparator(destinationDir)
provider.captureFrame(contextWrapper, desiredName, exifFields, bytes, destinationDir, nameConflictStrategy, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("captureFrame-failure", "failed to capture frame for uri=$uri", throwable.message)
})
}
companion object {
private val LOG_TAG = LogUtils.createTag<MediaEditHandler>()
const val CHANNEL = "deckers.thibault/aves/media_edit"
val cancelledOps = HashSet<String>()
}
}

View file

@ -1,9 +1,8 @@
package deckers.thibault.aves.channel.calls
import android.app.Activity
import android.content.Context
import android.graphics.Rect
import android.net.Uri
import android.util.Log
import com.bumptech.glide.Glide
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
@ -12,31 +11,29 @@ import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher
import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher
import deckers.thibault.aves.channel.calls.fetchers.TiffRegionFetcher
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.NameConflictStrategy
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator
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
import kotlin.math.roundToInt
class MediaFileHandler(private val activity: Activity) : MethodCallHandler {
class MediaFetchHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val density = activity.resources.displayMetrics.density
private val density = context.resources.displayMetrics.density
private val regionFetcher = RegionFetcher(activity)
private val regionFetcher = RegionFetcher(context)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getEntry" -> ioScope.launch { safe(call, result, ::getEntry) }
"getThumbnail" -> ioScope.launch { safeSuspend(call, result, ::getThumbnail) }
"getRegion" -> ioScope.launch { safeSuspend(call, result, ::getRegion) }
"cancelFileOp" -> safe(call, result, ::cancelFileOp)
"captureFrame" -> ioScope.launch { safeSuspend(call, result, ::captureFrame) }
"clearSizedThumbnailDiskCache" -> ioScope.launch { safe(call, result, ::clearSizedThumbnailDiskCache) }
else -> result.notImplemented()
}
@ -46,7 +43,7 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler {
val mimeType = call.argument<String>("mimeType") // MIME type is optional
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
result.error("getEntry-args", "failed because of missing arguments", null)
result.error("getEntry-args", "missing arguments", null)
return
}
@ -56,7 +53,7 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler {
return
}
provider.fetchSingle(activity, uri, mimeType, object : ImageOpCallback {
provider.fetchSingle(context, uri, mimeType, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("getEntry-failure", "failed to get entry for uri=$uri", throwable.message)
})
@ -74,13 +71,13 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler {
val defaultSizeDip = call.argument<Number>("defaultSizeDip")?.toDouble()
if (uri == null || mimeType == null || dateModifiedSecs == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null) {
result.error("getThumbnail-args", "failed because of missing arguments", null)
result.error("getThumbnail-args", "missing arguments", null)
return
}
// convert DIP to physical pixels here, instead of using `devicePixelRatio` in Flutter
ThumbnailFetcher(
activity,
context,
uri,
mimeType,
dateModifiedSecs,
@ -107,20 +104,20 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler {
val imageHeight = call.argument<Int>("imageHeight")
if (uri == null || mimeType == null || sampleSize == null || x == null || y == null || width == null || height == null || imageWidth == null || imageHeight == null) {
result.error("getRegion-args", "failed because of missing arguments", null)
result.error("getRegion-args", "missing arguments", null)
return
}
val regionRect = Rect(x, y, x + width, y + height)
when (mimeType) {
MimeTypes.SVG -> SvgRegionFetcher(activity).fetch(
MimeTypes.SVG -> SvgRegionFetcher(context).fetch(
uri = uri,
regionRect = regionRect,
imageWidth = imageWidth,
imageHeight = imageHeight,
result = result,
)
MimeTypes.TIFF -> TiffRegionFetcher(activity).fetch(
MimeTypes.TIFF -> TiffRegionFetcher(context).fetch(
uri = uri,
page = pageId ?: 0,
sampleSize = sampleSize,
@ -140,53 +137,12 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler {
}
}
private fun cancelFileOp(call: MethodCall, result: MethodChannel.Result) {
val opId = call.argument<String>("opId")
if (opId == null) {
result.error("cancelFileOp-args", "failed because of missing arguments", null)
return
}
Log.i(LOG_TAG, "cancelling file op $opId")
cancelledOps.add(opId)
result.success(null)
}
private suspend fun captureFrame(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val desiredName = call.argument<String>("desiredName")
val exifFields = call.argument<FieldMap>("exif") ?: HashMap()
val bytes = call.argument<ByteArray>("bytes")
var destinationDir = call.argument<String>("destinationPath")
val nameConflictStrategy = NameConflictStrategy.get(call.argument<String>("nameConflictStrategy"))
if (uri == null || desiredName == null || bytes == null || destinationDir == null || nameConflictStrategy == null) {
result.error("captureFrame-args", "failed because of missing arguments", null)
return
}
val provider = getProvider(uri)
if (provider == null) {
result.error("captureFrame-provider", "failed to find provider for uri=$uri", null)
return
}
destinationDir = ensureTrailingSeparator(destinationDir)
provider.captureFrame(activity, desiredName, exifFields, bytes, destinationDir, nameConflictStrategy, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("captureFrame-failure", "failed to capture frame for uri=$uri", throwable.message)
})
}
private fun clearSizedThumbnailDiskCache(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
Glide.get(activity).clearDiskCache()
Glide.get(context).clearDiskCache()
result.success(null)
}
companion object {
private val LOG_TAG = LogUtils.createTag<MediaFileHandler>()
const val CHANNEL = "deckers.thibault/aves/media_file"
val cancelledOps = HashSet<String>()
const val CHANNEL = "deckers.thibault/aves/media_fetch"
}
}

View file

@ -28,7 +28,7 @@ class MediaStoreHandler(private val context: Context) : MethodCallHandler {
private fun checkObsoleteContentIds(call: MethodCall, result: MethodChannel.Result) {
val knownContentIds = call.argument<List<Int?>>("knownContentIds")
if (knownContentIds == null) {
result.error("checkObsoleteContentIds-args", "failed because of missing arguments", null)
result.error("checkObsoleteContentIds-args", "missing arguments", null)
return
}
result.success(MediaStoreImageProvider().checkObsoleteContentIds(context, knownContentIds))
@ -37,7 +37,7 @@ class MediaStoreHandler(private val context: Context) : MethodCallHandler {
private fun checkObsoletePaths(call: MethodCall, result: MethodChannel.Result) {
val knownPathById = call.argument<Map<Int?, String?>>("knownPathById")
if (knownPathById == null) {
result.error("checkObsoletePaths-args", "failed because of missing arguments", null)
result.error("checkObsoletePaths-args", "missing arguments", null)
return
}
result.success(MediaStoreImageProvider().checkObsoletePaths(context, knownPathById))

View file

@ -1,6 +1,6 @@
package deckers.thibault.aves.channel.calls
import android.app.Activity
import android.content.ContextWrapper
import android.net.Uri
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.model.ExifOrientationOp
@ -15,7 +15,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
@ -33,7 +33,7 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
private fun rotate(call: MethodCall, result: MethodChannel.Result) {
val clockwise = call.argument<Boolean>("clockwise")
if (clockwise == null) {
result.error("rotate-args", "failed because of missing arguments", null)
result.error("rotate-args", "missing arguments", null)
return
}
@ -48,7 +48,7 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
private fun editOrientation(call: MethodCall, result: MethodChannel.Result, op: ExifOrientationOp) {
val entryMap = call.argument<FieldMap>("entry")
if (entryMap == null) {
result.error("editOrientation-args", "failed because of missing arguments", null)
result.error("editOrientation-args", "missing arguments", null)
return
}
@ -66,7 +66,7 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
return
}
provider.editOrientation(activity, path, uri, mimeType, op, object : ImageOpCallback {
provider.editOrientation(contextWrapper, path, uri, mimeType, op, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("editOrientation-failure", "failed to change orientation for mimeType=$mimeType uri=$uri", throwable.message)
})
@ -78,7 +78,7 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
val fields = call.argument<List<String>>("fields")
val entryMap = call.argument<FieldMap>("entry")
if (entryMap == null || fields == null) {
result.error("editDate-args", "failed because of missing arguments", null)
result.error("editDate-args", "missing arguments", null)
return
}
@ -96,7 +96,7 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
return
}
provider.editDate(activity, path, uri, mimeType, dateMillis, shiftMinutes, fields, object : ImageOpCallback {
provider.editDate(contextWrapper, path, uri, mimeType, dateMillis, shiftMinutes, fields, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("editDate-failure", "failed to edit date for mimeType=$mimeType uri=$uri", throwable.message)
})
@ -107,7 +107,7 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
val entryMap = call.argument<FieldMap>("entry")
val autoCorrectTrailerOffset = call.argument<Boolean>("autoCorrectTrailerOffset")
if (entryMap == null || metadata == null || autoCorrectTrailerOffset == null) {
result.error("editMetadata-args", "failed because of missing arguments", null)
result.error("editMetadata-args", "missing arguments", null)
return
}
@ -125,7 +125,7 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
return
}
provider.editMetadata(activity, path, uri, mimeType, metadata, autoCorrectTrailerOffset, callback = object : ImageOpCallback {
provider.editMetadata(contextWrapper, path, uri, mimeType, metadata, autoCorrectTrailerOffset, callback = object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("editMetadata-failure", "failed to edit metadata for mimeType=$mimeType uri=$uri", throwable.message)
})
@ -134,7 +134,7 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
private fun removeTrailerVideo(call: MethodCall, result: MethodChannel.Result) {
val entryMap = call.argument<FieldMap>("entry")
if (entryMap == null) {
result.error("removeTrailerVideo-args", "failed because of missing arguments", null)
result.error("removeTrailerVideo-args", "missing arguments", null)
return
}
@ -152,7 +152,7 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
return
}
provider.removeTrailerVideo(activity, path, uri, mimeType, object : ImageOpCallback {
provider.removeTrailerVideo(contextWrapper, path, uri, mimeType, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("removeTrailerVideo-failure", "failed to remove trailer video for mimeType=$mimeType uri=$uri", throwable.message)
})
@ -162,7 +162,7 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
val types = call.argument<List<String>>("types")
val entryMap = call.argument<FieldMap>("entry")
if (entryMap == null || types == null) {
result.error("removeTypes-args", "failed because of missing arguments", null)
result.error("removeTypes-args", "missing arguments", null)
return
}
@ -180,7 +180,7 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
return
}
provider.removeMetadataTypes(activity, path, uri, mimeType, types.toSet(), object : ImageOpCallback {
provider.removeMetadataTypes(contextWrapper, path, uri, mimeType, types.toSet(), object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("removeTypes-failure", "failed to remove metadata for mimeType=$mimeType uri=$uri", throwable.message)
})

View file

@ -115,7 +115,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getAllMetadata-args", "failed because of missing arguments", null)
result.error("getAllMetadata-args", "missing arguments", null)
return
}
@ -424,7 +424,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
val path = call.argument<String>("path")
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getCatalogMetadata-args", "failed because of missing arguments", null)
result.error("getCatalogMetadata-args", "missing arguments", null)
return
}
@ -691,7 +691,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getOverlayMetadata-args", "failed because of missing arguments", null)
result.error("getOverlayMetadata-args", "missing arguments", null)
return
}
@ -761,7 +761,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getGeoTiffInfo-args", "failed because of missing arguments", null)
result.error("getGeoTiffInfo-args", "missing arguments", null)
return
}
@ -802,7 +802,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null || sizeBytes == null) {
result.error("getMultiPageInfo-args", "failed because of missing arguments", null)
result.error("getMultiPageInfo-args", "missing arguments", null)
return
}
@ -824,7 +824,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getPanoramaInfo-args", "failed because of missing arguments", null)
result.error("getPanoramaInfo-args", "missing arguments", null)
return
}
@ -863,7 +863,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (mimeType == null || uri == null) {
result.error("getIptc-args", "failed because of missing arguments", null)
result.error("getIptc-args", "missing arguments", null)
return
}
@ -888,7 +888,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getXmp-args", "failed because of missing arguments", null)
result.error("getXmp-args", "missing arguments", null)
return
}
@ -918,7 +918,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
val prop = call.argument<String>("prop")
if (prop == null) {
result.error("hasContentResolverProp-args", "failed because of missing arguments", null)
result.error("hasContentResolverProp-args", "missing arguments", null)
return
}
@ -938,7 +938,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val prop = call.argument<String>("prop")
if (mimeType == null || uri == null || prop == null) {
result.error("getContentResolverProp-args", "failed because of missing arguments", null)
result.error("getContentResolverProp-args", "missing arguments", null)
return
}
@ -992,7 +992,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val field = call.argument<String>("field")
if (mimeType == null || uri == null || field == null) {
result.error("getDate-args", "failed because of missing arguments", null)
result.error("getDate-args", "missing arguments", null)
return
}

View file

@ -55,7 +55,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
)
)
}
} catch (e: IllegalArgumentException) {
} catch (e: Exception) {
// ignore
}
}
@ -80,7 +80,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
"state" to EnvironmentCompat.getStorageState(volumeFile)
)
)
} catch (e: IllegalArgumentException) {
} catch (e: Exception) {
// ignore
}
}
@ -91,7 +91,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
private fun getFreeSpace(call: MethodCall, result: MethodChannel.Result) {
val path = call.argument<String>("path")
if (path == null) {
result.error("getFreeSpace-args", "failed because of missing arguments", null)
result.error("getFreeSpace-args", "missing arguments", null)
return
}
@ -112,7 +112,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
private fun getInaccessibleDirectories(call: MethodCall, result: MethodChannel.Result) {
val dirPaths = call.argument<List<String>>("dirPaths")
if (dirPaths == null) {
result.error("getInaccessibleDirectories-args", "failed because of missing arguments", null)
result.error("getInaccessibleDirectories-args", "missing arguments", null)
return
}
@ -126,7 +126,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
private fun revokeDirectoryAccess(call: MethodCall, result: MethodChannel.Result) {
val path = call.argument<String>("path")
if (path == null) {
result.error("revokeDirectoryAccess-args", "failed because of missing arguments", null)
result.error("revokeDirectoryAccess-args", "missing arguments", null)
return
}
@ -142,7 +142,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
private fun deleteEmptyDirectories(call: MethodCall, result: MethodChannel.Result) {
val dirPaths = call.argument<List<String>>("dirPaths")
if (dirPaths == null) {
result.error("deleteEmptyDirectories-args", "failed because of missing arguments", null)
result.error("deleteEmptyDirectories-args", "missing arguments", null)
return
}
@ -167,7 +167,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
private fun canInsertMedia(call: MethodCall, result: MethodChannel.Result) {
val directories = call.argument<List<FieldMap>>("directories")
if (directories == null) {
result.error("canInsertMedia-args", "failed because of missing arguments", null)
result.error("canInsertMedia-args", "missing arguments", null)
return
}

View file

@ -1,9 +1,9 @@
package deckers.thibault.aves.channel.calls
import android.app.Activity
import android.app.WallpaperManager
import android.app.WallpaperManager.FLAG_LOCK
import android.app.WallpaperManager.FLAG_SYSTEM
import android.content.ContextWrapper
import android.os.Build
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import io.flutter.plugin.common.MethodCall
@ -14,7 +14,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class WallpaperHandler(private val activity: Activity) : MethodCallHandler {
class WallpaperHandler(private val contextWrapper: ContextWrapper) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
@ -29,11 +29,11 @@ class WallpaperHandler(private val activity: Activity) : MethodCallHandler {
val home = call.argument<Boolean>("home")
val lock = call.argument<Boolean>("lock")
if (bytes == null || home == null || lock == null) {
result.error("setWallpaper-args", "failed because of missing arguments", null)
result.error("setWallpaper-args", "missing arguments", null)
return
}
val manager = WallpaperManager.getInstance(activity)
val manager = WallpaperManager.getInstance(contextWrapper)
val supported = Build.VERSION.SDK_INT < Build.VERSION_CODES.M || manager.isWallpaperSupported
val allowed = Build.VERSION.SDK_INT < Build.VERSION_CODES.N || manager.isSetWallpaperAllowed
if (!supported || !allowed) {

View file

@ -1,89 +0,0 @@
package deckers.thibault.aves.channel.calls
import android.app.Activity
import android.os.Build
import android.provider.Settings
import android.util.Log
import android.view.WindowManager
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.utils.LogUtils
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
class WindowHandler(private val activity: Activity) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"keepScreenOn" -> safe(call, result, ::keepScreenOn)
"isRotationLocked" -> safe(call, result, ::isRotationLocked)
"requestOrientation" -> safe(call, result, ::requestOrientation)
"canSetCutoutMode" -> safe(call, result, ::canSetCutoutMode)
"setCutoutMode" -> safe(call, result, ::setCutoutMode)
else -> result.notImplemented()
}
}
private fun keepScreenOn(call: MethodCall, result: MethodChannel.Result) {
val on = call.argument<Boolean>("on")
if (on == null) {
result.error("keepOn-args", "failed because of missing arguments", null)
return
}
val window = activity.window
val flag = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
if (on) {
window.addFlags(flag)
} else {
window.clearFlags(flag)
}
result.success(null)
}
private fun isRotationLocked(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
var locked = false
try {
locked = Settings.System.getInt(activity.contentResolver, Settings.System.ACCELEROMETER_ROTATION) == 0
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
}
result.success(locked)
}
private fun requestOrientation(call: MethodCall, result: MethodChannel.Result) {
val orientation = call.argument<Int>("orientation")
if (orientation == null) {
result.error("requestOrientation-args", "failed because of missing arguments", null)
return
}
activity.requestedOrientation = orientation
result.success(true)
}
private fun canSetCutoutMode(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
}
private fun setCutoutMode(call: MethodCall, result: MethodChannel.Result) {
val use = call.argument<Boolean>("use")
if (use == null) {
result.error("setCutoutMode-args", "failed because of missing arguments", null)
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val mode = if (use) {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
} else {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER
}
activity.window.attributes.layoutInDisplayCutoutMode = mode
}
result.success(true)
}
companion object {
private val LOG_TAG = LogUtils.createTag<WindowHandler>()
const val CHANNEL = "deckers.thibault/aves/window"
}
}

View file

@ -0,0 +1,67 @@
package deckers.thibault.aves.channel.calls.window
import android.app.Activity
import android.os.Build
import android.view.WindowManager
import deckers.thibault.aves.utils.LogUtils
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) {
result.success(true)
}
override fun keepScreenOn(call: MethodCall, result: MethodChannel.Result) {
val on = call.argument<Boolean>("on")
if (on == null) {
result.error("keepOn-args", "missing arguments", null)
return
}
val window = activity.window
val flag = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
if (on) {
window.addFlags(flag)
} else {
window.clearFlags(flag)
}
result.success(null)
}
override fun requestOrientation(call: MethodCall, result: MethodChannel.Result) {
val orientation = call.argument<Int>("orientation")
if (orientation == null) {
result.error("requestOrientation-args", "missing arguments", null)
return
}
activity.requestedOrientation = orientation
result.success(true)
}
override fun canSetCutoutMode(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
}
override fun setCutoutMode(call: MethodCall, result: MethodChannel.Result) {
val use = call.argument<Boolean>("use")
if (use == null) {
result.error("setCutoutMode-args", "missing arguments", null)
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val mode = if (use) {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
} else {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER
}
activity.window.attributes.layoutInDisplayCutoutMode = mode
}
result.success(true)
}
companion object {
private val LOG_TAG = LogUtils.createTag<ActivityWindowHandler>()
}
}

View file

@ -0,0 +1,27 @@
package deckers.thibault.aves.channel.calls.window
import android.app.Service
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) {
result.success(false)
}
override fun keepScreenOn(call: MethodCall, result: MethodChannel.Result) {
result.success(null)
}
override fun requestOrientation(call: MethodCall, result: MethodChannel.Result) {
result.success(false)
}
override fun canSetCutoutMode(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(false)
}
override fun setCutoutMode(call: MethodCall, result: MethodChannel.Result) {
result.success(false)
}
}

View file

@ -0,0 +1,48 @@
package deckers.thibault.aves.channel.calls.window
import android.content.ContextWrapper
import android.provider.Settings
import android.util.Log
import deckers.thibault.aves.channel.calls.Coresult
import deckers.thibault.aves.utils.LogUtils
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
abstract class WindowHandler(private val contextWrapper: ContextWrapper) : MethodChannel.MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"isActivity" -> Coresult.safe(call, result, ::isActivity)
"keepScreenOn" -> Coresult.safe(call, result, ::keepScreenOn)
"isRotationLocked" -> Coresult.safe(call, result, ::isRotationLocked)
"requestOrientation" -> Coresult.safe(call, result, ::requestOrientation)
"canSetCutoutMode" -> Coresult.safe(call, result, ::canSetCutoutMode)
"setCutoutMode" -> Coresult.safe(call, result, ::setCutoutMode)
else -> result.notImplemented()
}
}
abstract fun isActivity(call: MethodCall, result: MethodChannel.Result)
abstract fun keepScreenOn(call: MethodCall, result: MethodChannel.Result)
private fun isRotationLocked(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
var locked = false
try {
locked = Settings.System.getInt(contextWrapper.contentResolver, Settings.System.ACCELEROMETER_ROTATION) == 0
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
}
result.success(locked)
}
abstract fun requestOrientation(call: MethodCall, result: MethodChannel.Result)
abstract fun canSetCutoutMode(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result)
abstract fun setCutoutMode(call: MethodCall, result: MethodChannel.Result)
companion object {
private val LOG_TAG = LogUtils.createTag<WindowHandler>()
const val CHANNEL = "deckers.thibault/aves/window"
}
}

View file

@ -22,9 +22,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
// starting activity to give access with the native dialog
// starting activity to get a result (e.g. storage access via native dialog)
// breaks the regular `MethodChannel` so we use a stream channel instead
class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?) : EventChannel.StreamHandler {
class ActivityResultStreamHandler(private val activity: Activity, arguments: Any?) : EventChannel.StreamHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private lateinit var eventSink: EventSink
private lateinit var handler: Handler
@ -48,6 +48,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
"requestMediaFileAccess" -> ioScope.launch { requestMediaFileAccess() }
"createFile" -> ioScope.launch { createFile() }
"openFile" -> ioScope.launch { openFile() }
"pickCollectionFilters" -> pickCollectionFilters()
else -> endOfStream()
}
}
@ -55,7 +56,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
private suspend fun requestDirectoryAccess() {
val path = args["path"] as String?
if (path == null) {
error("requestDirectoryAccess-args", "failed because of missing arguments", null)
error("requestDirectoryAccess-args", "missing arguments", null)
return
}
@ -77,7 +78,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
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) {
error("requestMediaFileAccess-args", "failed because of missing arguments", null)
error("requestMediaFileAccess-args", "missing arguments", null)
return
}
@ -111,7 +112,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
val mimeType = args["mimeType"] as String?
val bytes = args["bytes"] as ByteArray?
if (name == null || mimeType == null || bytes == null) {
error("createFile-args", "failed because of missing arguments", null)
error("createFile-args", "missing arguments", null)
return
}
@ -186,6 +187,18 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
}
}
private fun pickCollectionFilters() {
val initialFilters = (args["initialFilters"] as List<*>?)?.mapNotNull { if (it is String) it else null } ?: listOf()
val intent = Intent(MainActivity.INTENT_ACTION_PICK_COLLECTION_FILTERS, null, activity, MainActivity::class.java)
.putExtra(MainActivity.EXTRA_KEY_FILTERS_ARRAY, initialFilters.toTypedArray())
.putExtra(MainActivity.EXTRA_KEY_FILTERS_STRING, initialFilters.joinToString(MainActivity.EXTRA_STRING_ARRAY_SEPARATOR))
MainActivity.pendingCollectionFilterPickHandler = { filters ->
success(filters)
endOfStream()
}
activity.startActivityForResult(intent, MainActivity.PICK_COLLECTION_FILTERS_REQUEST)
}
override fun onCancel(arguments: Any?) {}
private fun success(result: Any?) {
@ -221,8 +234,8 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
}
companion object {
private val LOG_TAG = LogUtils.createTag<StorageAccessStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/storage_access_stream"
private val LOG_TAG = LogUtils.createTag<ActivityResultStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/activity_result_stream"
private const val BUFFER_SIZE = 2 shl 17 // 256kB
}
}

View file

@ -87,7 +87,7 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
val pageId = arguments["pageId"] as Int?
if (mimeType == null || uri == null) {
error("streamImage-args", "failed because of missing arguments", null)
error("streamImage-args", "missing arguments", null)
endOfStream()
return
}

View file

@ -5,7 +5,7 @@ import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import deckers.thibault.aves.channel.calls.MediaFileHandler.Companion.cancelledOps
import deckers.thibault.aves.channel.calls.MediaEditHandler.Companion.cancelledOps
import deckers.thibault.aves.model.AvesEntry
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.NameConflictStrategy
@ -143,7 +143,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
val height = arguments["height"] as Int?
val nameConflictStrategy = NameConflictStrategy.get(arguments["nameConflictStrategy"] as String?)
if (destinationDir == null || mimeType == null || width == null || height == null || nameConflictStrategy == null) {
error("export-args", "failed because of missing arguments", null)
error("export-args", "missing arguments", null)
return
}
@ -174,7 +174,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
val nameConflictStrategy = NameConflictStrategy.get(arguments["nameConflictStrategy"] as String?)
val rawEntryMap = arguments["entriesByDestination"] as Map<*, *>?
if (copy == null || nameConflictStrategy == null || rawEntryMap == null || rawEntryMap.isEmpty()) {
error("move-args", "failed because of missing arguments", null)
error("move-args", "missing arguments", null)
return
}
@ -207,7 +207,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
val rawEntryMap = arguments["entriesToNewName"] as Map<*, *>?
if (rawEntryMap == null || rawEntryMap.isEmpty()) {
error("rename-args", "failed because of missing arguments", null)
error("rename-args", "missing arguments", null)
return
}

View file

@ -20,6 +20,6 @@ class IntentStreamHandler : EventChannel.StreamHandler {
}
companion object {
const val CHANNEL = "deckers.thibault/aves/intent"
const val CHANNEL = "deckers.thibault/aves/new_intent_stream"
}
}

View file

@ -1,7 +1,7 @@
package deckers.thibault.aves.model.provider
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.net.Uri
import android.util.Log
import deckers.thibault.aves.model.SourceEntry
@ -37,7 +37,7 @@ internal class FileImageProvider : ImageProvider() {
}
}
override suspend fun delete(activity: Activity, uri: Uri, path: String?, mimeType: String) {
override suspend fun delete(contextWrapper: ContextWrapper, uri: Uri, path: String?, mimeType: String) {
val file = File(File(uri.path!!).path)
if (!file.exists()) return

View file

@ -2,6 +2,7 @@ package deckers.thibault.aves.model.provider
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
@ -45,7 +46,7 @@ abstract class ImageProvider {
callback.onFailure(UnsupportedOperationException("`fetchSingle` is not supported by this image provider"))
}
open suspend fun delete(activity: Activity, uri: Uri, path: String?, mimeType: String) {
open suspend fun delete(contextWrapper: ContextWrapper, uri: Uri, path: String?, mimeType: String) {
throw UnsupportedOperationException("`delete` is not supported by this image provider")
}
@ -151,7 +152,7 @@ abstract class ImageProvider {
desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}"
}
val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension(
activity = activity,
contextWrapper = activity,
dir = targetDir,
desiredNameWithoutExtension = desiredNameWithoutExtension,
mimeType = exportMimeType,
@ -242,7 +243,7 @@ abstract class ImageProvider {
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun captureFrame(
activity: Activity,
contextWrapper: ContextWrapper,
desiredNameWithoutExtension: String,
exifFields: FieldMap,
bytes: ByteArray,
@ -250,7 +251,7 @@ abstract class ImageProvider {
nameConflictStrategy: NameConflictStrategy,
callback: ImageOpCallback,
) {
val targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir)
val targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(contextWrapper, targetDir)
if (!File(targetDir).exists()) {
callback.onFailure(Exception("failed to create directory at path=$targetDir"))
return
@ -265,7 +266,7 @@ abstract class ImageProvider {
val captureMimeType = MimeTypes.JPEG
val targetNameWithoutExtension = try {
resolveTargetFileNameWithoutExtension(
activity = activity,
contextWrapper = contextWrapper,
dir = targetDir,
desiredNameWithoutExtension = desiredNameWithoutExtension,
mimeType = captureMimeType,
@ -287,7 +288,7 @@ abstract class ImageProvider {
// through a document URI, not a tree URI
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
val targetTreeFile = targetDirDocFile.createFile(captureMimeType, targetNameWithoutExtension)
val targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri)
val targetDocFile = DocumentFileCompat.fromSingleUri(contextWrapper, targetTreeFile.uri)
try {
if (exifFields.isEmpty()) {
@ -355,7 +356,7 @@ abstract class ImageProvider {
val fileName = targetDocFile.name
val targetFullPath = targetDir + fileName
val newFields = MediaStoreImageProvider().scanNewPath(activity, targetFullPath, captureMimeType)
val newFields = MediaStoreImageProvider().scanNewPath(contextWrapper, targetFullPath, captureMimeType)
callback.onSuccess(newFields)
} catch (e: Exception) {
callback.onFailure(e)
@ -364,7 +365,7 @@ abstract class ImageProvider {
// returns available name to use, or `null` to skip it
suspend fun resolveTargetFileNameWithoutExtension(
activity: Activity,
contextWrapper: ContextWrapper,
dir: String,
desiredNameWithoutExtension: String,
mimeType: String,
@ -386,9 +387,9 @@ abstract class ImageProvider {
if (targetFile.exists()) {
val path = targetFile.path
MediaStoreImageProvider().apply {
val uri = getContentUriForPath(activity, path)
val uri = getContentUriForPath(contextWrapper, path)
uri ?: throw Exception("failed to find content URI for path=$path")
delete(activity, uri, path, mimeType)
delete(contextWrapper, uri, path, mimeType)
}
}
desiredNameWithoutExtension

View file

@ -3,10 +3,7 @@ package deckers.thibault.aves.model.provider
import android.annotation.SuppressLint
import android.app.Activity
import android.app.RecoverableSecurityException
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.content.*
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build
@ -280,7 +277,7 @@ class MediaStoreImageProvider : ImageProvider() {
private fun needSize(mimeType: String) = MimeTypes.SVG != mimeType
// `uri` is a media URI, not a document URI
override suspend fun delete(activity: Activity, uri: Uri, path: String?, mimeType: String) {
override suspend fun delete(contextWrapper: ContextWrapper, uri: Uri, path: String?, mimeType: String) {
path ?: throw Exception("failed to delete file because path is null")
// the following situations are possible:
@ -291,10 +288,10 @@ class MediaStoreImageProvider : ImageProvider() {
val fileExists = file.exists()
if (fileExists) {
if (StorageUtils.canEditByFile(activity, path)) {
if (hasEntry(activity, uri)) {
if (StorageUtils.canEditByFile(contextWrapper, path)) {
if (hasEntry(contextWrapper, uri)) {
Log.d(LOG_TAG, "delete [permission:file, file exists, content exists] content at uri=$uri path=$path")
activity.contentResolver.delete(uri, null, null)
contextWrapper.contentResolver.delete(uri, null, null)
}
// in theory, deleting via content resolver should remove the file on storage
// in practice, the file may still be there afterwards
@ -303,31 +300,31 @@ class MediaStoreImageProvider : ImageProvider() {
if (file.delete()) {
// in theory, scanning an obsolete path should remove the entry from the Media Store
// in practice, the entry may still be there afterwards
scanObsoletePath(activity, uri, path, mimeType)
scanObsoletePath(contextWrapper, uri, path, mimeType)
return
}
} else {
return
}
} else if (!isMediaUriPermissionGranted(activity, uri, mimeType)
&& StorageUtils.requireAccessPermission(activity, path)
} else if (!isMediaUriPermissionGranted(contextWrapper, uri, mimeType)
&& StorageUtils.requireAccessPermission(contextWrapper, path)
) {
// the delete request may yield a `RecoverableSecurityException` when using scoped storage,
// even if we have permissions on the tree document via SAF
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q && hasEntry(activity, uri)) {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q && hasEntry(contextWrapper, uri)) {
Log.d(LOG_TAG, "delete [permission:doc, file exists, content exists] content at uri=$uri path=$path")
activity.contentResolver.delete(uri, null, null)
contextWrapper.contentResolver.delete(uri, null, null)
}
// in theory, deleting via content resolver should remove the file on storage
// in practice, the file may still be there afterwards
if (file.exists()) {
Log.d(LOG_TAG, "delete [permission:doc, file exists after content delete] document at uri=$uri path=$path")
val df = StorageUtils.getDocumentFile(activity, path, uri)
val df = StorageUtils.getDocumentFile(contextWrapper, path, uri)
@Suppress("BlockingMethodInNonBlockingContext")
if (df != null && df.delete()) {
scanObsoletePath(activity, uri, path, mimeType)
scanObsoletePath(contextWrapper, uri, path, mimeType)
return
}
throw Exception("failed to delete document with df=$df")
@ -343,28 +340,28 @@ class MediaStoreImageProvider : ImageProvider() {
try {
Log.d(LOG_TAG, "delete [file exists=$fileExists] content at uri=$uri path=$path")
if (activity.contentResolver.delete(uri, null, null) > 0) return
if (contextWrapper.contentResolver.delete(uri, null, null) > 0) return
if (hasEntry(activity, uri) || file.exists()) {
if (hasEntry(contextWrapper, uri) || file.exists()) {
throw Exception("failed to delete row from content provider")
}
} catch (securityException: SecurityException) {
// even if the app has access permission granted on the containing directory,
// the delete request may yield a `RecoverableSecurityException` on Android 10+
// when the underlying file no longer exists and this is an orphaned entry in the Media Store
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && contextWrapper is Activity) {
Log.w(LOG_TAG, "caught a security exception when attempting to delete uri=$uri", securityException)
val rse = securityException as? RecoverableSecurityException ?: throw securityException
val intentSender = rse.userAction.actionIntent.intentSender
// request user permission for this item
MainActivity.pendingScopedStoragePermissionCompleter = CompletableFuture<Boolean>()
activity.startIntentSenderForResult(intentSender, DELETE_SINGLE_PERMISSION_REQUEST, null, 0, 0, 0, null)
contextWrapper.startIntentSenderForResult(intentSender, DELETE_SINGLE_PERMISSION_REQUEST, null, 0, 0, 0, null)
val granted = MainActivity.pendingScopedStoragePermissionCompleter!!.join()
MainActivity.pendingScopedStoragePermissionCompleter = null
if (granted) {
delete(activity, uri, path, mimeType)
delete(contextWrapper, uri, path, mimeType)
} else {
throw Exception("failed to get delete permission")
}
@ -494,7 +491,7 @@ class MediaStoreImageProvider : ImageProvider() {
val desiredNameWithoutExtension = desiredName.substringBeforeLast(".")
val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension(
activity = activity,
contextWrapper = activity,
dir = targetDir,
desiredNameWithoutExtension = desiredNameWithoutExtension,
mimeType = mimeType,
@ -641,7 +638,7 @@ class MediaStoreImageProvider : ImageProvider() {
val dir = oldFile.parent ?: return skippedFieldMap
val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension(
activity = activity,
contextWrapper = activity,
dir = dir,
desiredNameWithoutExtension = desiredNameWithoutExtension,
mimeType = mimeType,

View file

@ -24,7 +24,6 @@ fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int): Ap
@Suppress("deprecation")
getApplicationInfo(packageName, flags)
}
}
fun PackageManager.queryIntentActivitiesCompat(intent: Intent, flags: Int): List<ResolveInfo> {

View file

@ -290,7 +290,7 @@ object StorageUtils {
if (volume != null && uuid.equals(volume.uuid, ignoreCase = true)) {
return volumePath
}
} catch (e: IllegalArgumentException) {
} catch (e: Exception) {
// ignore
}
}

View file

@ -0,0 +1,5 @@
<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_img"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitCenter" />

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="app_widget_label">Bilderrahmen</string>
<string name="wallpaper">Hintergrundbild</string>
<string name="search_shortcut_short_label">Suche</string>
<string name="videos_shortcut_short_label">Videos</string>

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="app_widget_label">Cadre photo</string>
<string name="wallpaper">Fond décran</string>
<string name="search_shortcut_short_label">Recherche</string>
<string name="videos_shortcut_short_label">Vidéos</string>

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="app_widget_label">Cornice foto</string>
<string name="wallpaper">Sfondo</string>
<string name="search_shortcut_short_label">Ricerca</string>
<string name="videos_shortcut_short_label">Video</string>

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="app_widget_label">フォトフレーム</string>
<string name="wallpaper">壁紙</string>
<string name="search_shortcut_short_label">検索</string>
<string name="videos_shortcut_short_label">動画</string>

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">아베스</string>
<string name="app_widget_label">사진 액자</string>
<string name="wallpaper">배경화면</string>
<string name="search_shortcut_short_label">검색</string>
<string name="videos_shortcut_short_label">동영상</string>

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="app_widget_label">Porta-retratos</string>
<string name="wallpaper">Papel de parede</string>
<string name="search_shortcut_short_label">Procurar</string>
<string name="videos_shortcut_short_label">Vídeos</string>

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="app_widget_label">相框</string>
<string name="wallpaper">壁纸</string>
<string name="search_shortcut_short_label">搜索</string>
<string name="videos_shortcut_short_label">视频</string>

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="app_widget_label">Photo Frame</string>
<string name="wallpaper">Wallpaper</string>
<string name="search_shortcut_short_label">Search</string>
<string name="videos_shortcut_short_label">Videos</string>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:configure="deckers.thibault.aves.HomeWidgetSettingsActivity"
android:initialLayout="@layout/app_widget"
android:minWidth="40dp"
android:minHeight="40dp"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:updatePeriodMillis="3600000"
android:widgetCategory="home_screen"
android:widgetFeatures="reconfigurable" />

View file

@ -0,0 +1,2 @@
<dream xmlns:android="http://schemas.android.com/apk/res/android"
android:settingsActivity="deckers.thibault.aves/deckers.thibault.aves.ScreenSaverSettingsActivity" />

View file

@ -0,0 +1,2 @@
<dream xmlns:android="http://schemas.android.com/apk/res/android"
android:settingsActivity="deckers.thibault.aves.profile/deckers.thibault.aves.ScreenSaverSettingsActivity" />

View file

@ -1,6 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.7.0'
ext.kotlin_version = '1.7.10'
repositories {
google()
mavenCentral()
@ -10,8 +10,8 @@ buildscript {
classpath 'com.android.tools.build:gradle:7.2.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// GMS & Firebase Crashlytics (used by some flavors only)
classpath 'com.google.gms:google-services:4.3.10'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.0'
classpath 'com.google.gms:google-services:4.3.13'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.1'
// HMS (used by some flavors only)
classpath 'com.huawei.agconnect:agcp:1.5.2.300'
}

View file

@ -3,3 +3,4 @@ storePassword=<KEYSTORE_PASSWORD>
keyAlias=<KEY_ALIAS>
keyPassword=<KEY_PASSWORD>
googleApiKey=<GOOGLE_API_KEY>
huaweiApiKey=<HUAWEI_API_KEY>

View file

@ -0,0 +1,5 @@
In v1.6.10:
- add the photo frame widget to your home
- use your photos as screen saver
- search photos taken "on this day"
Full changelog available on GitHub

View file

@ -2,4 +2,4 @@
<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 <b>API 19 to 33</b>, i.e. from KitKat to Android 13) with features such as <b>app shortcuts</b> and <b>global search</b> handling. It also works as a <b>media viewer and picker</b>.
<i>Aves</i> integrates with Android (from <b>API 19 to 33</b>, i.e. from KitKat to Android 13) with features such as <b>widgets</b>, <b>app shortcuts</b>, <b>screen saver</b> and <b>global search</b> handling. It also works as a <b>media viewer and picker</b>.

View file

@ -1,22 +1,34 @@
enum AppMode {
main,
pickCollectionFiltersExternal,
pickSingleMediaExternal,
pickMultipleMediaExternal,
pickMediaInternal,
pickFilterInternal,
screenSaver,
setWallpaper,
slideshow,
view,
}
extension ExtraAppMode on AppMode {
bool get canSearch => this == AppMode.main || this == AppMode.pickSingleMediaExternal || this == AppMode.pickMultipleMediaExternal;
bool get canNavigate => {
AppMode.main,
AppMode.pickCollectionFiltersExternal,
AppMode.pickSingleMediaExternal,
AppMode.pickMultipleMediaExternal,
}.contains(this);
bool get canSelectMedia => this == AppMode.main || this == AppMode.pickMultipleMediaExternal;
bool get canSelectMedia => {
AppMode.main,
AppMode.pickMultipleMediaExternal,
}.contains(this);
bool get canSelectFilter => this == AppMode.main;
bool get hasDrawer => this == AppMode.main || this == AppMode.pickSingleMediaExternal || this == AppMode.pickMultipleMediaExternal;
bool get isPickingMedia => this == AppMode.pickSingleMediaExternal || this == AppMode.pickMultipleMediaExternal || this == AppMode.pickMediaInternal;
bool get isPickingMedia => {
AppMode.pickSingleMediaExternal,
AppMode.pickMultipleMediaExternal,
AppMode.pickMediaInternal,
}.contains(this);
}

View file

@ -1,4 +1,4 @@
import 'dart:ui' as ui show Codec;
import 'dart:ui' as ui;
import 'package:aves/services/common/services.dart';
import 'package:equatable/equatable.dart';
@ -27,7 +27,7 @@ class AppIconImage extends ImageProvider<AppIconImageKey> {
}
@override
ImageStreamCompleter load(AppIconImageKey key, DecoderCallback decode) {
ImageStreamCompleter loadBuffer(AppIconImageKey key, DecoderBufferCallback decode) {
return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode),
scale: key.scale,
@ -37,10 +37,11 @@ class AppIconImage extends ImageProvider<AppIconImageKey> {
);
}
Future<ui.Codec> _loadAsync(AppIconImageKey key, DecoderCallback decode) async {
Future<ui.Codec> _loadAsync(AppIconImageKey key, DecoderBufferCallback decode) async {
try {
final bytes = await androidAppService.getAppIcon(key.packageName, key.size);
return await decode(bytes.isEmpty ? kTransparentImage : bytes);
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes.isEmpty ? kTransparentImage : bytes);
return await decode(buffer);
} catch (error) {
debugPrint('$runtimeType _loadAsync failed with packageName=$packageName, error=$error');
throw StateError('$packageName app icon decoding failed');

View file

@ -1,6 +1,6 @@
import 'dart:async';
import 'dart:math';
import 'dart:ui' as ui show Codec;
import 'dart:ui' as ui;
import 'package:aves/services/common/services.dart';
import 'package:equatable/equatable.dart';
@ -18,7 +18,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
}
@override
ImageStreamCompleter load(RegionProviderKey key, DecoderCallback decode) {
ImageStreamCompleter loadBuffer(RegionProviderKey key, DecoderBufferCallback decode) {
return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode),
scale: 1.0,
@ -28,12 +28,12 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
);
}
Future<ui.Codec> _loadAsync(RegionProviderKey key, DecoderCallback decode) async {
Future<ui.Codec> _loadAsync(RegionProviderKey key, DecoderBufferCallback decode) async {
final uri = key.uri;
final mimeType = key.mimeType;
final pageId = key.pageId;
try {
final bytes = await mediaFileService.getRegion(
final bytes = await mediaFetchService.getRegion(
uri,
mimeType,
key.rotationDegrees,
@ -47,7 +47,8 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
if (bytes.isEmpty) {
throw StateError('$uri ($mimeType) region loading failed');
}
return await decode(bytes);
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
return await decode(buffer);
} catch (error) {
// loading may fail if the provided MIME type is incorrect (e.g. the Media Store may report a JPEG as a TIFF)
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');
@ -57,11 +58,11 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
@override
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, RegionProviderKey key, ImageErrorListener handleError) {
mediaFileService.resumeLoading(key);
mediaFetchService.resumeLoading(key);
super.resolveStreamForKey(configuration, stream, key, handleError);
}
void pause() => mediaFileService.cancelRegion(key);
void pause() => mediaFetchService.cancelRegion(key);
}
@immutable

View file

@ -1,4 +1,4 @@
import 'dart:ui' as ui show Codec;
import 'dart:ui' as ui;
import 'package:aves/services/common/services.dart';
import 'package:equatable/equatable.dart';
@ -19,7 +19,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
}
@override
ImageStreamCompleter load(ThumbnailProviderKey key, DecoderCallback decode) {
ImageStreamCompleter loadBuffer(ThumbnailProviderKey key, DecoderBufferCallback decode) {
return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode),
scale: 1.0,
@ -30,12 +30,12 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
);
}
Future<ui.Codec> _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async {
Future<ui.Codec> _loadAsync(ThumbnailProviderKey key, DecoderBufferCallback decode) async {
final uri = key.uri;
final mimeType = key.mimeType;
final pageId = key.pageId;
try {
final bytes = await mediaFileService.getThumbnail(
final bytes = await mediaFetchService.getThumbnail(
uri: uri,
mimeType: mimeType,
pageId: pageId,
@ -48,7 +48,8 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
if (bytes.isEmpty) {
throw StateError('$uri ($mimeType) loading failed');
}
return await decode(bytes);
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
return await decode(buffer);
} catch (error) {
// loading may fail if the provided MIME type is incorrect (e.g. the Media Store may report a JPEG as a TIFF)
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');
@ -58,11 +59,11 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
@override
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) {
mediaFileService.resumeLoading(key);
mediaFetchService.resumeLoading(key);
super.resolveStreamForKey(configuration, stream, key, handleError);
}
void pause() => mediaFileService.cancelThumbnail(key);
void pause() => mediaFetchService.cancelThumbnail(key);
}
@immutable

View file

@ -1,5 +1,5 @@
import 'dart:async';
import 'dart:ui' as ui show Codec;
import 'dart:ui' as ui;
import 'package:aves/services/common/services.dart';
import 'package:equatable/equatable.dart';
@ -32,7 +32,7 @@ class UriImage extends ImageProvider<UriImage> with EquatableMixin {
}
@override
ImageStreamCompleter load(UriImage key, DecoderCallback decode) {
ImageStreamCompleter loadBuffer(UriImage key, DecoderBufferCallback decode) {
final chunkEvents = StreamController<ImageChunkEvent>();
return MultiFrameImageStreamCompleter(
@ -45,11 +45,11 @@ class UriImage extends ImageProvider<UriImage> with EquatableMixin {
);
}
Future<ui.Codec> _loadAsync(UriImage key, DecoderCallback decode, StreamController<ImageChunkEvent> chunkEvents) async {
Future<ui.Codec> _loadAsync(UriImage key, DecoderBufferCallback decode, StreamController<ImageChunkEvent> chunkEvents) async {
assert(key == this);
try {
final bytes = await mediaFileService.getImage(
final bytes = await mediaFetchService.getImage(
uri,
mimeType,
rotationDegrees,
@ -66,7 +66,8 @@ class UriImage extends ImageProvider<UriImage> with EquatableMixin {
if (bytes.isEmpty) {
throw StateError('$uri ($mimeType) loading failed');
}
return await decode(bytes);
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
return await decode(buffer);
} catch (error) {
// loading may fail if the provided MIME type is incorrect (e.g. the Media Store may report a JPEG as a TIFF)
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');

View file

@ -25,7 +25,8 @@
"showTooltip": "Anzeigen",
"hideTooltip": "Ausblenden",
"actionRemove": "Entfernen",
"resetButtonTooltip": "Zurücksetzen",
"resetTooltip": "Zurücksetzen",
"saveTooltip": "Speichern",
"doubleBackExitMessage": "Zum Verlassen erneut auf „Zurück“ tippen.",
"doNotAskAgain": "Nicht noch einmal fragen",
@ -94,6 +95,7 @@
"filterFavouriteLabel": "Favorit",
"filterLocationEmptyLabel": "Ungeortet",
"filterTagEmptyLabel": "Unmarkiert",
"filterOnThisDayLabel": "Am heutigen Tag",
"filterRatingUnratedLabel": "Nicht bewertet",
"filterRatingRejectedLabel": "Verworfen",
"filterTypeAnimatedLabel": "Animationen",
@ -303,7 +305,6 @@
"aboutBug": "Fehlerbericht",
"aboutBugSaveLogInstruction": "Anwendungsprotokolle in einer Datei speichern",
"aboutBugSaveLogButton": "Speichern",
"aboutBugCopyInfoInstruction": "Systeminformationen kopieren",
"aboutBugCopyInfoButton": "Kopieren",
"aboutBugReportInstruction": "Bericht auf GitHub mit den Protokollen und Systeminformationen",
@ -417,6 +418,7 @@
"searchCollectionFieldHint": "Sammlung durchsuchen",
"searchSectionRecent": "Neueste",
"searchSectionDate": "Datum",
"searchSectionAlbums": "Alben",
"searchSectionCountries": "Länder",
"searchSectionPlaces": "Orte",
@ -502,6 +504,7 @@
"settingsViewerSlideshowTitle": "Diashow",
"settingsSlideshowRepeat": "Wiederholung",
"settingsSlideshowShuffle": "Mischen",
"settingsSlideshowFillScreen": "Bildschirm ausfüllen",
"settingsSlideshowTransitionTile": "Übergang",
"settingsSlideshowTransitionTitle": "Übergang",
"settingsSlideshowIntervalTile": "Intervall",
@ -584,6 +587,11 @@
"settingsUnitSystemTile": "Einheiten",
"settingsUnitSystemTitle": "Einheiten",
"settingsScreenSaverPageTitle": "Bildschirmschoner",
"settingsWidgetPageTitle": "Bilderrahmen",
"settingsWidgetShowOutline": "Gliederung",
"statsPageTitle": "Statistiken",
"statsWithGps": "{count, plural, =1{1 Element mit Standort} other{{count} Elemente mit Standort}}",
"statsTopCountries": "Top-Länder",

View file

@ -53,7 +53,8 @@
"showTooltip": "Show",
"hideTooltip": "Hide",
"actionRemove": "Remove",
"resetButtonTooltip": "Reset",
"resetTooltip": "Reset",
"saveTooltip": "Save",
"doubleBackExitMessage": "Tap “back” again to exit.",
"doNotAskAgain": "Do not ask again",
@ -122,6 +123,7 @@
"filterFavouriteLabel": "Favorite",
"filterLocationEmptyLabel": "Unlocated",
"filterTagEmptyLabel": "Untagged",
"filterOnThisDayLabel": "On this day",
"filterRatingUnratedLabel": "Unrated",
"filterRatingRejectedLabel": "Rejected",
"filterTypeAnimatedLabel": "Animated",
@ -433,7 +435,6 @@
"aboutBug": "Bug Report",
"aboutBugSaveLogInstruction": "Save app logs to a file",
"aboutBugSaveLogButton": "Save",
"aboutBugCopyInfoInstruction": "Copy system information",
"aboutBugCopyInfoButton": "Copy",
"aboutBugReportInstruction": "Report on GitHub with the logs and system information",
@ -597,6 +598,7 @@
"searchCollectionFieldHint": "Search collection",
"searchSectionRecent": "Recent",
"searchSectionDate": "Date",
"searchSectionAlbums": "Albums",
"searchSectionCountries": "Countries",
"searchSectionPlaces": "Places",
@ -682,6 +684,7 @@
"settingsViewerSlideshowTitle": "Slideshow",
"settingsSlideshowRepeat": "Repeat",
"settingsSlideshowShuffle": "Shuffle",
"settingsSlideshowFillScreen": "Fill screen",
"settingsSlideshowTransitionTile": "Transition",
"settingsSlideshowTransitionTitle": "Transition",
"settingsSlideshowIntervalTile": "Interval",
@ -764,6 +767,11 @@
"settingsUnitSystemTile": "Units",
"settingsUnitSystemTitle": "Units",
"settingsScreenSaverPageTitle": "Screen Saver",
"settingsWidgetPageTitle": "Photo Frame",
"settingsWidgetShowOutline": "Outline",
"statsPageTitle": "Stats",
"statsWithGps": "{count, plural, =1{1 item with location} other{{count} items with location}}",
"@statsWithGps": {

View file

@ -25,7 +25,8 @@
"showTooltip": "Mostrar",
"hideTooltip": "Ocultar",
"actionRemove": "Remover",
"resetButtonTooltip": "Restablecer",
"resetTooltip": "Restablecer",
"saveTooltip": "Guardar",
"doubleBackExitMessage": "Presione «atrás» nuevamente para salir.",
"doNotAskAgain": "No preguntar nuevamente",
@ -303,7 +304,6 @@
"aboutBug": "Reporte de errores",
"aboutBugSaveLogInstruction": "Guardar registros de la aplicación a un archivo",
"aboutBugSaveLogButton": "Guardar",
"aboutBugCopyInfoInstruction": "Copiar información del sistema",
"aboutBugCopyInfoButton": "Copiar",
"aboutBugReportInstruction": "Reportar en GitHub con los registros y la información del sistema",
@ -417,6 +417,7 @@
"searchCollectionFieldHint": "Buscar en colección",
"searchSectionRecent": "Reciente",
"searchSectionDate": "Fecha",
"searchSectionAlbums": "Álbumes",
"searchSectionCountries": "Países",
"searchSectionPlaces": "Lugares",

View file

@ -25,7 +25,8 @@
"showTooltip": "Afficher",
"hideTooltip": "Masquer",
"actionRemove": "Supprimer",
"resetButtonTooltip": "Réinitialiser",
"resetTooltip": "Réinitialiser",
"saveTooltip": "Sauvegarder",
"doubleBackExitMessage": "Pressez «\u00A0retour\u00A0» à nouveau pour quitter.",
"doNotAskAgain": "Ne pas demander de nouveau",
@ -94,6 +95,7 @@
"filterFavouriteLabel": "Favori",
"filterLocationEmptyLabel": "Sans lieu",
"filterTagEmptyLabel": "Sans libellé",
"filterOnThisDayLabel": "Ce jour-là",
"filterRatingUnratedLabel": "Sans notation",
"filterRatingRejectedLabel": "Rejeté",
"filterTypeAnimatedLabel": "Animation",
@ -303,7 +305,6 @@
"aboutBug": "Rapports derreur",
"aboutBugSaveLogInstruction": "Sauvegarder les logs de lapp vers un fichier",
"aboutBugSaveLogButton": "Sauvegarder",
"aboutBugCopyInfoInstruction": "Copier les informations denvironnement",
"aboutBugCopyInfoButton": "Copier",
"aboutBugReportInstruction": "Créer une «\u00A0issue\u00A0» sur GitHub en attachant les logs et informations denvironnement",
@ -417,6 +418,7 @@
"searchCollectionFieldHint": "Recherche",
"searchSectionRecent": "Historique",
"searchSectionDate": "Date",
"searchSectionAlbums": "Albums",
"searchSectionCountries": "Pays",
"searchSectionPlaces": "Lieux",
@ -502,6 +504,7 @@
"settingsViewerSlideshowTitle": "Diaporama",
"settingsSlideshowRepeat": "Répéter",
"settingsSlideshowShuffle": "Aléatoire",
"settingsSlideshowFillScreen": "Remplir lécran",
"settingsSlideshowTransitionTile": "Transition",
"settingsSlideshowTransitionTitle": "Transition",
"settingsSlideshowIntervalTile": "Intervalle",
@ -574,7 +577,7 @@
"settingsThemeBrightness": "Thème",
"settingsThemeColorHighlights": "Surlignages colorés",
"settingsThemeEnableDynamicColor": "Couleur dynamique",
"settingsDisplayRefreshRateModeTile": "Fréquence dactualisation de l'écran",
"settingsDisplayRefreshRateModeTile": "Fréquence dactualisation de lécran",
"settingsDisplayRefreshRateModeTitle": "Fréquence dactualisation",
"settingsSectionLanguage": "Langue & Formats",
@ -584,6 +587,11 @@
"settingsUnitSystemTile": "Unités",
"settingsUnitSystemTitle": "Unités",
"settingsScreenSaverPageTitle": "Économiseur décran",
"settingsWidgetPageTitle": "Cadre photo",
"settingsWidgetShowOutline": "Contours",
"statsPageTitle": "Statistiques",
"statsWithGps": "{count, plural, =1{1 élément localisé} other{{count} éléments localisés}}",
"statsTopCountries": "Top pays",

View file

@ -25,7 +25,8 @@
"showTooltip": "Tampilkan",
"hideTooltip": "Sembunyikan",
"actionRemove": "Hapus",
"resetButtonTooltip": "Ulang",
"resetTooltip": "Ulang",
"saveTooltip": "Simpan",
"doubleBackExitMessage": "Ketuk “kembali” lagi untuk keluar.",
"doNotAskAgain": "Jangan tanya lagi",
@ -81,6 +82,9 @@
"videoActionSetSpeed": "Kecepatan pemutaran",
"videoActionSettings": "Pengaturan",
"slideshowActionResume": "Lanjutkan",
"slideshowActionShowInCollection": "Tampilkan di Koleksi",
"entryInfoActionEditDate": "Ubah tanggal & waktu",
"entryInfoActionEditLocation": "Ubah lokasi",
"entryInfoActionEditRating": "Ubah nilai",
@ -145,10 +149,23 @@
"displayRefreshRatePreferHighest": "Penyegaran tertinggi",
"displayRefreshRatePreferLowest": "Penyegaran terendah",
"slideshowVideoPlaybackSkip": "Lewati",
"slideshowVideoPlaybackMuted": "Mainkan bisu",
"slideshowVideoPlaybackWithSound": "Mainkan dengan suara",
"themeBrightnessLight": "Terang",
"themeBrightnessDark": "Gelap",
"themeBrightnessBlack": "Hitam",
"viewerTransitionSlide": "Menggeser",
"viewerTransitionParallax": "Paralaks",
"viewerTransitionFade": "Memudar",
"viewerTransitionZoomIn": "Membesar",
"wallpaperTargetHome": "Tampilan depan",
"wallpaperTargetLock": "Tampilan kunci",
"wallpaperTargetHomeLock": "Tampilan depan dan kunci",
"albumTierNew": "Baru",
"albumTierPinned": "Disemat",
"albumTierSpecial": "Biasa",
@ -263,6 +280,7 @@
"menuActionSelectAll": "Pilih semua",
"menuActionSelectNone": "Pilih tidak ada",
"menuActionMap": "Peta",
"menuActionSlideshow": "Tampilan slide",
"menuActionStats": "Statistik",
"viewDialogTabSort": "Sortir",
@ -286,7 +304,6 @@
"aboutBug": "Lapor Bug",
"aboutBugSaveLogInstruction": "Simpan log aplikasi ke file",
"aboutBugSaveLogButton": "Simpan",
"aboutBugCopyInfoInstruction": "Salin informasi sistem",
"aboutBugCopyInfoButton": "Salin",
"aboutBugReportInstruction": "Laporkan ke GitHub dengan log dan informasi sistem",
@ -350,6 +367,7 @@
"collectionEmptyFavourites": "Tidak ada favorit",
"collectionEmptyVideos": "Tidak ada video",
"collectionEmptyImages": "Tidak ada gambar",
"collectionEmptyGrantAccessButtonLabel": "Berikan akses",
"collectionSelectSectionTooltip": "Pilih bagian",
"collectionDeselectSectionTooltip": "Batalkan pilihan bagian",
@ -399,6 +417,7 @@
"searchCollectionFieldHint": "Cari koleksi",
"searchSectionRecent": "Terkini",
"searchSectionDate": "Tanggal",
"searchSectionAlbums": "Album",
"searchSectionCountries": "Negara",
"searchSectionPlaces": "Tempat",
@ -480,6 +499,17 @@
"settingsViewerShowOverlayThumbnails": "Tampilkan thumbnail",
"settingsViewerEnableOverlayBlurEffect": "Efek Kabur",
"settingsViewerSlideshowTile": "Tampilan slide",
"settingsViewerSlideshowTitle": "Tampilan Slide",
"settingsSlideshowRepeat": "Ulangi",
"settingsSlideshowShuffle": "Acak",
"settingsSlideshowTransitionTile": "Transisi",
"settingsSlideshowTransitionTitle": "Transisi",
"settingsSlideshowIntervalTile": "Interval",
"settingsSlideshowIntervalTitle": "Interval",
"settingsSlideshowVideoPlaybackTile": "Putaran ulang video",
"settingsSlideshowVideoPlaybackTitle": "Putaran Ulang Video",
"settingsVideoPageTitle": "Pengaturan Video",
"settingsSectionVideo": "Video",
"settingsVideoShowVideos": "Tampilkan video",
@ -544,6 +574,7 @@
"settingsSectionDisplay": "Tampilan",
"settingsThemeBrightness": "Tema",
"settingsThemeColorHighlights": "Highlight warna",
"settingsThemeEnableDynamicColor": "Warna dinamis",
"settingsDisplayRefreshRateModeTile": "Tingkat penyegaran tampilan",
"settingsDisplayRefreshRateModeTitle": "Tingkat Penyegaran",
@ -561,6 +592,7 @@
"statsTopTags": "Label Teratas",
"viewerOpenPanoramaButtonLabel": "BUKA PANORAMA",
"viewerSetWallpaperButtonLabel": "TETAPKAN SEBAGAI WALLPAPER",
"viewerErrorUnknown": "Ups!",
"viewerErrorDoesNotExist": "File tidak ada lagi.",

View file

@ -25,7 +25,8 @@
"showTooltip": "Mostra",
"hideTooltip": "Nascondi",
"actionRemove": "Rimuovi",
"resetButtonTooltip": "Reimposta",
"resetTooltip": "Reimposta",
"saveTooltip": "Salva",
"doubleBackExitMessage": "Tocca di nuovo «indietro» per uscire",
"doNotAskAgain": "Non chiedere di nuovo",
@ -94,6 +95,7 @@
"filterFavouriteLabel": "Preferiti",
"filterLocationEmptyLabel": "Senza posizione",
"filterTagEmptyLabel": "Senza etichetta",
"filterOnThisDayLabel": "In questo giorno",
"filterRatingUnratedLabel": "Non valutato",
"filterRatingRejectedLabel": "Rifiutato",
"filterTypeAnimatedLabel": "Animato",
@ -303,7 +305,6 @@
"aboutBug": "Segnalazione bug",
"aboutBugSaveLogInstruction": "Salva i log dellapp in un file",
"aboutBugSaveLogButton": "Salva",
"aboutBugCopyInfoInstruction": "Copia le informazioni di sistema",
"aboutBugCopyInfoButton": "Copia",
"aboutBugReportInstruction": "Segnala su GitHub con i log e le informazioni di sistema",
@ -417,6 +418,7 @@
"searchCollectionFieldHint": "Cerca raccolta",
"searchSectionRecent": "Recenti",
"searchSectionDate": "Data",
"searchSectionAlbums": "Album",
"searchSectionCountries": "Paesi",
"searchSectionPlaces": "Luoghi",
@ -502,6 +504,7 @@
"settingsViewerSlideshowTitle": "Presentazione",
"settingsSlideshowRepeat": "Ripeti",
"settingsSlideshowShuffle": "Ordine casuale",
"settingsSlideshowFillScreen": "Riempi schermo",
"settingsSlideshowTransitionTile": "Transizione",
"settingsSlideshowTransitionTitle": "Transizione",
"settingsSlideshowIntervalTile": "Intervallo",
@ -584,6 +587,11 @@
"settingsUnitSystemTile": "Unità",
"settingsUnitSystemTitle": "Unità",
"settingsScreenSaverPageTitle": "Salvaschermo",
"settingsWidgetPageTitle": "Cornice foto",
"settingsWidgetShowOutline": "Contorno",
"statsPageTitle": "Statistiche",
"statsWithGps": "{count, plural, =1{1 elemento con posizione} other{{count} elementi con posizione}}",
"statsTopCountries": "Paesi più frequenti",

View file

@ -25,7 +25,8 @@
"showTooltip": "表示する",
"hideTooltip": "非表示にする",
"actionRemove": "削除",
"resetButtonTooltip": "リセット",
"resetTooltip": "リセット",
"saveTooltip": "保存",
"doubleBackExitMessage": "終了するには「戻る」をもう一度タップしてください。",
"doNotAskAgain": "今後このメッセージを表示しない",
@ -81,6 +82,9 @@
"videoActionSetSpeed": "再生速度",
"videoActionSettings": "設定",
"slideshowActionResume": "再開",
"slideshowActionShowInCollection": "コレクションで表示",
"entryInfoActionEditDate": "日時を編集",
"entryInfoActionEditLocation": "位置情報を編集",
"entryInfoActionEditRating": "評価を編集",
@ -91,6 +95,7 @@
"filterFavouriteLabel": "お気に入り",
"filterLocationEmptyLabel": "位置情報なし",
"filterTagEmptyLabel": "タグ情報なし",
"filterOnThisDayLabel": "過去のこの日",
"filterRatingUnratedLabel": "評価情報なし",
"filterRatingRejectedLabel": "拒否",
"filterTypeAnimatedLabel": "アニメーション",
@ -145,10 +150,23 @@
"displayRefreshRatePreferHighest": "高レート",
"displayRefreshRatePreferLowest": "低レート",
"slideshowVideoPlaybackSkip": "スキップ",
"slideshowVideoPlaybackMuted": "ミュート再生",
"slideshowVideoPlaybackWithSound": "音声あり再生",
"themeBrightnessLight": "ライト",
"themeBrightnessDark": "ダーク",
"themeBrightnessBlack": "ブラック",
"viewerTransitionSlide": "スライド",
"viewerTransitionParallax": "パララックス",
"viewerTransitionFade": "フェード",
"viewerTransitionZoomIn": "ズームイン",
"wallpaperTargetHome": "ホーム画面",
"wallpaperTargetLock": "ロック画面",
"wallpaperTargetHomeLock": "ホームおよびロック画面",
"albumTierNew": "新規",
"albumTierPinned": "固定",
"albumTierSpecial": "全体",
@ -263,6 +281,7 @@
"menuActionSelectAll": "すべて選択",
"menuActionSelectNone": "選択を解除",
"menuActionMap": "地図",
"menuActionSlideshow": "スライドショー",
"menuActionStats": "統計",
"viewDialogTabSort": "並べ替え",
@ -286,7 +305,6 @@
"aboutBug": "バグの報告",
"aboutBugSaveLogInstruction": "アプリのログをファイルに保存",
"aboutBugSaveLogButton": "保存",
"aboutBugCopyInfoInstruction": "システム情報をコピー",
"aboutBugCopyInfoButton": "コピー",
"aboutBugReportInstruction": "ログとシステム情報とともに GitHub で報告",
@ -350,6 +368,7 @@
"collectionEmptyFavourites": "お気に入りはありません",
"collectionEmptyVideos": "動画はありません",
"collectionEmptyImages": "画像はありません",
"collectionEmptyGrantAccessButtonLabel": "アクセスを許可",
"collectionSelectSectionTooltip": "セクションを選択",
"collectionDeselectSectionTooltip": "セクションの選択を解除",
@ -399,6 +418,7 @@
"searchCollectionFieldHint": "コレクションを検索",
"searchSectionRecent": "最近",
"searchSectionDate": "日付",
"searchSectionAlbums": "アルバム",
"searchSectionCountries": "国",
"searchSectionPlaces": "場所",
@ -480,6 +500,18 @@
"settingsViewerShowOverlayThumbnails": "サムネイルを表示",
"settingsViewerEnableOverlayBlurEffect": "ぼかし効果",
"settingsViewerSlideshowTile": "スライドショー",
"settingsViewerSlideshowTitle": "スライドショー",
"settingsSlideshowRepeat": "繰り返し",
"settingsSlideshowShuffle": "シャッフル",
"settingsSlideshowFillScreen": "画面いっぱいに表示",
"settingsSlideshowTransitionTile": "トランジション",
"settingsSlideshowTransitionTitle": "トランジション",
"settingsSlideshowIntervalTile": "間隔",
"settingsSlideshowIntervalTitle": "間隔",
"settingsSlideshowVideoPlaybackTile": "動画を再生",
"settingsSlideshowVideoPlaybackTitle": "動画再生",
"settingsVideoPageTitle": "動画設定",
"settingsSectionVideo": "動画",
"settingsVideoShowVideos": "動画を表示",
@ -544,6 +576,7 @@
"settingsSectionDisplay": "ディスプレイ",
"settingsThemeBrightness": "テーマ",
"settingsThemeColorHighlights": "カラー強調表示",
"settingsThemeEnableDynamicColor": "ダイナミックカラー",
"settingsDisplayRefreshRateModeTile": "ディスプレイ リフレッシュ レート",
"settingsDisplayRefreshRateModeTitle": "リフレッシュ レート",
@ -554,6 +587,11 @@
"settingsUnitSystemTile": "単位",
"settingsUnitSystemTitle": "単位",
"settingsScreenSaverPageTitle": "スクリーンセーバー",
"settingsWidgetPageTitle": "フォトフレーム",
"settingsWidgetShowOutline": "枠",
"statsPageTitle": "統計",
"statsWithGps": "{count, plural, other{位置情報のあるアイテム {count} 件}}",
"statsTopCountries": "上位の国",
@ -561,6 +599,7 @@
"statsTopTags": "上位のタグ",
"viewerOpenPanoramaButtonLabel": "パノラマを開く",
"viewerSetWallpaperButtonLabel": "壁紙設定",
"viewerErrorUnknown": "エラー",
"viewerErrorDoesNotExist": "ファイルが存在しません。",

View file

@ -25,7 +25,8 @@
"showTooltip": "보기",
"hideTooltip": "숨기기",
"actionRemove": "제거",
"resetButtonTooltip": "복원",
"resetTooltip": "복원",
"saveTooltip": "저장",
"doubleBackExitMessage": "종료하려면 한번 더 누르세요.",
"doNotAskAgain": "다시 묻지 않기",
@ -94,6 +95,7 @@
"filterFavouriteLabel": "즐겨찾기",
"filterLocationEmptyLabel": "장소 없음",
"filterTagEmptyLabel": "태그 없음",
"filterOnThisDayLabel": "이 날",
"filterRatingUnratedLabel": "별점 없음",
"filterRatingRejectedLabel": "거부됨",
"filterTypeAnimatedLabel": "애니메이션",
@ -303,7 +305,6 @@
"aboutBug": "버그 보고",
"aboutBugSaveLogInstruction": "앱 로그를 파일에 저장하기",
"aboutBugSaveLogButton": "저장",
"aboutBugCopyInfoInstruction": "시스템 정보를 복사하기",
"aboutBugCopyInfoButton": "복사",
"aboutBugReportInstruction": "로그와 시스템 정보를 첨부하여 깃허브에서 이슈를 제출하기",
@ -417,6 +418,7 @@
"searchCollectionFieldHint": "미디어 검색",
"searchSectionRecent": "최근 검색기록",
"searchSectionDate": "날짜",
"searchSectionAlbums": "앨범",
"searchSectionCountries": "국가",
"searchSectionPlaces": "장소",
@ -502,6 +504,7 @@
"settingsViewerSlideshowTitle": "슬라이드쇼",
"settingsSlideshowRepeat": "반복",
"settingsSlideshowShuffle": "순서섞기",
"settingsSlideshowFillScreen": "화면 채우기",
"settingsSlideshowTransitionTile": "전환 효과",
"settingsSlideshowTransitionTitle": "전환 효과",
"settingsSlideshowIntervalTile": "교체 주기",
@ -584,6 +587,11 @@
"settingsUnitSystemTile": "단위법",
"settingsUnitSystemTitle": "단위법",
"settingsScreenSaverPageTitle": "화면보호기",
"settingsWidgetPageTitle": "사진 액자",
"settingsWidgetShowOutline": "윤곽",
"statsPageTitle": "통계",
"statsWithGps": "{count, plural, other{{count}개 위치가 있음}}",
"statsTopCountries": "국가 랭킹",

View file

@ -25,7 +25,8 @@
"showTooltip": "Mostrar",
"hideTooltip": "Ocultar",
"actionRemove": "Remover",
"resetButtonTooltip": "Resetar",
"resetTooltip": "Resetar",
"saveTooltip": "Salve",
"doubleBackExitMessage": "Toque em “voltar” novamente para sair.",
"doNotAskAgain": "Não pergunte novamente",
@ -94,6 +95,7 @@
"filterFavouriteLabel": "Favorito",
"filterLocationEmptyLabel": "Não localizado",
"filterTagEmptyLabel": "Sem etiqueta",
"filterOnThisDayLabel": "Neste dia",
"filterRatingUnratedLabel": "Sem classificação",
"filterRatingRejectedLabel": "Rejeitado",
"filterTypeAnimatedLabel": "Animado",
@ -303,7 +305,6 @@
"aboutBug": "Relatório de erro",
"aboutBugSaveLogInstruction": "Salvar registros de aplicativos em um arquivo",
"aboutBugSaveLogButton": "Salve",
"aboutBugCopyInfoInstruction": "Copiar informações do sistema",
"aboutBugCopyInfoButton": "Copiar",
"aboutBugReportInstruction": "Relatório no GitHub com os logs e informações do sistema",
@ -417,6 +418,7 @@
"searchCollectionFieldHint": "Pesquisar coleção",
"searchSectionRecent": "Recente",
"searchSectionDate": "Data",
"searchSectionAlbums": "Álbuns",
"searchSectionCountries": "Países",
"searchSectionPlaces": "Locais",
@ -502,6 +504,7 @@
"settingsViewerSlideshowTitle": "Apresentação de slides",
"settingsSlideshowRepeat": "Repetir",
"settingsSlideshowShuffle": "Embaralhar",
"settingsSlideshowFillScreen": "Preencher tela",
"settingsSlideshowTransitionTile": "Transição",
"settingsSlideshowTransitionTitle": "Transição",
"settingsSlideshowIntervalTile": "Intervalo",
@ -584,6 +587,11 @@
"settingsUnitSystemTile": "Unidades",
"settingsUnitSystemTitle": "Unidades",
"settingsScreenSaverPageTitle": "Protetor de tela",
"settingsWidgetPageTitle": "Porta-retratos",
"settingsWidgetShowOutline": "Contorno",
"statsPageTitle": "Estatísticas",
"statsWithGps": "{count, plural, =1{1 item com localização} other{{count} itens com localização}}",
"statsTopCountries": "Principais Países",

View file

@ -25,7 +25,8 @@
"showTooltip": "Показать",
"hideTooltip": "Скрыть",
"actionRemove": "Удалить",
"resetButtonTooltip": "Сбросить",
"resetTooltip": "Сбросить",
"saveTooltip": "Сохранить",
"doubleBackExitMessage": "Нажмите «Назад» еще раз, чтобы выйти.",
"doNotAskAgain": "Больше не спрашивать",
@ -81,6 +82,9 @@
"videoActionSetSpeed": "Скорость вопспроизведения",
"videoActionSettings": "Настройки",
"slideshowActionResume": "Продолжить",
"slideshowActionShowInCollection": "Показать в Коллекции",
"entryInfoActionEditDate": "Изменить дату и время",
"entryInfoActionEditLocation": "Изменить местоположение",
"entryInfoActionEditRating": "Изменить рейтинг",
@ -145,10 +149,23 @@
"displayRefreshRatePreferHighest": "Наивысшая частота",
"displayRefreshRatePreferLowest": "Наименьшая частота",
"slideshowVideoPlaybackSkip": "Пропустить",
"slideshowVideoPlaybackMuted": "Играть без звука",
"slideshowVideoPlaybackWithSound": "Играть со звуком",
"themeBrightnessLight": "Светлая",
"themeBrightnessDark": "Тёмная",
"themeBrightnessBlack": "Чёрная",
"viewerTransitionSlide": "Скольжение",
"viewerTransitionParallax": "Параллакс",
"viewerTransitionFade": "Затухание",
"viewerTransitionZoomIn": "Приближение",
"wallpaperTargetHome": "Домашний экран",
"wallpaperTargetLock": "Экран блокировки",
"wallpaperTargetHomeLock": "Домашний экран и экран блокировки",
"albumTierNew": "Новые",
"albumTierPinned": "Закрепленные",
"albumTierSpecial": "Стандартные",
@ -263,6 +280,7 @@
"menuActionSelectAll": "Выбрать все",
"menuActionSelectNone": "Снять выделение",
"menuActionMap": "Карта",
"menuActionSlideshow": "Слайд-шоу",
"menuActionStats": "Статистика",
"viewDialogTabSort": "Сортировка",
@ -286,7 +304,6 @@
"aboutBug": "Отчет об ошибке",
"aboutBugSaveLogInstruction": "Сохраните логи приложения в файл",
"aboutBugSaveLogButton": "Сохранить",
"aboutBugCopyInfoInstruction": "Скопируйте системную информацию",
"aboutBugCopyInfoButton": "Скопировать",
"aboutBugReportInstruction": "Отправьте отчёт об ошибке на GitHub вместе с логами и системной информацией",
@ -350,6 +367,7 @@
"collectionEmptyFavourites": "Нет избранных",
"collectionEmptyVideos": "Нет видео",
"collectionEmptyImages": "Нет изображений",
"collectionEmptyGrantAccessButtonLabel": "Предоставить доступ",
"collectionSelectSectionTooltip": "Выбрать раздел",
"collectionDeselectSectionTooltip": "Снять выбор с раздела",
@ -399,6 +417,7 @@
"searchCollectionFieldHint": "Поиск по коллекции",
"searchSectionRecent": "Недавние",
"searchSectionDate": "Дата",
"searchSectionAlbums": "Альбомы",
"searchSectionCountries": "Страны",
"searchSectionPlaces": "Локации",
@ -480,6 +499,17 @@
"settingsViewerShowOverlayThumbnails": "Показать эскизы",
"settingsViewerEnableOverlayBlurEffect": "Наложение эффекта размытия",
"settingsViewerSlideshowTile": "Слайд-шоу",
"settingsViewerSlideshowTitle": "Слайд-шоу",
"settingsSlideshowRepeat": "Повтор",
"settingsSlideshowShuffle": "Вперемешку",
"settingsSlideshowTransitionTile": "Эффект перехода",
"settingsSlideshowTransitionTitle": "Эффект Перехода",
"settingsSlideshowIntervalTile": "Интервал",
"settingsSlideshowIntervalTitle": "Интервал",
"settingsSlideshowVideoPlaybackTile": "Проигрывание видео",
"settingsSlideshowVideoPlaybackTitle": "Проигрывание Видео",
"settingsVideoPageTitle": "Настройки видео",
"settingsSectionVideo": "Видео",
"settingsVideoShowVideos": "Показать видео",
@ -544,6 +574,7 @@
"settingsSectionDisplay": "Отображение",
"settingsThemeBrightness": "Тема",
"settingsThemeColorHighlights": "Цветовые акценты",
"settingsThemeEnableDynamicColor": "Динамический цвет",
"settingsDisplayRefreshRateModeTile": "Частота обновления экрана",
"settingsDisplayRefreshRateModeTitle": "Частота обновления",
@ -561,6 +592,7 @@
"statsTopTags": "Топ тегов",
"viewerOpenPanoramaButtonLabel": "ОТКРЫТЬ ПАНОРАМУ",
"viewerSetWallpaperButtonLabel": "УСТАНОВИТЬ КАК ОБОИ",
"viewerErrorUnknown": "Упс!",
"viewerErrorDoesNotExist": "Файл больше не существует.",

View file

@ -25,7 +25,8 @@
"showTooltip": "Göster",
"hideTooltip": "Gizle",
"actionRemove": "Kaldır",
"resetButtonTooltip": "Sıfırla",
"resetTooltip": "Sıfırla",
"saveTooltip": "Kaydet",
"doubleBackExitMessage": "Çıkmak için tekrar “geri”, düğmesine dokunun.",
"doNotAskAgain": "Bir daha sorma",
@ -295,7 +296,6 @@
"aboutBug": "Hata Bildirimi",
"aboutBugSaveLogInstruction": "Uygulama günlüklerini bir dosyaya kaydet",
"aboutBugSaveLogButton": "Kaydet",
"aboutBugCopyInfoInstruction": "Sistem bilgilerini kopyala",
"aboutBugCopyInfoButton": "Kopyala",
"aboutBugReportInstruction": "GitHub'da günlükleri ve sistem bilgilerini içeren bir rapor oluştur",
@ -418,6 +418,7 @@
"searchCollectionFieldHint": "Koleksiyonu ara",
"searchSectionRecent": "Yakın zamanda",
"searchSectionDate": "Tarih",
"searchSectionAlbums": "Albümler",
"searchSectionCountries": "Ülkeler",
"searchSectionPlaces": "Yerler",

View file

@ -25,7 +25,8 @@
"showTooltip": "显示",
"hideTooltip": "隐藏",
"actionRemove": "移除",
"resetButtonTooltip": "重置",
"resetTooltip": "重置",
"saveTooltip": "保存",
"doubleBackExitMessage": "再按一次退出",
"doNotAskAgain": "不再询问",
@ -94,6 +95,7 @@
"filterFavouriteLabel": "收藏夹",
"filterLocationEmptyLabel": "未定位",
"filterTagEmptyLabel": "无标签",
"filterOnThisDayLabel": "选择日期",
"filterRatingUnratedLabel": "未评分",
"filterRatingRejectedLabel": "拒绝",
"filterTypeAnimatedLabel": "动画",
@ -303,7 +305,6 @@
"aboutBug": "报告错误",
"aboutBugSaveLogInstruction": "将应用日志保存到文件",
"aboutBugSaveLogButton": "保存",
"aboutBugCopyInfoInstruction": "复制系统信息",
"aboutBugCopyInfoButton": "复制",
"aboutBugReportInstruction": "在 GitHub 上报告日志和系统信息",
@ -417,6 +418,7 @@
"searchCollectionFieldHint": "搜索媒体集",
"searchSectionRecent": "最近",
"searchSectionDate": "日期",
"searchSectionAlbums": "相册",
"searchSectionCountries": "国家",
"searchSectionPlaces": "地点",
@ -502,6 +504,7 @@
"settingsViewerSlideshowTitle": "幻灯片",
"settingsSlideshowRepeat": "重复",
"settingsSlideshowShuffle": "随机播放",
"settingsSlideshowFillScreen": "填充屏幕",
"settingsSlideshowTransitionTile": "过渡动画",
"settingsSlideshowTransitionTitle": "过渡动画",
"settingsSlideshowIntervalTile": "时间间隔",
@ -584,6 +587,11 @@
"settingsUnitSystemTile": "单位",
"settingsUnitSystemTitle": "单位",
"settingsScreenSaverPageTitle": "屏保",
"settingsWidgetPageTitle": "相框",
"settingsWidgetShowOutline": "轮廓",
"statsPageTitle": "统计",
"statsWithGps": "{count, plural, other{{count} 项带位置信息}}",
"statsTopCountries": "热门国家",

View file

@ -1,6 +1,10 @@
import 'package:aves/app_flavor.dart';
import 'package:aves/main_common.dart';
import 'package:aves/widget_common.dart';
void main() {
mainCommon(AppFlavor.huawei);
}
const _flavor = AppFlavor.huawei;
void main() => mainCommon(_flavor);
@pragma('vm:entry-point')
void widgetMain() => widgetMainCommon(_flavor);

View file

@ -1,6 +1,10 @@
import 'package:aves/app_flavor.dart';
import 'package:aves/main_common.dart';
import 'package:aves/widget_common.dart';
void main() {
mainCommon(AppFlavor.izzy);
}
const _flavor = AppFlavor.izzy;
void main() => mainCommon(_flavor);
@pragma('vm:entry-point')
void widgetMain() => widgetMainCommon(_flavor);

View file

@ -1,6 +1,10 @@
import 'package:aves/app_flavor.dart';
import 'package:aves/main_common.dart';
import 'package:aves/widget_common.dart';
void main() {
mainCommon(AppFlavor.play);
}
const _flavor = AppFlavor.play;
void main() => mainCommon(_flavor);
@pragma('vm:entry-point')
void widgetMain() => widgetMainCommon(_flavor);

View file

@ -249,7 +249,9 @@ class AvesEntry {
bool get is360 => _catalogMetadata?.is360 ?? false;
bool get canEdit => path != null && !trashed;
bool get isMediaStoreContent => uri.startsWith('content://media/');
bool get canEdit => path != null && !trashed && isMediaStoreContent;
bool get canEditDate => canEdit && (canEditExif || canEditXmp);
@ -689,7 +691,7 @@ class AvesEntry {
await metadataDb.removeIds({id}, dataTypes: dataTypes);
}
final updatedEntry = await mediaFileService.getEntry(uri, mimeType);
final updatedEntry = await mediaFetchService.getEntry(uri, mimeType);
if (updatedEntry != null) {
await applyNewFields(updatedEntry.toMap(), persist: persist);
}
@ -699,7 +701,7 @@ class AvesEntry {
Future<bool> delete() {
final completer = Completer<bool>();
mediaFileService.delete(entries: {this}).listen(
mediaEditService.delete(entries: {this}).listen(
(event) => completer.complete(event.success && !event.skipped),
onError: completer.completeError,
onDone: () {

View file

@ -58,9 +58,13 @@ class EntryDir {
var resolved = vrl.volumePath;
final parts = pContext.split(vrl.relativeDir);
for (final part in parts) {
final partLower = part.toLowerCase();
final childrenDirs = Directory(resolved).listSync().where((v) => v.absolute is Directory).toSet();
final found = childrenDirs.firstWhereOrNull((v) => pContext.basename(v.path).toLowerCase() == partLower);
FileSystemEntity? found;
final dir = Directory(resolved);
if (dir.existsSync()) {
final partLower = part.toLowerCase();
final childrenDirs = dir.listSync().where((v) => v.absolute is Directory).toSet();
found = childrenDirs.firstWhereOrNull((v) => pContext.basename(v.path).toLowerCase() == partLower);
}
resolved = found?.path ?? '$resolved${pContext.separator}$part';
}
return resolved;

View file

@ -20,11 +20,12 @@ class AlbumFilter extends CoveredCollectionFilter {
const AlbumFilter(this.album, this.displayName);
AlbumFilter.fromMap(Map<String, dynamic> json)
: this(
json['album'],
json['uniqueName'],
);
factory AlbumFilter.fromMap(Map<String, dynamic> json) {
return AlbumFilter(
json['album'],
json['uniqueName'],
);
}
@override
Map<String, dynamic> toMap() => {

View file

@ -23,11 +23,12 @@ class CoordinateFilter extends CollectionFilter {
const CoordinateFilter(this.sw, this.ne, {this.minuteSecondPadding = false});
CoordinateFilter.fromMap(Map<String, dynamic> json)
: this(
LatLng.fromJson(json['sw']),
LatLng.fromJson(json['ne']),
);
factory CoordinateFilter.fromMap(Map<String, dynamic> json) {
return CoordinateFilter(
LatLng.fromJson(json['sw']),
LatLng.fromJson(json['ne']),
);
}
@override
Map<String, dynamic> toMap() => {

137
lib/model/filters/date.dart Normal file
View file

@ -0,0 +1,137 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/format.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';
class DateFilter extends CollectionFilter {
static const type = 'date';
final DateLevel level;
late final DateTime? date;
late final DateTime _effectiveDate;
late final EntryFilter _test;
static final onThisDay = DateFilter(DateLevel.md, null);
@override
List<Object?> get props => [level, date];
DateFilter(this.level, this.date) {
_effectiveDate = date ?? DateTime.now();
switch (level) {
case DateLevel.y:
_test = (entry) => entry.bestDate?.isAtSameYearAs(_effectiveDate) ?? false;
break;
case DateLevel.ym:
_test = (entry) => entry.bestDate?.isAtSameMonthAs(_effectiveDate) ?? false;
break;
case DateLevel.ymd:
_test = (entry) => entry.bestDate?.isAtSameDayAs(_effectiveDate) ?? false;
break;
case DateLevel.md:
final month = _effectiveDate.month;
final day = _effectiveDate.day;
_test = (entry) {
final bestDate = entry.bestDate;
return bestDate != null && bestDate.month == month && bestDate.day == day;
};
break;
case DateLevel.m:
final month = _effectiveDate.month;
_test = (entry) => entry.bestDate?.month == month;
break;
case DateLevel.d:
final day = _effectiveDate.day;
_test = (entry) => entry.bestDate?.day == day;
break;
}
}
factory DateFilter.fromMap(Map<String, dynamic> json) {
final dateString = json['date'] as String?;
return DateFilter(
DateLevel.values.firstWhereOrNull((v) => v.toString() == json['level']) ?? DateLevel.ymd,
dateString != null ? DateTime.tryParse(dateString) : null,
);
}
@override
Map<String, dynamic> toMap() => {
'type': type,
'level': level.toString(),
'date': date?.toIso8601String(),
};
@override
EntryFilter get test => _test;
@override
bool isCompatible(CollectionFilter other) {
if (other is DateFilter) {
return isCompatibleLevel(level, other.level);
} else {
return true;
}
}
static bool isCompatibleLevel(DateLevel a, DateLevel b) {
switch (a) {
case DateLevel.y:
return {DateLevel.md, DateLevel.m, DateLevel.d}.contains(b);
case DateLevel.ym:
return DateLevel.d == b;
case DateLevel.ymd:
return false;
case DateLevel.md:
return DateLevel.y == b;
case DateLevel.m:
return {DateLevel.y, DateLevel.d}.contains(b);
case DateLevel.d:
return {DateLevel.y, DateLevel.ym, DateLevel.m}.contains(b);
}
}
@override
String get universalLabel => _effectiveDate.toIso8601String();
@override
String getLabel(BuildContext context) {
final l10n = context.l10n;
final locale = l10n.localeName;
switch (level) {
case DateLevel.y:
return DateFormat.y(locale).format(_effectiveDate);
case DateLevel.ym:
return DateFormat.yMMM(locale).format(_effectiveDate);
case DateLevel.ymd:
return formatDay(_effectiveDate, locale);
case DateLevel.md:
if (date != null) {
return DateFormat.MMMd(locale).format(_effectiveDate);
} else {
return l10n.filterOnThisDayLabel;
}
case DateLevel.m:
return DateFormat.MMMM(locale).format(_effectiveDate);
case DateLevel.d:
return DateFormat.d(locale).format(_effectiveDate);
}
}
@override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) {
return Icon(AIcons.date, size: size);
}
@override
String get category => type;
@override
String get key => '$type-$level-$date';
}
enum DateLevel { y, ym, ymd, md, m, d }

View file

@ -4,6 +4,7 @@ import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/coordinate.dart';
import 'package:aves/model/filters/date.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/mime.dart';
@ -28,6 +29,7 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
MimeFilter.type,
AlbumFilter.type,
TypeFilter.type,
DateFilter.type,
LocationFilter.type,
CoordinateFilter.type,
FavouriteFilter.type,
@ -52,6 +54,8 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
return AlbumFilter.fromMap(jsonMap);
case CoordinateFilter.type:
return CoordinateFilter.fromMap(jsonMap);
case DateFilter.type:
return DateFilter.fromMap(jsonMap);
case FavouriteFilter.type:
return FavouriteFilter.instance;
case LocationFilter.type:
@ -85,7 +89,7 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
EntryFilter get test;
bool get isUnique => true;
bool isCompatible(CollectionFilter other) => category != other.category;
String get universalLabel;

View file

@ -31,11 +31,12 @@ class LocationFilter extends CoveredCollectionFilter {
}
}
LocationFilter.fromMap(Map<String, dynamic> json)
: this(
LocationLevel.values.firstWhereOrNull((v) => v.toString() == json['level']) ?? LocationLevel.place,
json['location'],
);
factory LocationFilter.fromMap(Map<String, dynamic> json) {
return LocationFilter(
LocationLevel.values.firstWhereOrNull((v) => v.toString() == json['level']) ?? LocationLevel.place,
json['location'],
);
}
@override
Map<String, dynamic> toMap() => {

View file

@ -43,10 +43,11 @@ class MimeFilter extends CollectionFilter {
_icon = icon ?? AIcons.vector;
}
MimeFilter.fromMap(Map<String, dynamic> json)
: this(
json['mime'],
);
factory MimeFilter.fromMap(Map<String, dynamic> json) {
return MimeFilter(
json['mime'],
);
}
@override
Map<String, dynamic> toMap() => {

View file

@ -15,10 +15,11 @@ class PathFilter extends CollectionFilter {
PathFilter(this.path) : _rootAlbum = path.substring(0, path.length - 1);
PathFilter.fromMap(Map<String, dynamic> json)
: this(
json['path'],
);
factory PathFilter.fromMap(Map<String, dynamic> json) {
return PathFilter(
json['path'],
);
}
@override
Map<String, dynamic> toMap() => {

View file

@ -59,10 +59,11 @@ class QueryFilter extends CollectionFilter {
_test = not ? (entry) => !testTitle(entry) : testTitle;
}
QueryFilter.fromMap(Map<String, dynamic> json)
: this(
json['query'],
);
factory QueryFilter.fromMap(Map<String, dynamic> json) {
return QueryFilter(
json['query'],
);
}
@override
Map<String, dynamic> toMap() => {
@ -74,7 +75,7 @@ class QueryFilter extends CollectionFilter {
EntryFilter get test => _test;
@override
bool get isUnique => false;
bool isCompatible(CollectionFilter other) => true;
@override
String get universalLabel => query;

View file

@ -13,10 +13,11 @@ class RatingFilter extends CollectionFilter {
const RatingFilter(this.rating);
RatingFilter.fromMap(Map<String, dynamic> json)
: this(
json['rating'] ?? 0,
);
factory RatingFilter.fromMap(Map<String, dynamic> json) {
return RatingFilter(
json['rating'] ?? 0,
);
}
@override
Map<String, dynamic> toMap() => {

View file

@ -20,11 +20,12 @@ class TagFilter extends CoveredCollectionFilter {
}
}
TagFilter.fromMap(Map<String, dynamic> json)
: this(
json['tag'],
not: json['not'] ?? false,
);
factory TagFilter.fromMap(Map<String, dynamic> json) {
return TagFilter(
json['tag'],
not: json['not'] ?? false,
);
}
@override
Map<String, dynamic> toMap() => {
@ -37,7 +38,7 @@ class TagFilter extends CoveredCollectionFilter {
EntryFilter get test => _test;
@override
bool get isUnique => false;
bool isCompatible(CollectionFilter other) => true;
@override
String get universalLabel => tag;

View file

@ -59,10 +59,11 @@ class TypeFilter extends CollectionFilter {
}
}
TypeFilter.fromMap(Map<String, dynamic> json)
: this._private(
json['itemType'],
);
factory TypeFilter.fromMap(Map<String, dynamic> json) {
return TypeFilter._private(
json['itemType'],
);
}
@override
Map<String, dynamic> toMap() => {

View file

@ -31,7 +31,7 @@ class SettingsDefaults {
static const mustBackTwiceToExit = true;
static const keepScreenOn = KeepScreenOn.viewerOnly;
static const homePage = HomePageSetting.collection;
static const showBottomNavigationBar = true;
static const enableBottomNavigationBar = true;
static const confirmDeleteForever = true;
static const confirmMoveToBin = true;
static const confirmMoveUndatedItems = true;
@ -128,10 +128,15 @@ class SettingsDefaults {
// slideshow
static const slideshowRepeat = false;
static const slideshowShuffle = false;
static const slideshowFillScreen = false;
static const slideshowTransition = ViewerTransition.fade;
static const slideshowVideoPlayback = SlideshowVideoPlayback.playMuted;
static const slideshowInterval = SlideshowInterval.s5;
// widget
static const widgetOutline = false;
static const widgetShape = WidgetShape.rrect;
// platform settings
static const isRotationLocked = false;
static const areAnimationsRemoved = false;

View file

@ -1,4 +1,6 @@
import 'package:aves/services/common/services.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter_displaymode/flutter_displaymode.dart';
@ -16,17 +18,22 @@ extension ExtraDisplayRefreshRateMode on DisplayRefreshRateMode {
}
}
void apply() {
Future<void> apply() async {
if (!await windowService.isActivity()) return;
final androidInfo = await DeviceInfoPlugin().androidInfo;
if ((androidInfo.version.sdkInt ?? 0) < 23) return;
debugPrint('Apply display refresh rate: $name');
switch (this) {
case DisplayRefreshRateMode.auto:
FlutterDisplayMode.setPreferredMode(DisplayMode.auto);
await FlutterDisplayMode.setPreferredMode(DisplayMode.auto);
break;
case DisplayRefreshRateMode.highest:
FlutterDisplayMode.setHighRefreshRate();
await FlutterDisplayMode.setHighRefreshRate();
break;
case DisplayRefreshRateMode.lowest:
FlutterDisplayMode.setLowRefreshRate();
await FlutterDisplayMode.setLowRefreshRate();
break;
}
}

View file

@ -29,3 +29,5 @@ enum VideoControls { play, playSeek, playOutside, none }
enum VideoLoopMode { never, shortOnly, always }
enum ViewerTransition { slide, parallax, fade, zoomIn }
enum WidgetShape { rrect, circle, heart }

View file

@ -0,0 +1,44 @@
import 'package:aves/model/settings/enums/enums.dart';
import 'package:flutter/material.dart';
extension ExtraWidgetShape on WidgetShape {
Path path(Size widgetSize, double devicePixelRatio) {
final rect = Rect.fromLTWH(0, 0, widgetSize.width, widgetSize.height);
switch (this) {
case WidgetShape.rrect:
return Path()..addRRect(BorderRadius.circular(24 * devicePixelRatio).toRRect(rect));
case WidgetShape.circle:
return Path()
..addOval(Rect.fromCircle(
center: rect.center,
radius: rect.shortestSide / 2,
));
case WidgetShape.heart:
final center = rect.center;
final dim = rect.shortestSide;
const p0dy = -.4;
const p1dx = .5;
const p1dy = -.4;
const p2dx = .8;
const p2dy = .5;
const p3dy = .5 - p0dy;
return Path()
..moveTo(center.dx, center.dy)
..relativeMoveTo(0, dim * p0dy)
..relativeCubicTo(dim * -p1dx, dim * p1dy, dim * -p2dx, dim * p2dy, 0, dim * p3dy)
..moveTo(center.dx, center.dy)
..relativeMoveTo(0, dim * p0dy)
..relativeCubicTo(dim * p1dx, dim * p1dy, dim * p2dx, dim * p2dy, 0, dim * p3dy);
}
}
Size size(Size widgetSize) {
switch (this) {
case WidgetShape.rrect:
return widgetSize;
case WidgetShape.circle:
case WidgetShape.heart:
return Size.square(widgetSize.shortestSide);
}
}
}

View file

@ -27,7 +27,7 @@ class Settings extends ChangeNotifier {
Settings._private();
static const Set<String> internalKeys = {
static const Set<String> _internalKeys = {
hasAcceptedTermsKey,
catalogTimeZoneKey,
videoShowRawTimedTextKey,
@ -36,6 +36,7 @@ class Settings extends ChangeNotifier {
platformTransitionAnimationScaleKey,
topEntryIdsKey,
};
static const _widgetKeyPrefix = 'widget_';
// app
static const hasAcceptedTermsKey = 'has_accepted_terms';
@ -60,7 +61,7 @@ class Settings extends ChangeNotifier {
static const mustBackTwiceToExitKey = 'must_back_twice_to_exit';
static const keepScreenOnKey = 'keep_screen_on';
static const homePageKey = 'home_page';
static const showBottomNavigationBarKey = 'show_bottom_navigation_bar';
static const enableBottomNavigationBarKey = 'show_bottom_navigation_bar';
static const confirmDeleteForeverKey = 'confirm_delete_forever';
static const confirmMoveToBinKey = 'confirm_move_to_bin';
static const confirmMoveUndatedItemsKey = 'confirm_move_undated_items';
@ -138,13 +139,27 @@ class Settings extends ChangeNotifier {
// file picker
static const filePickerShowHiddenFilesKey = 'file_picker_show_hidden_files';
// screen saver
static const screenSaverFillScreenKey = 'screen_saver_fill_screen';
static const screenSaverTransitionKey = 'screen_saver_transition';
static const screenSaverVideoPlaybackKey = 'screen_saver_video_playback';
static const screenSaverIntervalKey = 'screen_saver_interval';
static const screenSaverCollectionFiltersKey = 'screen_saver_collection_filters';
// slideshow
static const slideshowRepeatKey = 'slideshow_loop';
static const slideshowShuffleKey = 'slideshow_shuffle';
static const slideshowFillScreenKey = 'slideshow_fill_screen';
static const slideshowTransitionKey = 'slideshow_transition';
static const slideshowVideoPlaybackKey = 'slideshow_video_playback';
static const slideshowIntervalKey = 'slideshow_interval';
// widget
static const widgetOutlinePrefixKey = '${_widgetKeyPrefix}outline_';
static const widgetShapePrefixKey = '${_widgetKeyPrefix}shape_';
static const widgetCollectionFiltersPrefixKey = '${_widgetKeyPrefix}collection_filters_';
static const widgetUriPrefixKey = '${_widgetKeyPrefix}uri_';
// platform settings
// cf Android `Settings.System.ACCELEROMETER_ROTATION`
static const platformAccelerometerRotationKey = 'accelerometer_rotation';
@ -161,14 +176,18 @@ class Settings extends ChangeNotifier {
}
}
Future<void> reload() => settingsStore.reload();
Future<void> reset({required bool includeInternalKeys}) async {
if (includeInternalKeys) {
await settingsStore.clear();
} else {
await Future.forEach<String>(settingsStore.getKeys().whereNot(Settings.internalKeys.contains), settingsStore.remove);
await Future.forEach<String>(settingsStore.getKeys().whereNot(isInternalKey), settingsStore.remove);
}
}
bool isInternalKey(String key) => _internalKeys.contains(key) || key.startsWith(_widgetKeyPrefix);
Future<void> setContextualDefaults() async {
// performance
final performanceClass = await deviceService.getPerformanceClass();
@ -315,9 +334,9 @@ class Settings extends ChangeNotifier {
set homePage(HomePageSetting newValue) => setAndNotify(homePageKey, newValue.toString());
bool get showBottomNavigationBar => getBoolOrDefault(showBottomNavigationBarKey, SettingsDefaults.showBottomNavigationBar);
bool get enableBottomNavigationBar => getBoolOrDefault(enableBottomNavigationBarKey, SettingsDefaults.enableBottomNavigationBar);
set showBottomNavigationBar(bool newValue) => setAndNotify(showBottomNavigationBarKey, newValue);
set enableBottomNavigationBar(bool newValue) => setAndNotify(enableBottomNavigationBarKey, newValue);
bool get confirmDeleteForever => getBoolOrDefault(confirmDeleteForeverKey, SettingsDefaults.confirmDeleteForever);
@ -583,6 +602,28 @@ class Settings extends ChangeNotifier {
set filePickerShowHiddenFiles(bool newValue) => setAndNotify(filePickerShowHiddenFilesKey, newValue);
// screen saver
bool get screenSaverFillScreen => getBoolOrDefault(screenSaverFillScreenKey, SettingsDefaults.slideshowFillScreen);
set screenSaverFillScreen(bool newValue) => setAndNotify(screenSaverFillScreenKey, newValue);
ViewerTransition get screenSaverTransition => getEnumOrDefault(screenSaverTransitionKey, SettingsDefaults.slideshowTransition, ViewerTransition.values);
set screenSaverTransition(ViewerTransition newValue) => setAndNotify(screenSaverTransitionKey, newValue.toString());
SlideshowVideoPlayback get screenSaverVideoPlayback => getEnumOrDefault(screenSaverVideoPlaybackKey, SettingsDefaults.slideshowVideoPlayback, SlideshowVideoPlayback.values);
set screenSaverVideoPlayback(SlideshowVideoPlayback newValue) => setAndNotify(screenSaverVideoPlaybackKey, newValue.toString());
SlideshowInterval get screenSaverInterval => getEnumOrDefault(screenSaverIntervalKey, SettingsDefaults.slideshowInterval, SlideshowInterval.values);
set screenSaverInterval(SlideshowInterval newValue) => setAndNotify(screenSaverIntervalKey, newValue.toString());
Set<CollectionFilter> get screenSaverCollectionFilters => (getStringList(screenSaverCollectionFiltersKey) ?? []).map(CollectionFilter.fromJson).whereNotNull().toSet();
set screenSaverCollectionFilters(Set<CollectionFilter> newValue) => setAndNotify(screenSaverCollectionFiltersKey, newValue.map((filter) => filter.toJson()).toList());
// slideshow
bool get slideshowRepeat => getBoolOrDefault(slideshowRepeatKey, SettingsDefaults.slideshowRepeat);
@ -593,6 +634,10 @@ class Settings extends ChangeNotifier {
set slideshowShuffle(bool newValue) => setAndNotify(slideshowShuffleKey, newValue);
bool get slideshowFillScreen => getBoolOrDefault(slideshowFillScreenKey, SettingsDefaults.slideshowFillScreen);
set slideshowFillScreen(bool newValue) => setAndNotify(slideshowFillScreenKey, newValue);
ViewerTransition get slideshowTransition => getEnumOrDefault(slideshowTransitionKey, SettingsDefaults.slideshowTransition, ViewerTransition.values);
set slideshowTransition(ViewerTransition newValue) => setAndNotify(slideshowTransitionKey, newValue.toString());
@ -605,6 +650,27 @@ class Settings extends ChangeNotifier {
set slideshowInterval(SlideshowInterval newValue) => setAndNotify(slideshowIntervalKey, newValue.toString());
// widget
Color? getWidgetOutline(int widgetId) {
final value = getInt('$widgetOutlinePrefixKey$widgetId');
return value != null ? Color(value) : null;
}
void setWidgetOutline(int widgetId, Color? newValue) => setAndNotify('$widgetOutlinePrefixKey$widgetId', newValue?.value);
WidgetShape getWidgetShape(int widgetId) => getEnumOrDefault('$widgetShapePrefixKey$widgetId', SettingsDefaults.widgetShape, WidgetShape.values);
void setWidgetShape(int widgetId, WidgetShape newValue) => setAndNotify('$widgetShapePrefixKey$widgetId', newValue.toString());
Set<CollectionFilter> getWidgetCollectionFilters(int widgetId) => (getStringList('$widgetCollectionFiltersPrefixKey$widgetId') ?? []).map(CollectionFilter.fromJson).whereNotNull().toSet();
void setWidgetCollectionFilters(int widgetId, Set<CollectionFilter> newValue) => setAndNotify('$widgetCollectionFiltersPrefixKey$widgetId', newValue.map((filter) => filter.toJson()).toList());
String? getWidgetUri(int widgetId) => getString('$widgetUriPrefixKey$widgetId');
void setWidgetUri(int widgetId, String? newValue) => setAndNotify('$widgetUriPrefixKey$widgetId', newValue);
// convenience methods
int? getInt(String key) => settingsStore.getInt(key);
@ -687,7 +753,7 @@ class Settings extends ChangeNotifier {
// import/export
Map<String, dynamic> export() => Map.fromEntries(
settingsStore.getKeys().whereNot(internalKeys.contains).map((k) => MapEntry(k, settingsStore.get(k))),
settingsStore.getKeys().whereNot(isInternalKey).map((k) => MapEntry(k, settingsStore.get(k))),
);
Future<void> import(dynamic jsonMap) async {
@ -735,7 +801,7 @@ class Settings extends ChangeNotifier {
case isErrorReportingAllowedKey:
case enableDynamicColorKey:
case enableBlurEffectKey:
case showBottomNavigationBarKey:
case enableBottomNavigationBarKey:
case mustBackTwiceToExitKey:
case confirmDeleteForeverKey:
case confirmMoveToBinKey:
@ -763,8 +829,10 @@ class Settings extends ChangeNotifier {
case subtitleShowOutlineKey:
case saveSearchHistoryKey:
case filePickerShowHiddenFilesKey:
case screenSaverFillScreenKey:
case slideshowRepeatKey:
case slideshowShuffleKey:
case slideshowFillScreenKey:
if (newValue is bool) {
settingsStore.setBool(key, newValue);
} else {
@ -792,6 +860,9 @@ class Settings extends ChangeNotifier {
case unitSystemKey:
case accessibilityAnimationsKey:
case timeToTakeActionKey:
case screenSaverTransitionKey:
case screenSaverVideoPlaybackKey:
case screenSaverIntervalKey:
case slideshowTransitionKey:
case slideshowVideoPlaybackKey:
case slideshowIntervalKey:
@ -809,6 +880,7 @@ class Settings extends ChangeNotifier {
case collectionBrowsingQuickActionsKey:
case collectionSelectionQuickActionsKey:
case viewerQuickActionsKey:
case screenSaverCollectionFiltersKey:
if (newValue is List) {
settingsStore.setStringList(key, newValue.cast<String>());
} else {

View file

@ -3,6 +3,8 @@ abstract class SettingsStore {
Future<void> init();
Future<void> reload();
Future<bool> clear();
Future<bool> remove(String key);

View file

@ -17,6 +17,9 @@ class SharedPrefSettingsStore implements SettingsStore {
}
}
@override
Future<void> reload() => _prefs!.reload();
@override
Future<bool> clear() => _prefs!.clear();

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