-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/android block #230
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Feat/android block #230
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,71 @@ | ||||||
| package com.company.bookstar | ||||||
|
|
||||||
| import android.accessibilityservice.AccessibilityService | ||||||
| import android.view.accessibility.AccessibilityEvent | ||||||
| import android.content.Context | ||||||
| import android.content.Intent | ||||||
| import android.content.SharedPreferences | ||||||
| import android.util.Log | ||||||
|
|
||||||
| class FocusAccessibilityService : AccessibilityService() { | ||||||
|
|
||||||
| companion object { | ||||||
| private const val TAG = "FocusAccessibility" | ||||||
| private const val PREFS_NAME = "FocusModePrefs" | ||||||
| private const val KEY_IS_ACTIVE = "is_active" | ||||||
| // Whitelisted packages: Our app + Keyboards | ||||||
| private val WHITELISTED_PACKAGES = setOf( | ||||||
| "com.company.bookstar", | ||||||
| "com.google.android.inputmethod.latin", // Gboard | ||||||
| "com.samsung.android.honeyboard" // Samsung Keyboard | ||||||
| ) | ||||||
| } | ||||||
|
|
||||||
| override fun onAccessibilityEvent(event: AccessibilityEvent?) { | ||||||
| // We only care about window state changes (app switching) | ||||||
| if (event == null || event.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) { | ||||||
| return | ||||||
| } | ||||||
|
|
||||||
| val packageName = event.packageName?.toString() ?: return | ||||||
|
|
||||||
| // Check if blocking is enabled from SharedPrefs | ||||||
| val prefs: SharedPreferences = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) | ||||||
| val isBlocking = prefs.getBoolean(KEY_IS_ACTIVE, false) | ||||||
|
|
||||||
| if (isBlocking) { | ||||||
| if (!isPackageAllowed(packageName)) { | ||||||
| Log.d(TAG, "Blocking package: $packageName") | ||||||
|
|
||||||
| // Action: Bring our app to front to make it clear why | ||||||
| val intent = packageManager.getLaunchIntentForPackage("com.company.bookstar") | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 패키지 이름 "com.company.bookstar"가 하드코딩되어 있습니다.
Suggested change
|
||||||
| if (intent != null) { | ||||||
| intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) | ||||||
| startActivity(intent) | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| private fun isPackageAllowed(packageName: String): Boolean { | ||||||
| // 1. Check exact whitelist | ||||||
| if (WHITELISTED_PACKAGES.contains(packageName)) return true | ||||||
|
|
||||||
| // 2. Allow input methods (Keyboards) - Critical for user interaction | ||||||
| if (packageName.contains("inputmethod") || packageName.contains("keyboard")) return true | ||||||
|
|
||||||
| // 3. Allow System UI (Notifications, Quick Settings) | ||||||
| if (packageName == "com.android.systemui") return true | ||||||
|
|
||||||
| return false | ||||||
| } | ||||||
|
|
||||||
| override fun onInterrupt() { | ||||||
| Log.d(TAG, "FocusAccessibilityService interrupted") | ||||||
| } | ||||||
|
|
||||||
| override fun onServiceConnected() { | ||||||
| super.onServiceConnected() | ||||||
| Log.d(TAG, "FocusAccessibilityService connected") | ||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,73 @@ | ||||||
| package com.company.bookstar | ||||||
|
|
||||||
| import android.content.ComponentName | ||||||
| import android.content.Context | ||||||
| import android.content.Intent | ||||||
| import android.provider.Settings | ||||||
| import android.text.TextUtils | ||||||
| import androidx.annotation.NonNull | ||||||
| import io.flutter.embedding.android.FlutterActivity | ||||||
| import io.flutter.embedding.engine.FlutterEngine | ||||||
| import io.flutter.plugin.common.MethodChannel | ||||||
|
|
||||||
| class MainActivity : FlutterActivity() { | ||||||
| // Channel name as requested | ||||||
| private val CHANNEL = "com.example.blocker/control" | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 채널 이름에
Suggested change
|
||||||
|
|
||||||
| override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { | ||||||
| super.configureFlutterEngine(flutterEngine) | ||||||
| MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> | ||||||
| when (call.method) { | ||||||
| "startBlock" -> { | ||||||
| val prefs = getSharedPreferences("FocusModePrefs", Context.MODE_PRIVATE) | ||||||
| prefs.edit().putBoolean("is_active", true).apply() | ||||||
|
Comment on lines
+22
to
+23
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||
| result.success(true) | ||||||
| } | ||||||
| "stopBlock" -> { | ||||||
| val prefs = getSharedPreferences("FocusModePrefs", Context.MODE_PRIVATE) | ||||||
| prefs.edit().putBoolean("is_active", false).apply() | ||||||
| result.success(true) | ||||||
| } | ||||||
| "requestPermission" -> { | ||||||
| if (!isAccessibilityServiceEnabled()) { | ||||||
| val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) | ||||||
| // Add flags to ensure proper navigation behavior | ||||||
| intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) | ||||||
| startActivity(intent) | ||||||
| result.success(true) // Indicates we launched the settings | ||||||
| } else { | ||||||
| result.success(false) // Permission already granted | ||||||
| } | ||||||
| } | ||||||
| "checkPermission" -> { | ||||||
| result.success(isAccessibilityServiceEnabled()) | ||||||
| } | ||||||
| else -> { | ||||||
| result.notImplemented() | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| // Helper method to check if the Accessibility Service is enabled | ||||||
| private fun isAccessibilityServiceEnabled(): Boolean { | ||||||
| val expectedComponentName = ComponentName(this, FocusAccessibilityService::class.java) | ||||||
|
|
||||||
| val enabledServicesSetting = Settings.Secure.getString( | ||||||
| contentResolver, | ||||||
| Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES | ||||||
| ) ?: return false | ||||||
|
|
||||||
| val colonSplitter = TextUtils.SimpleStringSplitter(':') | ||||||
| colonSplitter.setString(enabledServicesSetting) | ||||||
|
|
||||||
| while (colonSplitter.hasNext()) { | ||||||
| val componentNameString = colonSplitter.next() | ||||||
| val enabledComponent = ComponentName.unflattenFromString(componentNameString) | ||||||
| if (enabledComponent != null && enabledComponent == expectedComponentName) { | ||||||
| return true | ||||||
| } | ||||||
| } | ||||||
| return false | ||||||
| } | ||||||
| } | ||||||
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| <?xml version="1.0" encoding="utf-8"?> | ||
| <resources> | ||
| <string name="accessibility_service_description">Used to block distracting apps while the Focus Timer is running.</string> | ||
| </resources> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| <?xml version="1.0" encoding="utf-8"?> | ||
| <accessibility-service xmlns:android="http://schemas.android.com/apk/res/android" | ||
| android:accessibilityEventTypes="typeWindowStateChanged" | ||
| android:accessibilityFeedbackType="feedbackGeneric" | ||
| android:notificationTimeout="100" | ||
| android:canRetrieveWindowContent="true" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| android:description="@string/accessibility_service_description" | ||
| /> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| import 'package:flutter/services.dart'; | ||
|
|
||
| class NativeBlockController { | ||
| static const MethodChannel _channel = MethodChannel('com.example.blocker/control'); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| /// 네이티브에 차단 모드 시작을 요청합니다. | ||
| Future<void> startBlocking() async { | ||
| try { | ||
| await _channel.invokeMethod('startBlock'); | ||
| print('Blocking started on native side.'); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } on PlatformException catch (e) { | ||
| print("Failed to start blocking: '${e.message}'."); | ||
| } | ||
| } | ||
|
|
||
| /// 네이티브에 차단 모드 해제를 요청합니다. | ||
| Future<void> stopBlocking() async { | ||
| try { | ||
| await _channel.invokeMethod('stopBlock'); | ||
| print('Blocking stopped on native side.'); | ||
| } on PlatformException catch (e) { | ||
| print("Failed to stop blocking: '${e.message}'."); | ||
| } | ||
| } | ||
|
|
||
| /// 네이티브에 접근성 권한 요청 (설정 화면으로 이동)을 요청합니다. | ||
| /// 설정 화면이 열렸으면 true, 이미 권한이 부여되었으면 false를 반환합니다. | ||
| Future<bool> requestPermission() async { | ||
| try { | ||
| final bool? result = await _channel.invokeMethod('requestPermission'); | ||
| print('Permission request sent to native side. Result: $result'); | ||
| return result ?? false; // true if settings were opened, false if already enabled | ||
| } on PlatformException catch (e) { | ||
| print("Failed to request permission: '${e.message}'."); | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| /// 네이티브에 접근성 권한 상태를 확인을 요청합니다. | ||
| /// 권한이 부여되었으면 true, 아니면 false를 반환합니다. | ||
| Future<bool> checkPermission() async { | ||
| try { | ||
| final bool? result = await _channel.invokeMethod('checkPermission'); | ||
| print('Permission check result from native side: $result'); | ||
| return result ?? false; | ||
| } on PlatformException catch (e) { | ||
| print("Failed to check permission: '${e.message}'."); | ||
| return false; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ import 'package:bookstar/gen/colors.gen.dart'; | |
| import 'package:bookstar/modules/reading_challenge/view_model/challenge_quiz_view_model.dart'; | ||
| import 'package:flutter/material.dart'; | ||
| import 'package:flutter_riverpod/flutter_riverpod.dart'; | ||
| import 'package:bookstar/core/native_block_controller.dart'; | ||
|
|
||
| class ReadingChallengeQuizDeepTimeScreen extends ConsumerStatefulWidget { | ||
| const ReadingChallengeQuizDeepTimeScreen({ | ||
|
|
@@ -45,10 +46,14 @@ class _ReadingChallengeQuizDeepTimeScreenState | |
| @override | ||
| bool get wantKeepAlive => true; | ||
|
|
||
| final NativeBlockController _blockController = NativeBlockController(); // Instantiate NativeBlockController | ||
| bool _isPermissionGranted = false; // State for accessibility permission | ||
|
|
||
| @override | ||
| void initState() { | ||
| super.initState(); | ||
| WidgetsBinding.instance.addObserver(this); | ||
| _checkPermissionStatus(); // Check permission on init | ||
| } | ||
|
|
||
| @override | ||
|
|
@@ -57,6 +62,48 @@ class _ReadingChallengeQuizDeepTimeScreenState | |
| super.dispose(); | ||
| } | ||
|
|
||
| @override | ||
| void didChangeAppLifecycleState(AppLifecycleState state) { | ||
| if (state == AppLifecycleState.resumed) { | ||
| _checkPermissionStatus(); // Re-check permission when app resumes | ||
| } | ||
| } | ||
|
|
||
| // Method to check accessibility permission status | ||
| Future<void> _checkPermissionStatus() async { | ||
| final bool granted = await _blockController.checkPermission(); | ||
| if (mounted) { | ||
| setState(() { | ||
| _isPermissionGranted = granted; | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| Future<bool> _showPermissionGuideDialog() async { | ||
| final result = await showDialog<bool>( | ||
| context: context, | ||
| builder: (context) { | ||
| return CustomDialog( | ||
| title: '집중 모드 권한 설정 안내', | ||
| content: '다른 앱의 실행을 차단하고 독서에 집중하기 위해\n접근성 권한이 필요합니다.\n\n설정 화면에서 \'BookStar\'를 찾아\n권한을 허용해주세요.', | ||
| titleStyle: AppTexts.b7.copyWith(color: ColorName.w1), | ||
| contentStyle: AppTexts.b11.copyWith(color: ColorName.g2), | ||
| // Using a lock icon or similar as a visual cue | ||
| icon: Assets.icons.icDeepTimeLockClose.svg(width: 80, height: 80), | ||
| onCancel: () { | ||
| Navigator.of(context).pop(false); | ||
| }, | ||
| onConfirm: () { | ||
| Navigator.of(context).pop(true); | ||
| }, | ||
| confirmButtonText: '설정하러 가기', | ||
| cancelButtonText: '취소', | ||
| ); | ||
| }, | ||
| ); | ||
| return result ?? false; | ||
| } | ||
|
|
||
| @override | ||
| Widget build(BuildContext context) { | ||
| super.build(context); | ||
|
|
@@ -193,8 +240,29 @@ class _ReadingChallengeQuizDeepTimeScreenState | |
| child: Padding( | ||
| padding: const EdgeInsets.symmetric(horizontal: 16), | ||
| child: GestureDetector( | ||
| onTap: () { | ||
| toggleIsLock(); | ||
| onTap: () async { | ||
| if (!_isPermissionGranted) { | ||
| // Show dialog to guide user before requesting permission | ||
| final bool shouldGoToSettings = await _showPermissionGuideDialog(); | ||
|
|
||
| if (shouldGoToSettings) { | ||
| final bool settingsOpened = await _blockController.requestPermission(); | ||
| if (settingsOpened) { | ||
| // Wait for user to possibly enable it and come back | ||
| // Note: We can't know exactly when they come back, but lifecycle listener handles re-check | ||
| } | ||
| } | ||
| // Don't proceed to toggle lock if permission wasn't granted yet | ||
| return; | ||
| } | ||
|
|
||
| // If permission is granted, proceed with blocking/unblocking logic | ||
| if (!widget.isLock) { // If currently unlocked (false), tapping means locking (true) | ||
| await _blockController.startBlocking(); | ||
| } else { // If currently locked (true), tapping means unlocking (false) | ||
| await _blockController.stopBlocking(); | ||
| } | ||
| widget.onLockToggle(); // Call original toggle callback to update widget.isLock | ||
| }, | ||
|
Comment on lines
+243
to
266
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| child: !isLock | ||
| ? Assets.icons.icDeepTimeLockOpen.svg() | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
getSharedPreferences가 모든 접근성 이벤트마다 호출되고 있습니다. 접근성 이벤트는 매우 빈번하게 발생할 수 있으므로 비효율적입니다.SharedPreferences인스턴스를 한 번만 가져와서 (예:onServiceConnected()에서) 멤버 변수에 저장하고 재사용하는 것이 좋습니다.