viewer: action to rotate screen when device has locked rotation

This commit is contained in:
Thibault Deckers 2021-07-03 17:02:22 +09:00
parent c294343f07
commit adc41bf3cd
16 changed files with 255 additions and 26 deletions

View file

@ -22,7 +22,8 @@ import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
private lateinit var contentStreamHandler: ContentChangeStreamHandler
private lateinit var mediaStoreChangeStreamHandler: MediaStoreChangeStreamHandler
private lateinit var settingsChangeStreamHandler: SettingsChangeStreamHandler
private lateinit var intentStreamHandler: IntentStreamHandler
private lateinit var intentDataMap: MutableMap<String, Any?>
@ -50,8 +51,11 @@ class MainActivity : FlutterActivity() {
StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL).setStreamHandlerFactory { args -> StorageAccessStreamHandler(this, args) }
// Media Store change monitoring
contentStreamHandler = ContentChangeStreamHandler(this).apply {
EventChannel(messenger, ContentChangeStreamHandler.CHANNEL).setStreamHandler(this)
mediaStoreChangeStreamHandler = MediaStoreChangeStreamHandler(this).apply {
EventChannel(messenger, MediaStoreChangeStreamHandler.CHANNEL).setStreamHandler(this)
}
settingsChangeStreamHandler = SettingsChangeStreamHandler(this).apply {
EventChannel(messenger, SettingsChangeStreamHandler.CHANNEL).setStreamHandler(this)
}
// intent handling
@ -75,7 +79,8 @@ class MainActivity : FlutterActivity() {
}
override fun onDestroy() {
contentStreamHandler.dispose()
mediaStoreChangeStreamHandler.dispose()
settingsChangeStreamHandler.dispose()
super.onDestroy()
}

View file

@ -1,8 +1,11 @@
package deckers.thibault.aves.channel.calls
import android.app.Activity
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
@ -11,6 +14,8 @@ 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)
else -> result.notImplemented()
}
}
@ -32,7 +37,28 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler {
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", e)
}
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)
}
companion object {
private val LOG_TAG = LogUtils.createTag<WindowHandler>()
const val CHANNEL = "deckers.thibault/aves/window"
}
}

View file

@ -11,7 +11,7 @@ import deckers.thibault.aves.utils.LogUtils
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink
class ContentChangeStreamHandler(private val context: Context) : EventChannel.StreamHandler {
class MediaStoreChangeStreamHandler(private val context: Context) : EventChannel.StreamHandler {
// cannot use `lateinit` because we cannot guarantee
// its initialization in `onListen` at the right time
private var eventSink: EventSink? = null
@ -58,7 +58,7 @@ class ContentChangeStreamHandler(private val context: Context) : EventChannel.St
}
companion object {
private val LOG_TAG = LogUtils.createTag<ContentChangeStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/contentchange"
private val LOG_TAG = LogUtils.createTag<MediaStoreChangeStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/mediastorechange"
}
}

View file

