Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<service
android:name=".FocusAccessibilityService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config" />
</service>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
Expand Down
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

getSharedPreferences가 모든 접근성 이벤트마다 호출되고 있습니다. 접근성 이벤트는 매우 빈번하게 발생할 수 있으므로 비효율적입니다. SharedPreferences 인스턴스를 한 번만 가져와서 (예: onServiceConnected()에서) 멤버 변수에 저장하고 재사용하는 것이 좋습니다.

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")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

패키지 이름 "com.company.bookstar"가 하드코딩되어 있습니다. applicationContext.packageName을 사용하면 패키지 이름이 변경될 경우 발생할 수 있는 문제를 피하고 코드를 더 견고하게 만들 수 있습니다.

Suggested change
val intent = packageManager.getLaunchIntentForPackage("com.company.bookstar")
val intent = packageManager.getLaunchIntentForPackage(applicationContext.packageName)

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")
}
}
73 changes: 73 additions & 0 deletions android/app/src/main/kotlin/com/company/bookstar/MainActivity.kt
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"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

채널 이름에 com.example이 사용되었습니다. 채널 이름의 접두사로 앱의 패키지 이름을 사용하는 것이 고유성을 보장하는 좋은 방법입니다. 이렇게 하면 다른 플러그인이나 앱과의 잠재적인 충돌을 피할 수 있습니다. lib/core/native_block_controller.dart 파일에서도 함께 수정해야 합니다.

Suggested change
private val CHANNEL = "com.example.blocker/control"
private val CHANNEL = "com.company.bookstar/blocker/control"


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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

SharedPreferences 이름 "FocusModePrefs"와 키 "is_active"가 하드코딩되어 있습니다. 이 값들은 FocusAccessibilityService에서도 사용됩니다. 중복과 잠재적인 불일치를 피하기 위해, 공유 위치(예: FocusAccessibilityService.companion object)에 상수로 정의하고 여기와 서비스에서 참조하는 것이 좋습니다. 이 내용은 27-28라인에도 동일하게 적용됩니다.

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
}
}
5 changes: 0 additions & 5 deletions android/app/src/main/kotlin/com/example/book/MainActivity.kt

This file was deleted.

4 changes: 4 additions & 0 deletions android/app/src/main/res/values/strings.xml
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>
8 changes: 8 additions & 0 deletions android/app/src/main/res/xml/accessibility_service_config.xml
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"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

android:canRetrieveWindowContent="true" 권한은 서비스가 모든 창의 콘텐츠를 읽을 수 있도록 허용하는 매우 강력하고 민감한 권한입니다. 현재 FocusAccessibilityService 구현은 이 권한이 필요 없는 event.packageName만 사용합니다. 최소 권한 원칙에 따라 이 속성을 제거해야 합니다.

android:description="@string/accessibility_service_description"
/>
51 changes: 51 additions & 0 deletions lib/core/native_block_controller.dart
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');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

이 채널 이름은 MainActivity.kt에 정의된 이름과 일치해야 합니다. 채널 이름의 접두사로 앱의 패키지 이름을 사용하는 것이 고유성을 보장하는 좋은 방법입니다.

Suggested change
static const MethodChannel _channel = MethodChannel('com.example.blocker/control');
static const MethodChannel _channel = MethodChannel('com.company.bookstar/blocker/control');


/// 네이티브에 차단 모드 시작을 요청합니다.
Future<void> startBlocking() async {
try {
await _channel.invokeMethod('startBlock');
print('Blocking started on native side.');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

print() 호출은 프로덕션 코드에서 피해야 합니다. package:flutter/foundation.dartdebugPrint (릴리즈 빌드에서는 아무 작업도 하지 않음)를 사용하거나 전용 로깅 패키지를 사용하는 것을 고려해 보세요. 이 파일의 모든 print 호출에 해당됩니다.

Suggested change
print('Blocking started on native side.');
debugPrint('Blocking started on native side.');

} 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
Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

새로운 onTap 로직으로 인해 _buildDeepTimeLock 메소드의 toggleIsLock 파라미터가 더 이상 필요 없게 되었습니다. 이제 widget.onLockToggle()을 직접 호출하고 있기 때문입니다. 코드 명확성을 높이고 사용하지 않는 코드를 제거하기 위해 _buildDeepTimeLock에서 toggleIsLock 파라미터를 제거하고 호출 부분(139라인)도 업데이트하는 것이 좋습니다.

child: !isLock
? Assets.icons.icDeepTimeLockOpen.svg()
Expand Down