diff --git a/android/app/src/debug/res/xml/screen_saver.xml b/android/app/src/debug/res/xml/screen_saver.xml new file mode 100644 index 000000000..91be344f3 --- /dev/null +++ b/android/app/src/debug/res/xml/screen_saver.xml @@ -0,0 +1,2 @@ + \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index f7f1a5b4b..8617fd4b5 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -5,7 +5,10 @@ import android.app.SearchManager 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 +16,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 @@ -66,11 +71,12 @@ class MainActivity : FlutterActivity() { 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, 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 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/ScreenSaverService.kt b/android/app/src/main/kotlin/deckers/thibault/aves/ScreenSaverService.kt new file mode 100644 index 000000000..ad949c2de --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/ScreenSaverService.kt @@ -0,0 +1,4 @@ +package deckers.thibault.aves + +class ScreenSaverService { +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/ScreenSaverSettingsActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/ScreenSaverSettingsActivity.kt new file mode 100644 index 000000000..5f6549f7c --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/ScreenSaverSettingsActivity.kt @@ -0,0 +1,4 @@ +package deckers.thibault.aves + +class ScreenSaverSettingsActivity { +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/WallpaperActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/WallpaperActivity.kt index d7f28867e..20f89511c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/WallpaperActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/WallpaperActivity.kt @@ -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 @@ -30,11 +35,12 @@ class WallpaperActivity : FlutterActivity() { MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this)) MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(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 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AccessibilityHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AccessibilityHandler.kt index 6deead748..55925791a 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AccessibilityHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AccessibilityHandler.kt @@ -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) } @@ -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 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFileHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFileHandler.kt index 44c0cc405..0d4dc3c37 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFileHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFileHandler.kt @@ -1,6 +1,6 @@ package deckers.thibault.aves.channel.calls -import android.app.Activity +import android.content.ContextWrapper import android.graphics.Rect import android.net.Uri import android.util.Log @@ -21,14 +21,17 @@ 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 MediaFileHandler(private val contextWrapper: ContextWrapper) : MethodCallHandler { private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private val density = activity.resources.displayMetrics.density + private val density = contextWrapper.resources.displayMetrics.density - private val regionFetcher = RegionFetcher(activity) + private val regionFetcher = RegionFetcher(contextWrapper) override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { @@ -56,7 +59,7 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler { return } - provider.fetchSingle(activity, uri, mimeType, object : ImageOpCallback { + provider.fetchSingle(contextWrapper, 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) }) @@ -80,7 +83,7 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler { // convert DIP to physical pixels here, instead of using `devicePixelRatio` in Flutter ThumbnailFetcher( - activity, + contextWrapper, uri, mimeType, dateModifiedSecs, @@ -113,14 +116,14 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler { val regionRect = Rect(x, y, x + width, y + height) when (mimeType) { - MimeTypes.SVG -> SvgRegionFetcher(activity).fetch( + MimeTypes.SVG -> SvgRegionFetcher(contextWrapper).fetch( uri = uri, regionRect = regionRect, imageWidth = imageWidth, imageHeight = imageHeight, result = result, ) - MimeTypes.TIFF -> TiffRegionFetcher(activity).fetch( + MimeTypes.TIFF -> TiffRegionFetcher(contextWrapper).fetch( uri = uri, page = pageId ?: 0, sampleSize = sampleSize, @@ -172,14 +175,14 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler { } destinationDir = ensureTrailingSeparator(destinationDir) - provider.captureFrame(activity, desiredName, exifFields, bytes, destinationDir, nameConflictStrategy, object : ImageOpCallback { + 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) }) } private fun clearSizedThumbnailDiskCache(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { - Glide.get(activity).clearDiskCache() + Glide.get(contextWrapper).clearDiskCache() result.success(null) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt index 7b6d7ecfb..da5124483 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt @@ -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) { @@ -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) }) @@ -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) }) @@ -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) }) @@ -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) }) @@ -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) }) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WallpaperHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WallpaperHandler.kt index c4f1eab9d..2935a8c14 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WallpaperHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WallpaperHandler.kt @@ -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) { @@ -33,7 +33,7 @@ class WallpaperHandler(private val activity: Activity) : MethodCallHandler { 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) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WindowHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WindowHandler.kt deleted file mode 100644 index 528e70941..000000000 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WindowHandler.kt +++ /dev/null @@ -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("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("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("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() - const val CHANNEL = "deckers.thibault/aves/window" - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/ActivityWindowHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/ActivityWindowHandler.kt new file mode 100644 index 000000000..11905270e --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/ActivityWindowHandler.kt @@ -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("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) + } + + override fun requestOrientation(call: MethodCall, result: MethodChannel.Result) { + val orientation = call.argument("orientation") + if (orientation == null) { + result.error("requestOrientation-args", "failed because of 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("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() + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/ServiceWindowHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/ServiceWindowHandler.kt new file mode 100644 index 000000000..55794ade4 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/ServiceWindowHandler.kt @@ -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) + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/WindowHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/WindowHandler.kt new file mode 100644 index 000000000..184d2398d --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/WindowHandler.kt @@ -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() + const val CHANNEL = "deckers.thibault/aves/window" + } +} diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt index 7a6cfae94..66d6f1035 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt @@ -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 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index 12ce8e4a5..679e96b9a 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -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 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index c8f38a340..e3e9ff736 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -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() - 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, diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/Compat.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/Compat.kt index cd7dffcfc..9be2fbe67 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/Compat.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/Compat.kt @@ -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 { diff --git a/android/app/src/main/res/xml/screen_saver.xml b/android/app/src/main/res/xml/screen_saver.xml new file mode 100644 index 000000000..e69de29bb diff --git a/android/app/src/profile/res/xml/screen_saver.xml b/android/app/src/profile/res/xml/screen_saver.xml new file mode 100644 index 000000000..91be344f3 --- /dev/null +++ b/android/app/src/profile/res/xml/screen_saver.xml @@ -0,0 +1,2 @@ + \ No newline at end of file diff --git a/lib/widgets/settings/screen_saver_settings_page.dart b/lib/widgets/settings/screen_saver_settings_page.dart new file mode 100644 index 000000000..e69de29bb diff --git a/lib/widgets/viewer/screen_saver_page.dart b/lib/widgets/viewer/screen_saver_page.dart new file mode 100644 index 000000000..c21b238bf --- /dev/null +++ b/lib/widgets/viewer/screen_saver_page.dart @@ -0,0 +1,142 @@ +import 'package:aves/app_mode.dart'; +import 'package:aves/model/actions/slideshow_actions.dart'; +import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/mime.dart'; +import 'package:aves/model/settings/enums/enums.dart'; +import 'package:aves/model/settings/enums/slideshow_interval.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/empty.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/viewer/controller.dart'; +import 'package:aves/widgets/viewer/entry_viewer_page.dart'; +import 'package:aves/widgets/viewer/entry_viewer_stack.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class SlideshowPage extends StatefulWidget { + static const routeName = '/collection/slideshow'; + + final CollectionLens collection; + + const SlideshowPage({ + super.key, + required this.collection, + }); + + @override + State createState() => _SlideshowPageState(); +} + +class _SlideshowPageState extends State { + late final CollectionLens _slideshowCollection; + late final ViewerController _viewerController; + + @override + void initState() { + super.initState(); + final originalCollection = widget.collection; + var entries = originalCollection.sortedEntries; + if (settings.slideshowVideoPlayback == SlideshowVideoPlayback.skip) { + entries = entries.where((entry) => !MimeFilter.video.test(entry)).toList(); + } + if (settings.slideshowShuffle) { + entries.shuffle(); + } + _slideshowCollection = CollectionLens( + source: originalCollection.source, + listenToSource: false, + fixedSort: true, + fixedSelection: entries, + ); + _viewerController = ViewerController( + transition: settings.slideshowTransition, + repeat: settings.slideshowRepeat, + autopilot: true, + autopilotInterval: settings.slideshowInterval.getDuration(), + ); + } + + @override + void dispose() { + _viewerController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final entries = _slideshowCollection.sortedEntries; + return ListenableProvider>.value( + value: ValueNotifier(AppMode.slideshow), + child: MediaQueryDataProvider( + child: Scaffold( + body: entries.isEmpty + ? EmptyContent( + icon: AIcons.image, + text: context.l10n.collectionEmptyImages, + alignment: Alignment.center, + ) + : ViewStateConductorProvider( + child: VideoConductorProvider( + child: MultiPageConductorProvider( + child: NotificationListener( + onNotification: (notification) { + _onActionSelected(notification.action); + return true; + }, + child: EntryViewerStack( + collection: _slideshowCollection, + initialEntry: entries.first, + viewerController: _viewerController, + ), + ), + ), + ), + ), + ), + ), + ); + } + + void _onActionSelected(SlideshowAction action) { + switch (action) { + case SlideshowAction.resume: + _viewerController.autopilot = true; + break; + case SlideshowAction.showInCollection: + _showInCollection(); + break; + } + } + + void _showInCollection() { + final entry = _viewerController.entryNotifier.value; + if (entry == null) return; + + final source = _slideshowCollection.source; + final album = entry.directory; + final uri = entry.uri; + + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + settings: const RouteSettings(name: CollectionPage.routeName), + builder: (context) => CollectionPage( + source: source, + filters: album != null ? {AlbumFilter(album, source.getAlbumDisplayName(context, album))} : null, + highlightTest: (entry) => entry.uri == uri, + ), + ), + (route) => false, + ); + } +} + +class SlideshowActionNotification extends Notification { + final SlideshowAction action; + + SlideshowActionNotification(this.action); +}