@ -0,0 +1,88 @@
package deckers.thibault.aves.channel.streams
import android.content.Context
import android.database.ContentObserver
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.util.Log
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.LogUtils
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink
class SettingsChangeStreamHandler(private val context: Context) : EventChannel.StreamHandler {
// cannot use `lateinit` because we cannot guarantee
// its initialization in `onListen` at the right time
private var eventSink: EventSink? = null
private var handler: Handler? = null
private val contentObserver = object : ContentObserver(null) {
private var accelerometerRotation: Int = 0
init {
update()
}
override fun onChange(selfChange: Boolean) {
this.onChange(selfChange, null)
}
override fun onChange(selfChange: Boolean, uri: Uri?) {
if (update()) {
success(
hashMapOf(
Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation
)
)
}
}
private fun update(): Boolean {
var changed = false
try {
val newAccelerometerRotation = Settings.System.getInt(context.contentResolver, Settings.System.ACCELEROMETER_ROTATION)
if (accelerometerRotation != newAccelerometerRotation) {
accelerometerRotation = newAccelerometerRotation
changed = true
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get settings", e)
}
return changed
}
}
init {
context.contentResolver.apply {
registerContentObserver(Settings.System.CONTENT_URI, true, contentObserver)
}
}
override fun onListen(arguments: Any?, eventSink: EventSink) {
this.eventSink = eventSink
handler = Handler(Looper.getMainLooper())
}
override fun onCancel(arguments: Any?) {}
fun dispose() {
context.contentResolver.unregisterContentObserver(contentObserver)
}
private fun success(settings: FieldMap) {
handler?.post {
try {
eventSink?.success(settings)
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to use event sink", e)
}
}
}
companion object {
private val LOG_TAG = LogUtils.createTag<SettingsChangeStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/settingschange"
}
}

View file

@ -88,6 +88,8 @@
"@entryActionSetAs": {},
"entryActionOpenMap": "Show on map…",
"@entryActionOpenMap": {},
"entryActionRotateScreen": "Rotate screen",
"@entryActionRotateScreen": {},
"entryActionAddFavourite": "Add to favourites",
"@entryActionAddFavourite": {},
"entryActionRemoveFavourite": "Remove from favourites",

View file

@ -44,6 +44,7 @@
"entryActionOpen": "다른 앱에서 열기…",
"entryActionSetAs": "다음 용도로 사용…",
"entryActionOpenMap": "지도에서 보기…",
"entryActionRotateScreen": "화면 회전",
"entryActionAddFavourite": "즐겨찾기에 추가",
"entryActionRemoveFavourite": "즐겨찾기에서 삭제",

View file

@ -21,6 +21,8 @@ enum EntryAction {
open,
openMap,
setAs,
// platform
rotateScreen,
// debug
debug,
}
@ -40,6 +42,7 @@ class EntryActions {
EntryAction.export,
EntryAction.print,
EntryAction.viewSource,
EntryAction.rotateScreen,
];
static const externalApp = [
@ -93,6 +96,9 @@ extension ExtraEntryAction on EntryAction {
return context.l10n.entryActionSetAs;
case EntryAction.openMap:
return context.l10n.entryActionOpenMap;
// platform
case EntryAction.rotateScreen:
return context.l10n.entryActionRotateScreen;
// debug
case EntryAction.debug:
return 'Debug';
@ -132,6 +138,9 @@ extension ExtraEntryAction on EntryAction {
case EntryAction.setAs:
case EntryAction.openMap:
return null;
// platform
case EntryAction.rotateScreen:
return AIcons.rotateScreen;
// debug
case EntryAction.debug:
return AIcons.debug;

View file

@ -4,20 +4,26 @@ import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/screen_on.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/services/window_service.dart';
import 'package:aves/utils/pedantic.dart';
import 'package:collection/collection.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:shared_preferences/shared_preferences.dart';
final Settings settings = Settings._private();
class Settings extends ChangeNotifier {
final EventChannel _platformSettingsChangeChannel = const EventChannel('deckers.thibault/aves/settingschange');
static SharedPreferences? _prefs;
Settings._private();
Settings._private() {
_platformSettingsChangeChannel.receiveBroadcastStream().listen((event) => _onPlatformSettingsChange(event as Map?));
}
// app
static const hasAcceptedTermsKey = 'has_accepted_terms';
@ -84,6 +90,7 @@ class Settings extends ChangeNotifier {
static const viewerQuickActionsDefault = [
EntryAction.toggleFavourite,
EntryAction.share,
EntryAction.rotateScreen,
];
static const videoQuickActionsDefault = [
VideoAction.replay10,
@ -92,6 +99,7 @@ class Settings extends ChangeNotifier {
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
_isRotationLocked = await WindowService.isRotationLocked();
}
// Crashlytics initialization is separated from the main settings initialization
@ -364,4 +372,30 @@ class Settings extends ChangeNotifier {
notifyListeners();
}
}
// platform settings
void _onPlatformSettingsChange(Map? fields) {
fields?.forEach((key, value) {
switch (key) {
// cf Android `Settings.System.ACCELEROMETER_ROTATION`
case 'accelerometer_rotation':
if (value is int) {
final newValue = value == 0;
if (_isRotationLocked != newValue) {
_isRotationLocked = newValue;
if (!_isRotationLocked) {
WindowService.requestOrientation();
}
notifyListeners();
}
}
break;
}
});
}
bool _isRotationLocked = false;
bool get isRotationLocked => _isRotationLocked;
}

View file

@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
class WindowService {
static const platform = MethodChannel('deckers.thibault/aves/window');
@ -13,4 +14,40 @@ class WindowService {
debugPrint('keepScreenOn failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
}
static Future<bool> isRotationLocked() async {
try {
final result = await platform.invokeMethod('isRotationLocked');
if (result != null) return result as bool;
} on PlatformException catch (e) {
debugPrint('isRotationLocked failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return false;
}
static Future<void> requestOrientation([Orientation? orientation]) async {
// cf Android `ActivityInfo.ScreenOrientation`
late final int orientationCode;
switch (orientation) {
case Orientation.landscape:
// SCREEN_ORIENTATION_USER_LANDSCAPE
orientationCode = 11;
break;
case Orientation.portrait:
// SCREEN_ORIENTATION_USER_PORTRAIT
orientationCode = 12;
break;
default:
// SCREEN_ORIENTATION_UNSPECIFIED
orientationCode = -1;
break;
}
try {
await platform.invokeMethod('requestOrientation', <String, dynamic>{
'orientation': orientationCode,
});
} on PlatformException catch (e) {
debugPrint('requestOrientation failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
}
}

View file

@ -56,6 +56,7 @@ class AIcons {
static const IconData rename = Icons.title_outlined;
static const IconData rotateLeft = Icons.rotate_left_outlined;
static const IconData rotateRight = Icons.rotate_right_outlined;
static const IconData rotateScreen = Icons.screen_rotation_outlined;
static const IconData search = Icons.search_outlined;
static const IconData select = Icons.select_all_outlined;
static const IconData setCover = MdiIcons.imageEditOutline;

View file

@ -35,13 +35,13 @@ class _AvesAppState extends State<AvesApp> {
final ValueNotifier<AppMode> appModeNotifier = ValueNotifier(AppMode.main);
late Future<void> _appSetup;
final _mediaStoreSource = MediaStoreSource();
final Debouncer _contentChangeDebouncer = Debouncer(delay: Durations.contentChangeDebounceDelay);
final Debouncer _mediaStoreChangeDebouncer = Debouncer(delay: Durations.contentChangeDebounceDelay);
final Set<String> changedUris = {};
// observers are not registered when using the same list object with different items
// the list itself needs to be reassigned
List<NavigatorObserver> _navigatorObservers = [];
final EventChannel _contentChangeChannel = const EventChannel('deckers.thibault/aves/contentchange');
final EventChannel _mediaStoreChangeChannel = const EventChannel('deckers.thibault/aves/mediastorechange');
final EventChannel _newIntentChannel = const EventChannel('deckers.thibault/aves/intent');
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator');
@ -52,7 +52,7 @@ class _AvesAppState extends State<AvesApp> {
super.initState();
initPlatformServices();
_appSetup = _setup();
_contentChangeChannel.receiveBroadcastStream().listen((event) => _onContentChange(event as String?));
_mediaStoreChangeChannel.receiveBroadcastStream().listen((event) => _onMediaStoreChange(event as String?));
_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map?));
}
@ -155,16 +155,16 @@ class _AvesAppState extends State<AvesApp> {
));
}
void _onContentChange(String? uri) {
void _onMediaStoreChange(String? uri) {
if (uri != null) changedUris.add(uri);
if (changedUris.isNotEmpty) {
_contentChangeDebouncer(() async {
_mediaStoreChangeDebouncer(() async {
final todo = changedUris.toSet();
changedUris.clear();
final tempUris = await _mediaStoreSource.refreshUris(todo);
if (tempUris.isNotEmpty) {
changedUris.addAll(tempUris);
_onContentChange(null);
_onMediaStoreChange(null);
}
});
}

View file

@ -22,7 +22,7 @@ class ThumbnailsSection extends StatelessWidget {
final currentShowThumbnailRaw = context.select<Settings, bool>((s) => s.showThumbnailRaw);
final currentShowThumbnailVideoDuration = context.select<Settings, bool>((s) => s.showThumbnailVideoDuration);
final iconSize = IconTheme.of(context).size! * MediaQuery.of(context).textScaleFactor;
final iconSize = IconTheme.of(context).size! * MediaQuery.textScaleFactorOf(context);
double opacityFor(bool enabled) => enabled ? 1 : .2;
return AvesExpansionTile(

View file

@ -37,6 +37,7 @@ class ViewerActionEditorPage extends StatelessWidget {
EntryAction.rename,
EntryAction.export,
EntryAction.print,
EntryAction.rotateScreen,
EntryAction.viewSource,
EntryAction.flip,
EntryAction.rotateCCW,

View file

@ -12,6 +12,7 @@ import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/services.dart';
import 'package:aves/services/window_service.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/utils/pedantic.dart';
import 'package:aves/widgets/collection/collection_page.dart';
@ -84,6 +85,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
if (!success) showNoMatchingAppDialog(context);
});
break;
case EntryAction.rotateScreen:
_rotateScreen(context);
break;
case EntryAction.setAs:
AndroidAppService.setAs(entry.uri, entry.mimeType).then((success) {
if (!success) showNoMatchingAppDialog(context);
@ -117,6 +121,17 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
if (!success) showFeedback(context, context.l10n.genericFailureFeedback);
}
Future<void> _rotateScreen(BuildContext context) async {
switch (context.read<MediaQueryData>().orientation) {
case Orientation.landscape:
await WindowService.requestOrientation(Orientation.portrait);
break;
case Orientation.portrait:
await WindowService.requestOrientation(Orientation.landscape);
break;
}
}
Future<void> _showDeleteDialog(BuildContext context, AvesEntry entry) async {
final confirmed = await showDialog<bool>(
context: context,

View file

@ -511,6 +511,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
void _onLeave() {
_showSystemUI();
WindowService.requestOrientation();
if (settings.keepScreenOn == KeepScreenOn.viewerOnly) {
WindowService.keepScreenOn(false);
}

View file

@ -99,6 +99,8 @@ class ViewerTopOverlay extends StatelessWidget {
return targetEntry.hasGps;
case EntryAction.viewSource:
return targetEntry.isSvg;
case EntryAction.rotateScreen:
return settings.isRotationLocked;
case EntryAction.share:
case EntryAction.info:
case EntryAction.open:
@ -110,17 +112,22 @@ class ViewerTopOverlay extends StatelessWidget {
}
}
final quickActions = settings.viewerQuickActions.where(_canDo).take(availableCount - 1).toList();
final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo).toList();
final externalAppActions = EntryActions.externalApp.where(_canDo).toList();
final buttonRow = _TopOverlayRow(
quickActions: quickActions,
inAppActions: inAppActions,
externalAppActions: externalAppActions,
scale: scale,
mainEntry: mainEntry,
pageEntry: pageEntry,
onActionSelected: onActionSelected,
final buttonRow = Selector<Settings, bool>(
selector: (context, s) => s.isRotationLocked,
builder: (context, s, child) {
final quickActions = settings.viewerQuickActions.where(_canDo).take(availableCount - 1).toList();
final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo).toList();
final externalAppActions = EntryActions.externalApp.where(_canDo).toList();
return _TopOverlayRow(
quickActions: quickActions,
inAppActions: inAppActions,
externalAppActions: externalAppActions,
scale: scale,
mainEntry: mainEntry,
pageEntry: pageEntry!,
onActionSelected: onActionSelected,
);
},
);
return settings.showOverlayMinimap && viewStateNotifier != null
@ -212,6 +219,7 @@ class _TopOverlayRow extends StatelessWidget {
case EntryAction.rotateCCW:
case EntryAction.rotateCW:
case EntryAction.share:
case EntryAction.rotateScreen:
case EntryAction.viewSource:
child = IconButton(
icon: Icon(action.getIcon()),
@ -256,6 +264,7 @@ class _TopOverlayRow extends StatelessWidget {
case EntryAction.rotateCCW:
case EntryAction.rotateCW:
case EntryAction.share:
case EntryAction.rotateScreen:
case EntryAction.viewSource:
case EntryAction.debug:
child = MenuRow(text: action.getText(context), icon: action.getIcon());