diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cf112f5..884bc98 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,10 +10,10 @@ android { defaultConfig { applicationId = "com.kevinluo.autoglm" - minSdk = 24 + minSdk = 27 targetSdk = 34 - versionCode = 5 - versionName = "0.0.5" + versionCode = 2 + versionName = "0.0.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -32,7 +32,8 @@ android { buildTypes { debug { - // 不再使用 applicationIdSuffix,与发行版使用相同包名 + applicationIdSuffix = ".debug" + versionNameSuffix = "-debug" resValue("string", "app_name", "AutoGLM Dev") } release { @@ -59,7 +60,7 @@ android { kotlinOptions { jvmTarget = "11" } - + // Enable JUnit 5 for Kotest property-based testing testOptions { unitTests.all { @@ -69,33 +70,25 @@ android { } } -// Copy dev_profiles.json to assets for debug builds only +// Copy dev_profiles.json to assets for debug builds android.applicationVariants.all { val variant = this - - // Custom APK file name - outputs.all { - val output = this as com.android.build.gradle.internal.api.BaseVariantOutputImpl - output.outputFileName = "AutoGLM-${variant.versionName}-${variant.buildType.name}.apk" - } - if (variant.buildType.name == "debug") { val copyDevProfiles = tasks.register("copyDevProfiles${variant.name.replaceFirstChar { it.uppercase() }}") { val devProfilesFile = rootProject.file("dev_profiles.json") - // Use debug-specific assets directory to avoid polluting release builds - val assetsDir = file("src/debug/assets") - + val assetsDir = file("src/main/assets") + doLast { if (devProfilesFile.exists()) { assetsDir.mkdirs() devProfilesFile.copyTo(File(assetsDir, "dev_profiles.json"), overwrite = true) - println("Copied dev_profiles.json to debug assets") + println("Copied dev_profiles.json to assets") } else { println("dev_profiles.json not found, skipping") } } } - + tasks.named("merge${variant.name.replaceFirstChar { it.uppercase() }}Assets") { dependsOn(copyDevProfiles) } @@ -111,29 +104,29 @@ dependencies { implementation(libs.androidx.constraintlayout) implementation(libs.shizuku.api) implementation(libs.shizuku.provider) - + // Kotlin Coroutines for async operations implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.android) - + // Lifecycle & ViewModel implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.androidx.lifecycle.runtime) - + // Security for encrypted preferences implementation(libs.androidx.security.crypto) - + // OkHttp for API communication implementation(libs.okhttp) implementation(libs.okhttp.sse) implementation(libs.okhttp.logging) - + // Retrofit for API communication implementation(libs.retrofit) - + // Kotlin Serialization for JSON parsing implementation(libs.kotlinx.serialization.json) - + // Testing testImplementation(libs.junit) testImplementation(libs.kotest.runner.junit5) @@ -141,4 +134,4 @@ dependencies { testImplementation(libs.kotest.assertions.core) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b4cd24b..31ac67f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,9 +3,13 @@ xmlns:tools="http://schemas.android.com/tools"> + + + + + + + + + + + - \ No newline at end of file + diff --git a/app/src/main/aidl/com/kevinluo/autoglm/IUserService.aidl b/app/src/main/aidl/com/kevinluo/autoglm/IUserService.aidl index 09a179b..6ed15f5 100644 --- a/app/src/main/aidl/com/kevinluo/autoglm/IUserService.aidl +++ b/app/src/main/aidl/com/kevinluo/autoglm/IUserService.aidl @@ -3,4 +3,10 @@ package com.kevinluo.autoglm; interface IUserService { void destroy() = 16777114; String executeCommand(String command) = 1; + // Capture screenshot to internal buffer and return base64 length + int captureScreenshotAndGetSize() = 2; + // Read a base64 chunk from the internal screenshot buffer + String readScreenshotChunk(int offset, int size) = 3; + // Clear internal screenshot buffer to free memory + void clearScreenshotBuffer() = 4; } diff --git a/app/src/main/java/com/kevinluo/autoglm/ComponentManager.kt b/app/src/main/java/com/kevinluo/autoglm/ComponentManager.kt index 3f29cf0..354f3ab 100644 --- a/app/src/main/java/com/kevinluo/autoglm/ComponentManager.kt +++ b/app/src/main/java/com/kevinluo/autoglm/ComponentManager.kt @@ -7,7 +7,11 @@ import com.kevinluo.autoglm.agent.AgentConfig import com.kevinluo.autoglm.agent.PhoneAgent import com.kevinluo.autoglm.agent.PhoneAgentListener import com.kevinluo.autoglm.app.AppResolver +import com.kevinluo.autoglm.device.AccessibilityDeviceController +import com.kevinluo.autoglm.device.DeviceControlMode import com.kevinluo.autoglm.device.DeviceExecutor +import com.kevinluo.autoglm.device.IDeviceController +import com.kevinluo.autoglm.device.ShizukuDeviceController import com.kevinluo.autoglm.history.HistoryManager import com.kevinluo.autoglm.input.TextInputManager import com.kevinluo.autoglm.model.ModelClient @@ -17,29 +21,29 @@ import com.kevinluo.autoglm.screenshot.ScreenshotService import com.kevinluo.autoglm.settings.SettingsManager import com.kevinluo.autoglm.ui.FloatingWindowService import com.kevinluo.autoglm.util.HumanizedSwipeGenerator -import com.kevinluo.autoglm.util.Logger /** * Centralized component manager for dependency injection and lifecycle management. * Provides a single point of access for all major components in the application. - * + * * This class ensures: * - Proper dependency injection * - Lifecycle-aware component management * - Clean separation of concerns - * + * + * Requirements: All (integration) */ class ComponentManager private constructor(private val context: Context) { - + companion object { private const val TAG = "ComponentManager" - + @Volatile private var instance: ComponentManager? = null - + /** * Gets the singleton instance of ComponentManager. - * + * * @param context Application context * @return ComponentManager instance */ @@ -48,7 +52,7 @@ class ComponentManager private constructor(private val context: Context) { instance ?: ComponentManager(context.applicationContext).also { instance = it } } } - + /** * Clears the singleton instance. * Should be called when the application is being destroyed. @@ -60,66 +64,84 @@ class ComponentManager private constructor(private val context: Context) { } } } - + // Settings manager - always available val settingsManager: SettingsManager by lazy { SettingsManager(context) } - + // History manager - always available val historyManager: HistoryManager by lazy { HistoryManager.getInstance(context) } - + // User service reference - set when Shizuku connects private var userService: IUserService? = null - + + // Device controller - initialized based on control mode + private var _deviceController: IDeviceController? = null + // Lazily initialized components that depend on UserService private var _deviceExecutor: DeviceExecutor? = null private var _textInputManager: TextInputManager? = null private var _screenshotService: ScreenshotService? = null private var _actionHandler: ActionHandler? = null private var _phoneAgent: PhoneAgent? = null - + // Components that don't depend on UserService private var _modelClient: ModelClient? = null private var _appResolver: AppResolver? = null private var _swipeGenerator: HumanizedSwipeGenerator? = null - + /** - * Checks if the UserService is connected. + * Checks if the device controller is ready. + * For Shizuku mode: checks if Shizuku service is connected + * For Accessibility mode: checks if PhoneAgent is initialized */ val isServiceConnected: Boolean - get() = userService != null - + get() { + val controlMode = settingsManager.getDeviceControlMode() + return when (controlMode) { + DeviceControlMode.SHIZUKU -> userService != null + DeviceControlMode.ACCESSIBILITY -> _phoneAgent != null + } + } + /** * Gets the DeviceExecutor instance. * Requires UserService to be connected. */ val deviceExecutor: DeviceExecutor? get() = _deviceExecutor - + + /** + * Gets the DeviceController instance. + * Available in both Shizuku and Accessibility modes. + */ + val deviceControllerInstance: IDeviceController? + get() = _deviceController + /** * Gets the ScreenshotService instance. * Requires UserService to be connected. */ val screenshotService: ScreenshotService? get() = _screenshotService - + /** * Gets the ActionHandler instance. * Requires UserService to be connected. */ val actionHandler: ActionHandler? get() = _actionHandler - + /** * Gets the PhoneAgent instance. * Requires UserService to be connected. */ val phoneAgent: PhoneAgent? get() = _phoneAgent - + /** * Gets the ModelClient instance. * Creates a new instance if config has changed. @@ -132,7 +154,7 @@ class ComponentManager private constructor(private val context: Context) { } return _modelClient!! } - + /** * Gets the AppResolver instance. */ @@ -143,7 +165,7 @@ class ComponentManager private constructor(private val context: Context) { } return _appResolver!! } - + /** * Gets the HumanizedSwipeGenerator instance. */ @@ -154,57 +176,110 @@ class ComponentManager private constructor(private val context: Context) { } return _swipeGenerator!! } - + // Track current model config for change detection private var currentModelConfig: ModelConfig? = null - + /** * Called when UserService connects. - * Initializes all service-dependent components. - * + * Initializes all service-dependent components for Shizuku mode. + * * @param service The connected UserService */ fun onServiceConnected(service: IUserService) { - Logger.i(TAG, "UserService connected, initializing components") + android.util.Log.i(TAG, "UserService connected, initializing components") userService = service - initializeServiceDependentComponents() + + // Only initialize Shizuku components if in Shizuku mode + val controlMode = settingsManager.getDeviceControlMode() + if (controlMode == DeviceControlMode.SHIZUKU) { + initializeServiceDependentComponents() + } } - + /** * Called when UserService disconnects. * Cleans up service-dependent components. */ fun onServiceDisconnected() { - Logger.i(TAG, "UserService disconnected, cleaning up components") + android.util.Log.i(TAG, "UserService disconnected, cleaning up components") userService = null cleanupServiceDependentComponents() } - + /** * Initializes components that depend on UserService. */ private fun initializeServiceDependentComponents() { val service = userService ?: return - + // Create DeviceExecutor _deviceExecutor = DeviceExecutor(service) - + // Create TextInputManager _textInputManager = TextInputManager(service) - + // Create ScreenshotService with floating window controller provider // Use a provider function so it can get the current instance dynamically - _screenshotService = ScreenshotService(service) { FloatingWindowService.getInstance() } - - // Create ActionHandler with floating window provider to hide window during touch operations + _screenshotService = ScreenshotService( + userService = service, + screenshotProvider = null, // For Shizuku mode, use shell commands + floatingWindowControllerProvider = { FloatingWindowService.getInstance() } + ) + + // Create ShizukuDeviceController for Shizuku mode + _deviceController = ShizukuDeviceController( + userService = service, + textInputManager = _textInputManager!!, + screenshotProvider = { _screenshotService!! } + ) + + // Create ActionHandler with IDeviceController _actionHandler = ActionHandler( - deviceExecutor = _deviceExecutor!!, + deviceController = _deviceController!!, + appResolver = appResolver, + swipeGenerator = swipeGenerator, + floatingWindowProvider = { FloatingWindowService.getInstance() } + ) + + // Create PhoneAgent + val agentConfig = settingsManager.getAgentConfig() + _phoneAgent = PhoneAgent( + modelClient = modelClient, + actionHandler = _actionHandler!!, + screenshotService = _screenshotService!!, + config = agentConfig, + historyManager = historyManager + ) + + android.util.Log.i(TAG, "All service-dependent components initialized") + } + + /** + * Initializes components for accessibility mode. + * Creates a device controller when Accessibility Service is available. + */ + private fun initializeAccessibilityComponents() { + // Create AccessibilityDeviceController + _deviceController = AccessibilityDeviceController(context) + + // Create ScreenshotService for accessibility mode with proper screenshotProvider + // The screenshotProvider delegates to AccessibilityDeviceController for screenshots + _screenshotService = ScreenshotService( + userService = null, // No userService needed in accessibility mode + screenshotProvider = { _deviceController!!.captureScreen() }, + floatingWindowControllerProvider = { FloatingWindowService.getInstance() } + ) + + // Create ActionHandler with IDeviceController (AccessibilityDeviceController) + // No need for DeviceExecutor or TextInputManager, all operations go through the controller + _actionHandler = ActionHandler( + deviceController = _deviceController!!, appResolver = appResolver, swipeGenerator = swipeGenerator, - textInputManager = _textInputManager!!, floatingWindowProvider = { FloatingWindowService.getInstance() } ) - + // Create PhoneAgent val agentConfig = settingsManager.getAgentConfig() _phoneAgent = PhoneAgent( @@ -214,10 +289,15 @@ class ComponentManager private constructor(private val context: Context) { config = agentConfig, historyManager = historyManager ) - - Logger.i(TAG, "All service-dependent components initialized") + + // Check permission status and log accordingly + if (!_deviceController!!.checkPermission()) { + android.util.Log.w(TAG, "Accessibility service not yet connected, but components initialized for deferred startup") + } else { + android.util.Log.i(TAG, "Accessibility mode components initialized with service connected") + } } - + /** * Cleans up components that depend on UserService. */ @@ -228,68 +308,66 @@ class ComponentManager private constructor(private val context: Context) { _screenshotService = null _textInputManager = null _deviceExecutor = null - - Logger.i(TAG, "Service-dependent components cleaned up") + + android.util.Log.i(TAG, "Service-dependent components cleaned up") } - + /** * Reinitializes the PhoneAgent with updated configuration. * Call this after settings have been changed. - * - * Note: This will NOT reinitialize if a task is currently running or paused, - * to prevent accidentally cancelling user tasks. + * Handles mode switching between Shizuku and Accessibility modes. */ fun reinitializeAgent() { - if (userService == null) { - Logger.w(TAG, "Cannot reinitialize agent: UserService not connected") - return - } - - // Safety check: don't reinitialize while a task is active - _phoneAgent?.let { agent -> - if (agent.isRunning() || agent.isPaused()) { - Logger.w(TAG, "Cannot reinitialize agent: task is currently active (state: ${agent.getState()})") - return - } - } - - // Cancel any running task (should be IDLE at this point, but just in case) + val controlMode = settingsManager.getDeviceControlMode() + + // Cancel any running task _phoneAgent?.cancel() - + // Recreate model client with new config _modelClient = null - - // Recreate PhoneAgent - val agentConfig = settingsManager.getAgentConfig() - _phoneAgent = PhoneAgent( - modelClient = modelClient, - actionHandler = _actionHandler!!, - screenshotService = _screenshotService!!, - config = agentConfig, - historyManager = historyManager - ) - - Logger.i(TAG, "PhoneAgent reinitialized with new configuration") + + when (controlMode) { + DeviceControlMode.SHIZUKU -> { + if (userService == null) { + android.util.Log.w(TAG, "Cannot reinitialize agent: UserService not connected") + return + } + + // Re-initialize Shizuku components + cleanupServiceDependentComponents() + initializeServiceDependentComponents() + + android.util.Log.i(TAG, "PhoneAgent reinitialized for Shizuku mode") + } + + DeviceControlMode.ACCESSIBILITY -> { + // Re-initialize accessibility components + cleanupServiceDependentComponents() + initializeAccessibilityComponents() + + android.util.Log.i(TAG, "PhoneAgent reinitialized for Accessibility mode") + } + } } - + /** * Sets the listener for PhoneAgent events. - * + * * @param listener The listener to set */ fun setPhoneAgentListener(listener: PhoneAgentListener?) { _phoneAgent?.setListener(listener) } - + /** * Sets the confirmation callback for ActionHandler. - * + * * @param callback The callback to set */ fun setConfirmationCallback(callback: ActionHandler.ConfirmationCallback?) { _actionHandler?.setConfirmationCallback(callback) } - + /** * Checks if the model config has changed. */ @@ -300,20 +378,20 @@ class ComponentManager private constructor(private val context: Context) { } return changed } - + /** * Cleans up all components. * Should be called when the application is being destroyed. */ fun cleanup() { - Logger.i(TAG, "Cleaning up all components") + android.util.Log.i(TAG, "Cleaning up all components") cleanupServiceDependentComponents() _modelClient = null _appResolver = null _swipeGenerator = null currentModelConfig = null } - + /** * Gets the current state summary for debugging. */ diff --git a/app/src/main/java/com/kevinluo/autoglm/MainActivity.kt b/app/src/main/java/com/kevinluo/autoglm/MainActivity.kt index c1fe57d..53c6f92 100644 --- a/app/src/main/java/com/kevinluo/autoglm/MainActivity.kt +++ b/app/src/main/java/com/kevinluo/autoglm/MainActivity.kt @@ -1,28 +1,35 @@ package com.kevinluo.autoglm +import android.accessibilityservice.AccessibilityServiceInfo import android.content.ComponentName import android.content.Intent import android.content.ServiceConnection import android.content.pm.PackageManager import android.graphics.drawable.GradientDrawable +import android.os.Build import android.os.Bundle import android.os.IBinder +import android.provider.Settings import android.view.View +import android.view.accessibility.AccessibilityManager import android.widget.Button import android.widget.ImageButton import android.widget.TextView import android.widget.Toast -import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat +import androidx.core.content.PermissionChecker import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.lifecycle.lifecycleScope +import com.kevinluo.autoglm.accessibility.AutoGLMAccessibilityService import com.kevinluo.autoglm.action.ActionHandler import com.kevinluo.autoglm.action.AgentAction import com.kevinluo.autoglm.agent.PhoneAgent import com.kevinluo.autoglm.agent.PhoneAgentListener +import com.kevinluo.autoglm.device.DeviceControlMode import com.kevinluo.autoglm.settings.SettingsActivity import com.kevinluo.autoglm.ui.FloatingWindowService import com.kevinluo.autoglm.ui.TaskStatus @@ -50,10 +57,12 @@ import rikka.shizuku.Shizuku * The activity implements [PhoneAgentListener] to receive callbacks * during task execution for UI updates. * + * Requirements: 1.1, 2.1, 2.2 */ class MainActivity : AppCompatActivity(), PhoneAgentListener { // Shizuku status views + private lateinit var shizukuCard: View private lateinit var statusText: TextView private lateinit var shizukuStatusIndicator: View private lateinit var shizukuButtonsRow: View @@ -73,6 +82,12 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { private lateinit var keyboardStatusText: TextView private lateinit var enableKeyboardBtn: Button + // Accessibility permission views + private lateinit var accessibilityCard: View + private lateinit var accessibilityStatusIcon: android.widget.ImageView + private lateinit var accessibilityStatusText: TextView + private lateinit var requestAccessibilityBtn: Button + // Task input views private lateinit var taskInputLayout: TextInputLayout private lateinit var taskInput: TextInputEditText @@ -88,7 +103,22 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { // Component manager for dependency injection private lateinit var componentManager: ComponentManager - + + private val requestNotificationPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted: Boolean -> + if (isGranted) { + Logger.i(TAG, "Notification permission granted") + } else { + Logger.w(TAG, "Notification permission denied") + Toast.makeText( + this, + R.string.toast_notification_permission_required, + Toast.LENGTH_LONG + ).show() + } + } + // Current step tracking for floating window private var currentStepNumber = 0 private var currentThinking = "" @@ -108,26 +138,26 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { val userService = IUserService.Stub.asInterface(service) Logger.i(TAG, "UserService connected") - + // Notify ComponentManager componentManager.onServiceConnected(userService) - + runOnUiThread { Toast.makeText(this@MainActivity, R.string.toast_user_service_connected, Toast.LENGTH_SHORT).show() - updateShizukuStatus() + updateDeviceServiceStatus() initializePhoneAgent() } } override fun onServiceDisconnected(name: ComponentName?) { Logger.i(TAG, "UserService disconnected") - + // Notify ComponentManager componentManager.onServiceDisconnected() - + runOnUiThread { Toast.makeText(this@MainActivity, R.string.toast_user_service_disconnected, Toast.LENGTH_SHORT).show() - updateShizukuStatus() + updateDeviceServiceStatus() updateTaskButtonStates() } } @@ -137,7 +167,7 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { Shizuku.OnRequestPermissionResultListener { requestCode, grantResult -> if (requestCode == SHIZUKU_PERMISSION_REQUEST_CODE) { if (grantResult == PackageManager.PERMISSION_GRANTED) { - updateShizukuStatus() + updateDeviceServiceStatus() bindUserService() Toast.makeText(this, R.string.toast_shizuku_permission_granted, Toast.LENGTH_SHORT).show() } else { @@ -147,7 +177,7 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { } private val binderReceivedListener = Shizuku.OnBinderReceivedListener { - updateShizukuStatus() + updateDeviceServiceStatus() if (hasShizukuPermission()) { bindUserService() } @@ -156,35 +186,82 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { private val binderDeadListener = Shizuku.OnBinderDeadListener { Logger.w(TAG, "Shizuku binder died") componentManager.onServiceDisconnected() - updateShizukuStatus() + updateDeviceServiceStatus() updateTaskButtonStates() } + // Accessibility state change listener + private val accessibilityStateChangeListener = + AccessibilityManager.AccessibilityStateChangeListener { enabled -> + Logger.d(TAG, "Accessibility state changed: enabled=$enabled") + runOnUiThread { + updateAccessibilityStatus() + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - enableEdgeToEdge() setContentView(R.layout.activity_main) - ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - insets - } - // Initialize ComponentManager componentManager = ComponentManager.getInstance(this) Logger.i(TAG, "ComponentManager initialized") - + + // Log all launchable apps at startup + logAllLaunchableApps() + initViews() setupListeners() + checkAndRequestNotificationPermission() setupShizukuListeners() - - updateShizukuStatus() + setupAccessibilityListener() + + // For accessibility mode, initialize components immediately + val controlMode = componentManager.settingsManager.getDeviceControlMode() + if (controlMode == DeviceControlMode.ACCESSIBILITY) { + componentManager.reinitializeAgent() + } + + updateDeviceServiceStatus() updateOverlayPermissionStatus() updateKeyboardStatus() + updateAccessibilityStatus() updateTaskStatus(TaskStatus.IDLE) + updateTaskButtonStates() } - + + /** + * Logs all launchable apps for debugging purposes. + * + * Queries the package manager for all apps with launcher activities + * and logs them for debugging. Only logs the first 20 apps to avoid + * excessive log output. + */ + private fun logAllLaunchableApps() { + // Query apps without loading icons to avoid excessive logging + val intent = android.content.Intent(android.content.Intent.ACTION_MAIN).apply { + addCategory(android.content.Intent.CATEGORY_LAUNCHER) + } + val resolveInfoList = packageManager.queryIntentActivities(intent, 0) + + val apps = resolveInfoList.mapNotNull { resolveInfo -> + val activityInfo = resolveInfo.activityInfo ?: return@mapNotNull null + val displayName = resolveInfo.loadLabel(packageManager)?.toString() ?: return@mapNotNull null + val packageName = activityInfo.packageName ?: return@mapNotNull null + displayName to packageName + }.distinctBy { it.second } + + Logger.i(TAG, "=== All Launchable Apps: ${apps.size} total ===") + // Only log first 20 apps to avoid log quota + apps.take(20).forEach { (name, pkg) -> + Logger.i(TAG, " $name -> $pkg") + } + if (apps.size > 20) { + Logger.i(TAG, " ... and ${apps.size - 20} more apps") + } + Logger.i(TAG, "=== End of App List ===") + } + /** * Updates the overlay permission status display. * @@ -193,7 +270,7 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { */ private fun updateOverlayPermissionStatus() { val hasPermission = FloatingWindowService.canDrawOverlays(this) - + if (hasPermission) { overlayStatusText.text = getString(R.string.overlay_permission_granted) overlayStatusIcon.setColorFilter(ContextCompat.getColor(this, R.color.status_running)) @@ -204,7 +281,7 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { requestOverlayBtn.visibility = View.VISIBLE } } - + /** * Updates the keyboard status display. * @@ -212,7 +289,7 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { */ private fun updateKeyboardStatus() { val status = com.kevinluo.autoglm.input.KeyboardHelper.getAutoGLMKeyboardStatus(this) - + when (status) { com.kevinluo.autoglm.input.KeyboardHelper.KeyboardStatus.ENABLED -> { keyboardStatusText.text = getString(R.string.keyboard_settings_subtitle) @@ -228,16 +305,145 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { } } + /** + * Updates the accessibility service status display. + * + * Checks if AutoGLM Accessibility Service is enabled and updates the UI accordingly. + * Also shows/hides relevant permission cards based on the device control mode. + */ + private fun updateAccessibilityStatus() { + val controlMode = componentManager.settingsManager.getDeviceControlMode() + + // Show/hide relevant cards based on control mode + when (controlMode) { + DeviceControlMode.SHIZUKU -> { + // Shizuku mode: show Shizuku card and keyboard card, hide accessibility card + shizukuCard.visibility = View.VISIBLE + keyboardCard.visibility = View.VISIBLE + accessibilityCard.visibility = View.GONE + } + DeviceControlMode.ACCESSIBILITY -> { + // Accessibility mode: hide Shizuku card and keyboard card, show accessibility card + shizukuCard.visibility = View.GONE + keyboardCard.visibility = View.GONE + accessibilityCard.visibility = View.VISIBLE + + // Update accessibility status + val isAccessibilityEnabled = AutoGLMAccessibilityService.isEnabled() + + if (isAccessibilityEnabled) { + accessibilityStatusText.text = getString(R.string.accessibility_permission_granted) + accessibilityStatusIcon.setColorFilter(ContextCompat.getColor(this, R.color.status_running)) + requestAccessibilityBtn.visibility = View.GONE + } else { + accessibilityStatusText.text = getString(R.string.accessibility_permission_denied) + accessibilityStatusIcon.setColorFilter(ContextCompat.getColor(this, R.color.status_waiting)) + requestAccessibilityBtn.visibility = View.VISIBLE + requestAccessibilityBtn.text = getString(R.string.request_accessibility_permission) + } + + // Check Android version for accessibility support + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + accessibilityStatusText.text = getString(R.string.accessibility_not_supported) + requestAccessibilityBtn.visibility = View.GONE + } + } + } + + // Also update the main status display to reflect the current mode + updateDeviceServiceStatus() + } + + /** + * Attempts to automatically enable AutoGLM Accessibility Service when in accessibility mode + * and the app has WRITE_SECURE_SETTINGS permission. + * + * This writes the component into Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES and sets + * Settings.Secure.ACCESSIBILITY_ENABLED to 1. Requires privileged permission. + */ + private fun tryAutoEnableAccessibilityService() { + val controlMode = componentManager.settingsManager.getDeviceControlMode() + if (controlMode != DeviceControlMode.ACCESSIBILITY) return + + // Already enabled + if (AutoGLMAccessibilityService.isEnabled()) return + + // Check WRITE_SECURE_SETTINGS permission + val hasWss = PermissionChecker.checkSelfPermission( + this, + android.Manifest.permission.WRITE_SECURE_SETTINGS + ) == PermissionChecker.PERMISSION_GRANTED + + if (!hasWss) { + Logger.d(TAG, "WRITE_SECURE_SETTINGS not granted; skip auto-enable") + return + } + + val component = ComponentName(this, AutoGLMAccessibilityService::class.java) + val flattened = component.flattenToString() + try { + val current = Settings.Secure.getString( + contentResolver, + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES + ) + val items = current?.split(":")?.filter { it.isNotBlank() }?.toMutableSet() ?: mutableSetOf() + if (!items.contains(flattened)) { + items.add(flattened) + } + val newValue = items.joinToString(":") + val updated = Settings.Secure.putString( + contentResolver, + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, + newValue + ) + val enabled = Settings.Secure.putInt( + contentResolver, + Settings.Secure.ACCESSIBILITY_ENABLED, + 1 + ) + Logger.i( + TAG, + "Auto-enable accessibility attempted: updated=$updated enabledSet=$enabled" + ) + } catch (e: Exception) { + Logger.e(TAG, "Failed to auto-enable accessibility service", e) + } + + // Refresh UI after attempt + updateAccessibilityStatus() + } + + /** + * Checks and requests notification permission on Android 13+. + */ + private fun checkAndRequestNotificationPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission( + this, + android.Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + requestNotificationPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) + } + } + } + override fun onResume() { super.onResume() Logger.d(TAG, "onResume - checking for settings changes") - + // Update overlay permission status (user may have granted it) updateOverlayPermissionStatus() - + // Update keyboard status (user may have enabled it) updateKeyboardStatus() - + + // Update accessibility status (user may have enabled it or mode changed) + updateAccessibilityStatus() + + // Attempt auto-enable accessibility if applicable + tryAutoEnableAccessibilityService() + // Re-setup floating window callbacks if service is running FloatingWindowService.getInstance()?.let { service -> service.setStopTaskCallback { @@ -260,36 +466,37 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { resumeTask() } } - - // Only reinitialize if service is connected and we need to refresh + + // Check if settings actually changed before reinitializing + if (componentManager.settingsManager.hasConfigChanged()) { + componentManager.reinitializeAgent() + } + + // Update button states if (componentManager.isServiceConnected) { - // Check if settings actually changed before reinitializing - // But NEVER reinitialize while a task is running or paused - this would cancel the task! - val isTaskActive = componentManager.phoneAgent?.let { - it.isRunning() || it.isPaused() - } ?: false - - if (!isTaskActive && componentManager.settingsManager.hasConfigChanged()) { - componentManager.reinitializeAgent() - } componentManager.setPhoneAgentListener(this) setupConfirmationCallback() - updateTaskButtonStates() } + updateTaskButtonStates() } override fun onDestroy() { Logger.i(TAG, "onDestroy - cleaning up") super.onDestroy() - + // Remove Shizuku listeners Shizuku.removeRequestPermissionResultListener(onRequestPermissionResultListener) Shizuku.removeBinderReceivedListener(binderReceivedListener) Shizuku.removeBinderDeadListener(binderDeadListener) + // Remove accessibility state change listener + (getSystemService(ACCESSIBILITY_SERVICE) as? AccessibilityManager)?.removeAccessibilityStateChangeListener( + accessibilityStateChangeListener + ) + // Cancel any running task componentManager.phoneAgent?.cancel() - + // Unbind user service if (componentManager.isServiceConnected) { try { @@ -298,7 +505,7 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { Logger.e(TAG, "Error unbinding user service", e) } } - + // Note: Don't stop FloatingWindowService here - it should run independently // The service will be stopped when user explicitly closes it or the app process is killed } @@ -310,6 +517,7 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { */ private fun initViews() { // Shizuku status views + shizukuCard = findViewById(R.id.shizukuCard) statusText = findViewById(R.id.statusText) shizukuStatusIndicator = findViewById(R.id.shizukuStatusIndicator) shizukuButtonsRow = findViewById(R.id.shizukuButtonsRow) @@ -329,6 +537,12 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { keyboardStatusText = findViewById(R.id.keyboardStatusText) enableKeyboardBtn = findViewById(R.id.enableKeyboardBtn) + // Accessibility permission views + accessibilityCard = findViewById(R.id.accessibilityCard) + accessibilityStatusIcon = findViewById(R.id.accessibilityStatusIcon) + accessibilityStatusText = findViewById(R.id.accessibilityStatusText) + requestAccessibilityBtn = findViewById(R.id.requestAccessibilityBtn) + // Task input views taskInputLayout = findViewById(R.id.taskInputLayout) taskInput = findViewById(R.id.taskInput) @@ -359,7 +573,7 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { openShizukuApp() } - // Settings button + // Settings button - Requirements: 6.1 settingsBtn.setOnClickListener { startActivity(Intent(this, SettingsActivity::class.java)) } @@ -385,16 +599,23 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { com.kevinluo.autoglm.input.KeyboardHelper.openInputMethodSettings(this) } - // Start task button + // Accessibility permission button + requestAccessibilityBtn.setOnClickListener { + // Open accessibility settings + val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) + startActivity(intent) + } + + // Start task button - Requirements: 1.1 startTaskBtn.setOnClickListener { startTask() } - // Cancel task button + // Cancel task button - Requirements: 1.4 cancelTaskBtn.setOnClickListener { cancelTask() } - + // Select template button btnSelectTemplate.setOnClickListener { showTemplateSelectionDialog() @@ -404,7 +625,7 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { taskInput.setOnFocusChangeListener { _, _ -> updateTaskButtonStates() } - + // Watch for text changes to enable/disable start button taskInput.addTextChangedListener(object : android.text.TextWatcher { override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} @@ -541,7 +762,17 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { Shizuku.addBinderReceivedListenerSticky(binderReceivedListener) Shizuku.addBinderDeadListener(binderDeadListener) } - + + /** + * Sets up accessibility state change listener. + * + * Registers listener to detect when accessibility service is enabled/disabled. + */ + private fun setupAccessibilityListener() { + val accessibilityManager = getSystemService(ACCESSIBILITY_SERVICE) as? AccessibilityManager + accessibilityManager?.addAccessibilityStateChangeListener(accessibilityStateChangeListener) + } + /** * Opens the Shizuku app or Play Store if not installed. * @@ -573,23 +804,24 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { * Called after UserService is connected. Sets up the agent listener * and confirmation callback for sensitive operations. * + * Requirements: 1.1, 2.1, 2.2 */ private fun initializePhoneAgent() { if (!componentManager.isServiceConnected) { Logger.w(TAG, "Cannot initialize PhoneAgent: service not connected") return } - + // Set up listener componentManager.setPhoneAgentListener(this) - + // Setup confirmation callback setupConfirmationCallback() - + updateTaskButtonStates() Logger.i(TAG, "PhoneAgent initialized successfully") } - + /** * Sets up the confirmation callback for sensitive operations. * @@ -635,31 +867,50 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { * Validates the task description, checks agent state, starts the * floating window service, and launches the task in a coroutine. * + * Requirements: 1.1, 2.1, 2.2 */ private fun startTask() { val taskDescription = taskInput.text?.toString()?.trim() ?: "" - + // Validate task description if (taskDescription.isBlank()) { Toast.makeText(this, R.string.toast_task_empty, Toast.LENGTH_SHORT).show() taskInputLayout.error = getString(R.string.toast_task_empty) return } - + taskInputLayout.error = null - + val agent = componentManager.phoneAgent if (agent == null) { Logger.e(TAG, "PhoneAgent not initialized") + Toast.makeText(this, "Device controller not initialized. Please check your settings.", Toast.LENGTH_SHORT).show() return } - + // Check if already running if (agent.isRunning()) { Logger.w(TAG, "Task already running") return } - + + // Check device controller permissions + val deviceController = componentManager.deviceControllerInstance + if (deviceController != null && !deviceController.checkPermission()) { + val mode = deviceController.getMode() + val message = when (mode) { + com.kevinluo.autoglm.device.DeviceControlMode.ACCESSIBILITY -> { + "Accessibility service not enabled. Please enable it in system settings." + } + com.kevinluo.autoglm.device.DeviceControlMode.SHIZUKU -> { + "Shizuku service not connected. Please ensure Shizuku is running." + } + } + Toast.makeText(this, message, Toast.LENGTH_LONG).show() + deviceController.requestPermission(this) + return + } + // Start floating window service if overlay permission granted if (FloatingWindowService.canDrawOverlays(this)) { Logger.d(TAG, "startTask: Starting floating window service") @@ -669,7 +920,7 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { FloatingWindowService.requestOverlayPermission(this) return } - + // Update UI state - manually set running state since agent.run() hasn't started yet updateTaskStatus(TaskStatus.RUNNING) // Manually update UI for running state @@ -677,9 +928,9 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { runningSection.visibility = View.VISIBLE cancelTaskBtn.isEnabled = true taskInput.isEnabled = false - + Logger.i(TAG, "Starting task: $taskDescription") - + // Run task in coroutine lifecycleScope.launch { // Set up callbacks immediately after service starts @@ -711,15 +962,15 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { floatingWindow?.updateStatus(TaskStatus.RUNNING) floatingWindow?.show() } - + // Minimize app to let agent work withContext(Dispatchers.Main) { moveTaskToBack(true) } - + try { val result = agent.run(taskDescription) - + withContext(Dispatchers.Main) { if (result.success) { Logger.i(TAG, "Task completed: ${result.message}") @@ -746,26 +997,27 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { * Cancels the agent, resets its state, and updates the UI * to reflect the cancelled status. * + * Requirements: 1.1, 2.1, 2.2 */ private fun cancelTask() { Logger.i(TAG, "Cancelling task") - + // Cancel the agent - this will cancel any ongoing network requests componentManager.phoneAgent?.cancel() - + // Reset the agent state so it can accept new tasks componentManager.phoneAgent?.reset() - + Toast.makeText(this, R.string.toast_task_cancelled, Toast.LENGTH_SHORT).show() updateTaskStatus(TaskStatus.FAILED) updateTaskButtonStates() - + // Update floating window to show cancelled state // Use the same message as PhoneAgent for consistency val cancellationMessage = PhoneAgent.CANCELLATION_MESSAGE FloatingWindowService.getInstance()?.showResult(cancellationMessage, false) } - + /** * Pauses the currently running task. * @@ -773,14 +1025,14 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { */ private fun pauseTask() { Logger.i(TAG, "Pausing task") - + val paused = componentManager.phoneAgent?.pause() == true if (paused) { updateTaskStatus(TaskStatus.PAUSED) FloatingWindowService.getInstance()?.updateStatus(TaskStatus.PAUSED) } } - + /** * Resumes a paused task. * @@ -788,7 +1040,7 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { */ private fun resumeTask() { Logger.i(TAG, "Resuming task") - + val resumed = componentManager.phoneAgent?.resume() == true if (resumed) { updateTaskStatus(TaskStatus.RUNNING) @@ -807,15 +1059,15 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { val hasAgent = componentManager.phoneAgent != null val hasTaskText = !taskInput.text.isNullOrBlank() val isRunning = componentManager.phoneAgent?.isRunning() == true - + // Show/hide sections based on running state startTaskBtn.visibility = if (isRunning) View.GONE else View.VISIBLE runningSection.visibility = if (isRunning) View.VISIBLE else View.GONE - + startTaskBtn.isEnabled = hasService && hasAgent && hasTaskText && !isRunning cancelTaskBtn.isEnabled = isRunning taskInput.isEnabled = !isRunning - + Logger.d( TAG, "Button states updated: service=$hasService, agent=$hasAgent, " + @@ -831,6 +1083,7 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { * * @param status The new task status to display * + * Requirements: 1.1, 2.1, 2.2 */ private fun updateTaskStatus(status: TaskStatus) { val (text, colorRes) = when (status) { @@ -842,14 +1095,14 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { TaskStatus.WAITING_CONFIRMATION -> "等待确认" to R.color.status_waiting TaskStatus.WAITING_TAKEOVER -> "等待接管" to R.color.status_waiting } - + taskStatusText.text = text - + val drawable = taskStatusIndicator.background as? GradientDrawable ?: GradientDrawable().also { taskStatusIndicator.background = it } drawable.shape = GradientDrawable.OVAL drawable.setColor(ContextCompat.getColor(this, colorRes)) - + // Also update floating window FloatingWindowService.getInstance()?.updateStatus(status) } @@ -863,6 +1116,7 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { * * @param stepNumber The current step number * + * Requirements: 1.1, 2.1, 2.2 */ override fun onStepStarted(stepNumber: Int) { runOnUiThread { @@ -880,6 +1134,7 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { * * @param thinking The model's thinking text * + * Requirements: 1.1, 2.1, 2.2 */ override fun onThinkingUpdate(thinking: String) { runOnUiThread { @@ -894,6 +1149,7 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { * * @param action The action that was executed * + * Requirements: 1.1, 2.1, 2.2 */ override fun onActionExecuted(action: AgentAction) { runOnUiThread { @@ -910,6 +1166,7 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { * * @param message The completion message * + * Requirements: 1.1, 2.1, 2.2 */ override fun onTaskCompleted(message: String) { runOnUiThread { @@ -928,6 +1185,7 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { * * @param error The error message * + * Requirements: 1.1, 2.1, 2.2 */ override fun onTaskFailed(error: String) { runOnUiThread { @@ -943,6 +1201,7 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { * * Note: Floating window hide is handled by ScreenshotService. * + * Requirements: 1.1, 2.1, 2.2 */ override fun onScreenshotStarted() { // Floating window hide is handled by ScreenshotService @@ -953,6 +1212,7 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { * * Note: Floating window show is handled by ScreenshotService. * + * Requirements: 1.1, 2.1, 2.2 */ override fun onScreenshotCompleted() { // Floating window show is handled by ScreenshotService @@ -972,7 +1232,22 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { // endregion - // region Shizuku Methods + // region Device Service Status Methods + + /** + * Updates the device service status display based on current control mode. + * + * For Shizuku mode: shows Shizuku connection status. + * For Accessibility mode: shows Accessibility service status. + */ + private fun updateDeviceServiceStatus() { + val controlMode = componentManager.settingsManager.getDeviceControlMode() + + when (controlMode) { + DeviceControlMode.SHIZUKU -> updateShizukuStatusDisplay() + DeviceControlMode.ACCESSIBILITY -> updateAccessibilityStatusDisplay() + } + } /** * Updates the Shizuku connection status display. @@ -980,7 +1255,7 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { * Checks Shizuku binder status, permission, and service connection, * then updates the UI to reflect the current state. */ - private fun updateShizukuStatus() { + private fun updateShizukuStatusDisplay() { val isBinderAlive = try { Shizuku.pingBinder() } catch (e: Exception) { @@ -996,7 +1271,7 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { !serviceConnected -> getString(R.string.shizuku_status_connecting) else -> getString(R.string.shizuku_status_connected) } - + val statusColor = when { !isBinderAlive -> R.color.status_failed !hasPermission -> R.color.status_waiting @@ -1007,7 +1282,7 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { runOnUiThread { statusText.text = statusMessage shizukuStatusIndicator.background.setTint(getColor(statusColor)) - + // Show buttons based on Shizuku state if (serviceConnected) { // Connected - hide buttons row @@ -1021,7 +1296,40 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { requestPermissionBtn.visibility = View.VISIBLE requestPermissionBtn.isEnabled = isBinderAlive } - + + updateTaskButtonStates() + } + } + + /** + * Updates the Accessibility service status display. + * + * Shows the accessibility service connection status in the same location + * where Shizuku status is normally displayed. + */ + private fun updateAccessibilityStatusDisplay() { + val isAccessibilityEnabled = AutoGLMAccessibilityService.isEnabled() + val isAndroidSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + + val statusMessage = when { + !isAndroidSupported -> getString(R.string.accessibility_not_supported) + !isAccessibilityEnabled -> getString(R.string.accessibility_permission_denied) + else -> getString(R.string.accessibility_permission_granted) + } + + val statusColor = when { + !isAndroidSupported -> R.color.status_failed + !isAccessibilityEnabled -> R.color.status_waiting + else -> R.color.status_running + } + + runOnUiThread { + statusText.text = statusMessage + shizukuStatusIndicator.background.setTint(getColor(statusColor)) + + // Hide Shizuku buttons in accessibility mode + shizukuButtonsRow.visibility = View.GONE + updateTaskButtonStates() } } @@ -1098,9 +1406,9 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { private fun formatAction(action: AgentAction): String = action.formatForDisplay() // endregion - + // region Task Templates - + /** * Shows a dialog to select a task template. * @@ -1109,7 +1417,7 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { */ private fun showTemplateSelectionDialog() { val templates = componentManager.settingsManager.getTaskTemplates() - + if (templates.isEmpty()) { Toast.makeText(this, R.string.settings_no_templates, Toast.LENGTH_SHORT).show() // Offer to go to settings to add templates @@ -1123,9 +1431,9 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { .show() return } - + val templateNames = templates.map { it.name }.toTypedArray() - + AlertDialog.Builder(this) .setTitle(R.string.task_select_template) .setItems(templateNames) { _, which -> @@ -1136,7 +1444,7 @@ class MainActivity : AppCompatActivity(), PhoneAgentListener { .setNegativeButton(R.string.dialog_cancel, null) .show() } - + // endregion companion object { diff --git a/app/src/main/java/com/kevinluo/autoglm/UserService.kt b/app/src/main/java/com/kevinluo/autoglm/UserService.kt index 909022f..9bac2ee 100644 --- a/app/src/main/java/com/kevinluo/autoglm/UserService.kt +++ b/app/src/main/java/com/kevinluo/autoglm/UserService.kt @@ -9,6 +9,8 @@ import java.io.InputStreamReader * This service runs in a separate process with Shizuku permissions. */ class UserService : IUserService.Stub() { + // Internal buffer to store base64-encoded screenshot data + private var screenshotBase64: String? = null /** * Destroys the service and exits the process. @@ -57,6 +59,63 @@ class UserService : IUserService.Stub() { } } + /** + * Captures a screenshot and stores base64 data in memory. + * Returns the base64 string length, or -1 on failure. + */ + override fun captureScreenshotAndGetSize(): Int { + return try { + val process = Runtime.getRuntime().exec(arrayOf("sh", "-c", "screencap -p | base64")) + val reader = BufferedReader(InputStreamReader(process.inputStream)) + val errorReader = BufferedReader(InputStreamReader(process.errorStream)) + + val sb = StringBuilder() + var line: String? + // Read without preserving newlines to avoid wrapping issues + while (reader.readLine().also { line = it } != null) { + sb.append(line) + } + + val errorOutput = StringBuilder() + while (errorReader.readLine().also { line = it } != null) { + errorOutput.append(line).append('\n') + } + + val exitCode = process.waitFor() + reader.close() + errorReader.close() + + if (exitCode != 0 || errorOutput.isNotEmpty()) { + // Failure, clear buffer + screenshotBase64 = null + return -1 + } + + screenshotBase64 = sb.toString() + screenshotBase64?.length ?: -1 + } catch (e: Exception) { + screenshotBase64 = null + -1 + } + } + + /** + * Reads a chunk from the in-memory base64 screenshot. + */ + override fun readScreenshotChunk(offset: Int, size: Int): String { + val data = screenshotBase64 ?: return "" + if (offset < 0 || size <= 0 || offset >= data.length) return "" + val end = kotlin.math.min(offset + size, data.length) + return data.substring(offset, end) + } + + /** + * Clears the in-memory screenshot buffer. + */ + override fun clearScreenshotBuffer() { + screenshotBase64 = null + } + companion object { private const val TAG = "UserService" } diff --git a/app/src/main/java/com/kevinluo/autoglm/accessibility/AutoGLMAccessibilityService.kt b/app/src/main/java/com/kevinluo/autoglm/accessibility/AutoGLMAccessibilityService.kt new file mode 100644 index 0000000..f776a68 --- /dev/null +++ b/app/src/main/java/com/kevinluo/autoglm/accessibility/AutoGLMAccessibilityService.kt @@ -0,0 +1,345 @@ +package com.kevinluo.autoglm.accessibility + +import android.accessibilityservice.AccessibilityService +import android.accessibilityservice.GestureDescription +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Path +import android.os.Build +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo + +import com.kevinluo.autoglm.util.Logger +import kotlinx.coroutines.suspendCancellableCoroutine +import java.util.concurrent.atomic.AtomicReference +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * Accessibility Service for device control without Shizuku. + * + * This service provides: + * - Screen capture using takeScreenshot() API (Android 13+) + * - Touch operations via dispatchGesture() + * - Text input via AccessibilityNodeInfo + * - Key press simulation + * - App launching and info retrieval + * + * Requirements: Android 13+ (API 33) for screenshot functionality + */ +class AutoGLMAccessibilityService : AccessibilityService() { + + companion object { + private const val TAG = "AutoGLMAccessibilityService" + + // Gesture timing constants + private const val TAP_DURATION_MS = 100L + private const val DOUBLE_TAP_INTERVAL_MS = 100L + + // Android KeyEvent keycodes + const val KEYCODE_BACK = 4 + const val KEYCODE_HOME = 3 + const val KEYCODE_RECENTS = 187 + const val KEYCODE_NOTIFICATIONS = 4 + const val KEYCODE_QUICK_SETTINGS = 5 + + @Volatile + private var instance: AutoGLMAccessibilityService? = null + + /** + * Gets the singleton instance of the accessibility service. + * + * @return The service instance, or null if not connected + */ + fun getInstance(): AutoGLMAccessibilityService? = instance + + /** + * Checks if the accessibility service is enabled. + * + * @return true if the service is running, false otherwise + */ + fun isEnabled(): Boolean = instance != null + } + + private val serviceState = AtomicReference(ServiceState.IDLE) + + private enum class ServiceState { + IDLE, CONNECTING, CONNECTED, DISCONNECTED + } + + override fun onServiceConnected() { + super.onServiceConnected() + Logger.i(TAG, "AccessibilityService connected") + instance = this + serviceState.set(ServiceState.CONNECTED) + } + + override fun onAccessibilityEvent(event: AccessibilityEvent?) { + // Handle accessibility events if needed + // Currently not used, but can be used for detecting UI changes + } + + override fun onInterrupt() { + Logger.w(TAG, "AccessibilityService interrupted") + } + + override fun onDestroy() { + super.onDestroy() + Logger.i(TAG, "AccessibilityService destroyed") + instance = null + serviceState.set(ServiceState.DISCONNECTED) + } + + // ==================== Screenshot ==================== + + /** + * Captures the current screen content. + * + * Uses AccessibilityService.takeScreenshot() API available on Android 13+. + * + * @return Bitmap of the screen, or null if capture failed + */ + suspend fun captureScreen(): Bitmap? = suspendCancellableCoroutine { continuation -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + try { + Logger.d(TAG, "Starting screenshot capture via AccessibilityService.takeScreenshot()") + takeScreenshot(/* displayId= */ 0, /* executor= */ mainExecutor, + object : TakeScreenshotCallback { + override fun onSuccess(screenshot: ScreenshotResult) { + try { + Logger.d(TAG, "Screenshot callback: onSuccess") + val hardwareBuffer = screenshot.hardwareBuffer + val bitmap = Bitmap.wrapHardwareBuffer(hardwareBuffer, screenshot.colorSpace) + if (bitmap != null) { + Logger.d(TAG, "Successfully wrapped HardwareBuffer to Bitmap: ${bitmap.width}x${bitmap.height}") + continuation.resume(bitmap) + } else { + Logger.w(TAG, "Failed to wrap HardwareBuffer to Bitmap") + continuation.resume(null) + } + hardwareBuffer.close() + } catch (e: Exception) { + Logger.e(TAG, "Failed to process screenshot result", e) + continuation.resume(null) + } + } + + override fun onFailure(error: Int) { + Logger.e(TAG, "Screenshot callback: onFailure with error code: $error") + continuation.resume(null) + } + }) + } catch (e: SecurityException) { + Logger.e(TAG, "SecurityException: Service doesn't have screenshot capability. " + + "Please ensure: 1) Accessibility service is enabled 2) accessibility_service_config.xml has canTakeScreenshot=true 3) Device is Android 13+", e) + continuation.resume(null) + } catch (e: Exception) { + Logger.e(TAG, "Failed to call takeScreenshot", e) + continuation.resume(null) + } + } else { + Logger.w(TAG, "Screenshot not supported on Android < 13 (current: ${Build.VERSION.SDK_INT})") + continuation.resume(null) + } + } + + // ==================== Touch Operations ==================== + + /** + * Performs a tap gesture at the specified coordinates. + * + * @param x X coordinate in pixels + * @param y Y coordinate in pixels + * @return true if gesture was dispatched successfully + */ + suspend fun tap(x: Int, y: Int): Boolean { + val path = Path().apply { + moveTo(x.toFloat(), y.toFloat()) + } + + val gesture = GestureDescription.Builder() + .addStroke(GestureDescription.StrokeDescription(path, 0, TAP_DURATION_MS)) + .build() + + return dispatchGesture(gesture, null, null) + } + + /** + * Performs a double tap gesture at the specified coordinates. + * + * @param x X coordinate in pixels + * @param y Y coordinate in pixels + * @return true if both taps were dispatched successfully + */ + suspend fun doubleTap(x: Int, y: Int): Boolean { + val firstTap = tap(x, y) + kotlinx.coroutines.delay(DOUBLE_TAP_INTERVAL_MS) + val secondTap = tap(x, y) + return firstTap && secondTap + } + + /** + * Performs a long press gesture at the specified coordinates. + * + * @param x X coordinate in pixels + * @param y Y coordinate in pixels + * @param durationMs Duration of the long press in milliseconds + * @return true if gesture was dispatched successfully + */ + suspend fun longPress(x: Int, y: Int, durationMs: Int): Boolean { + val path = Path().apply { + moveTo(x.toFloat(), y.toFloat()) + } + + val gesture = GestureDescription.Builder() + .addStroke(GestureDescription.StrokeDescription(path, 0, durationMs.toLong())) + .build() + + return dispatchGesture(gesture, null, null) + } + + /** + * Performs a swipe gesture from start to end coordinates. + * + * @param startX Start X coordinate in pixels + * @param startY Start Y coordinate in pixels + * @param endX End X coordinate in pixels + * @param endY End Y coordinate in pixels + * @param durationMs Duration of the swipe in milliseconds + * @return true if gesture was dispatched successfully + */ + suspend fun swipe(startX: Int, startY: Int, endX: Int, endY: Int, durationMs: Int): Boolean { + val path = Path().apply { + moveTo(startX.toFloat(), startY.toFloat()) + lineTo(endX.toFloat(), endY.toFloat()) + } + + val gesture = GestureDescription.Builder() + .addStroke(GestureDescription.StrokeDescription(path, 0, durationMs.toLong())) + .build() + + return dispatchGesture(gesture, null, null) + } + + // ==================== Key Press ==================== + + /** + * Performs a key press event. + * + * Uses performGlobalAction for system keys and fallback to dispatchKeyEvent. + * + * @param keyCode Android KeyEvent keycode + * @return true if key press was successful + */ + fun pressKey(keyCode: Int): Boolean { + return when (keyCode) { + KEYCODE_BACK -> performGlobalAction(GLOBAL_ACTION_BACK) + KEYCODE_HOME -> performGlobalAction(GLOBAL_ACTION_HOME) + KEYCODE_RECENTS -> performGlobalAction(GLOBAL_ACTION_RECENTS) + KEYCODE_NOTIFICATIONS -> performGlobalAction(GLOBAL_ACTION_NOTIFICATIONS) + KEYCODE_QUICK_SETTINGS -> performGlobalAction(GLOBAL_ACTION_QUICK_SETTINGS) + else -> { + Logger.w(TAG, "Key code $keyCode not supported via accessibility") + false + } + } + } + + // ==================== Text Input ==================== + + /** + * Inputs text into the currently focused editable field. + * + * @param text The text to input + * @return true if text was entered successfully + */ + fun inputText(text: String): Boolean { + val rootNode = rootInActiveWindow ?: run { + Logger.w(TAG, "No active window root node") + return false + } + + val focusedNode = findFocusedEditableNode(rootNode) ?: run { + Logger.w(TAG, "No focused editable node found") + return false + } + + return try { + // Clear existing text + focusedNode.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, createSetTextBundle("")) + + // Set new text + focusedNode.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, createSetTextBundle(text)) + + Logger.d(TAG, "Text input successful: '${text.take(30)}...'") + true + } catch (e: Exception) { + Logger.e(TAG, "Failed to input text", e) + false + } + } + + /** + * Finds the currently focused editable node in the node tree. + * + * @param node The root node to search from + * @return The focused editable node, or null if not found + */ + private fun findFocusedEditableNode(node: AccessibilityNodeInfo): AccessibilityNodeInfo? { + // Check if this node is focused and editable + if (node.isFocused && node.isEditable) { + return node + } + + // Recursively search children + for (i in 0 until node.childCount) { + val child = node.getChild(i) ?: continue + val result = findFocusedEditableNode(child) + if (result != null) { + return result + } + // Note: AccessibilityNodeInfo.recycle() is deprecated and no longer needed + // The garbage collector will handle cleanup automatically + } + + return null + } + + /** + * Creates a bundle for setting text in an editable node. + * + * @param text The text to set + * @return Bundle with text argument + */ + private fun createSetTextBundle(text: String): android.os.Bundle { + return android.os.Bundle().apply { + putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text) + } + } + + // ==================== App Operations ==================== + + /** + * Gets the current foreground app's package name. + * + * @return Package name of the current app, or empty string if not found + */ + fun getCurrentApp(): String { + val rootNode = rootInActiveWindow ?: return "" + + // Try to get package name from window info + val packageName = rootNode.packageName?.toString() + + return packageName ?: "" + } + + /** + * Opens the accessibility settings for this app. + * + * @param intent The intent to start the accessibility settings + */ + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + // Handle any commands sent to the service + return START_STICKY + } +} diff --git a/app/src/main/java/com/kevinluo/autoglm/action/ActionHandler.kt b/app/src/main/java/com/kevinluo/autoglm/action/ActionHandler.kt index 100cda2..6ee5c5b 100644 --- a/app/src/main/java/com/kevinluo/autoglm/action/ActionHandler.kt +++ b/app/src/main/java/com/kevinluo/autoglm/action/ActionHandler.kt @@ -1,8 +1,7 @@ package com.kevinluo.autoglm.action import com.kevinluo.autoglm.app.AppResolver -import com.kevinluo.autoglm.device.DeviceExecutor -import com.kevinluo.autoglm.input.TextInputManager +import com.kevinluo.autoglm.device.IDeviceController import com.kevinluo.autoglm.screenshot.FloatingWindowController import com.kevinluo.autoglm.util.CoordinateConverter import com.kevinluo.autoglm.util.ErrorHandler @@ -11,25 +10,23 @@ import com.kevinluo.autoglm.util.Logger import kotlinx.coroutines.delay /** - * Handles execution of agent actions by coordinating with DeviceExecutor, - * AppResolver, HumanizedSwipeGenerator, and TextInputManager. + * Handles execution of agent actions by coordinating with IDeviceController, + * AppResolver, and HumanizedSwipeGenerator. * * This class is responsible for translating high-level [AgentAction] commands * into device-level operations. It manages floating window visibility during * touch operations to prevent interference. * - * @param deviceExecutor Executor for device-level operations (tap, swipe, etc.) + * @param deviceController Device controller for device-level operations (tap, swipe, text input, etc.) * @param appResolver Resolver for app name to package name mapping * @param swipeGenerator Generator for humanized swipe paths - * @param textInputManager Manager for text input operations * @param floatingWindowProvider Optional provider for floating window controller * */ class ActionHandler( - private val deviceExecutor: DeviceExecutor, + private val deviceController: IDeviceController, private val appResolver: AppResolver, private val swipeGenerator: HumanizedSwipeGenerator, - private val textInputManager: TextInputManager, private val floatingWindowProvider: (() -> FloatingWindowController?)? = null ) { @@ -97,6 +94,26 @@ class ActionHandler( floatingWindowProvider?.invoke()?.show() } + /** + * Handles text input via the device controller. + * + * @param text The text to input + * @return ActionResult with success status and message + */ + private suspend fun inputText(text: String): ActionResult { + return try { + val result = deviceController.inputText(text) + if (result.contains("Error", ignoreCase = true)) { + ActionResult(false, false, result) + } else { + ActionResult(true, false, result) + } + } catch (e: Exception) { + Logger.e(TAG, "Text input failed", e) + ActionResult(false, false, "文本输入失败: ${e.message}") + } + } + /** * Executes an agent action on the device. * @@ -180,7 +197,7 @@ class ActionHandler( hideFloatingWindow() return try { - val result = deviceExecutor.tap(absX, absY) + val result = deviceController.tap(absX, absY) if (isDeviceExecutorError(result)) { Logger.w(TAG, "Tap command failed: $result") ActionResult(false, false, "点击失败: $result") @@ -224,7 +241,7 @@ class ActionHandler( hideFloatingWindow() return try { - val result = deviceExecutor.swipe(swipePath.points, swipePath.durationMs) + val result = deviceController.swipe(swipePath.points, swipePath.durationMs) // Wait for swipe animation to complete before showing floating window // The swipe command returns immediately, but the gesture takes time @@ -259,9 +276,8 @@ class ActionHandler( return try { // Small delay to let the system settle focus delay(200) - - val result = textInputManager.typeText(action.text) - ActionResult(result.success, false, result.message) + + inputText(action.text) } finally { // Always show floating window after typing, even if typing fails showFloatingWindow() @@ -279,8 +295,8 @@ class ActionHandler( return try { // Small delay to let the system settle focus delay(200) - - val result = textInputManager.typeText(action.text) + + val result = inputText(action.text) ActionResult(result.success, false, "输入名称: ${action.text}") } finally { // Always show floating window after typing, even if typing fails @@ -310,7 +326,7 @@ class ActionHandler( return if (packageName != null) { Logger.i(TAG, "Launching package: $packageName") - val launchResult = deviceExecutor.launchApp(packageName) + val launchResult = deviceController.launchApp(packageName) // Check if launch was successful by examining the result val isError = launchResult.contains("Error", ignoreCase = true) || @@ -321,7 +337,7 @@ class ActionHandler( if (isError) { Logger.w(TAG, "Launch failed for $packageName: $launchResult") // Launch failed - instruct model to find app icon on screen - deviceExecutor.pressKey(DeviceExecutor.KEYCODE_HOME) + deviceController.pressKey(IDeviceController.KEYCODE_HOME) ActionResult( success = true, // Operation itself succeeded, just app not found shouldFinish = false, @@ -334,7 +350,7 @@ class ActionHandler( // Package not found - instruct model to find app icon on screen Logger.i(TAG, "Package not found for '${action.app}', instructing model to find app icon on screen") // Press Home first to go to home screen - deviceExecutor.pressKey(DeviceExecutor.KEYCODE_HOME) + deviceController.pressKey(IDeviceController.KEYCODE_HOME) ActionResult( success = true, shouldFinish = false, @@ -378,11 +394,11 @@ class ActionHandler( private suspend fun executeBack(): ActionResult { // First, dismiss keyboard with ESCAPE key to ensure Back actually navigates // If keyboard is shown, the first Back would just close it - deviceExecutor.pressKey(111) // KEYCODE_ESCAPE + deviceController.pressKey(111) // KEYCODE_ESCAPE delay(100) // Now press Back to navigate - val result = deviceExecutor.pressKey(DeviceExecutor.KEYCODE_BACK) + val result = deviceController.pressKey(IDeviceController.KEYCODE_BACK) return if (isDeviceExecutorError(result)) { Logger.w(TAG, "Back key press failed: $result") ActionResult(false, false, "返回键失败: $result") @@ -395,7 +411,7 @@ class ActionHandler( * Executes a Home action. */ private suspend fun executeHome(): ActionResult { - val result = deviceExecutor.pressKey(DeviceExecutor.KEYCODE_HOME) + val result = deviceController.pressKey(IDeviceController.KEYCODE_HOME) return if (isDeviceExecutorError(result)) { Logger.w(TAG, "Home key press failed: $result") ActionResult(false, false, "主页键失败: $result") @@ -408,7 +424,7 @@ class ActionHandler( * Executes a VolumeUp action. */ private suspend fun executeVolumeUp(): ActionResult { - val result = deviceExecutor.pressKey(DeviceExecutor.KEYCODE_VOLUME_UP) + val result = deviceController.pressKey(IDeviceController.KEYCODE_VOLUME_UP) return if (isDeviceExecutorError(result)) { Logger.w(TAG, "Volume up key press failed: $result") ActionResult(false, false, "音量+键失败: $result") @@ -421,7 +437,7 @@ class ActionHandler( * Executes a VolumeDown action. */ private suspend fun executeVolumeDown(): ActionResult { - val result = deviceExecutor.pressKey(DeviceExecutor.KEYCODE_VOLUME_DOWN) + val result = deviceController.pressKey(IDeviceController.KEYCODE_VOLUME_DOWN) return if (isDeviceExecutorError(result)) { Logger.w(TAG, "Volume down key press failed: $result") ActionResult(false, false, "音量-键失败: $result") @@ -434,7 +450,7 @@ class ActionHandler( * Executes a Power action. */ private suspend fun executePower(): ActionResult { - val result = deviceExecutor.pressKey(DeviceExecutor.KEYCODE_POWER) + val result = deviceController.pressKey(IDeviceController.KEYCODE_POWER) return if (isDeviceExecutorError(result)) { Logger.w(TAG, "Power key press failed: $result") ActionResult(false, false, "电源键失败: $result") @@ -462,7 +478,7 @@ class ActionHandler( hideFloatingWindow() return try { - val result = deviceExecutor.longPress(absX, absY, action.durationMs) + val result = deviceController.longPress(absX, absY, action.durationMs) // Wait for long press to complete before showing floating window // The command returns immediately, but the gesture takes time @@ -498,7 +514,7 @@ class ActionHandler( hideFloatingWindow() return try { - val result = deviceExecutor.doubleTap(absX, absY) + val result = deviceController.doubleTap(absX, absY) if (isDeviceExecutorError(result)) { Logger.w(TAG, "Double tap command failed: $result") ActionResult(false, false, "双击失败: $result") diff --git a/app/src/main/java/com/kevinluo/autoglm/device/AccessibilityDeviceController.kt b/app/src/main/java/com/kevinluo/autoglm/device/AccessibilityDeviceController.kt new file mode 100644 index 0000000..c65fb59 --- /dev/null +++ b/app/src/main/java/com/kevinluo/autoglm/device/AccessibilityDeviceController.kt @@ -0,0 +1,283 @@ +package com.kevinluo.autoglm.device + +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.os.Build +import android.provider.Settings +import com.kevinluo.autoglm.accessibility.AutoGLMAccessibilityService +import com.kevinluo.autoglm.screenshot.Screenshot +import com.kevinluo.autoglm.util.Logger +import com.kevinluo.autoglm.util.Point +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream +import android.util.Base64 + +/** + * Device controller implementation using Android Accessibility Service. + * + * This controller uses the AccessibilityService API for device control. + * It requires the accessibility service to be enabled in system settings. + * + * @param context Android context + * + * Requirements: Android 13+ (API 33) for screenshot functionality + */ +class AccessibilityDeviceController( + private val context: Context +) : IDeviceController { + + override fun getMode(): DeviceControlMode = DeviceControlMode.ACCESSIBILITY + + override fun checkPermission(): Boolean { + return AutoGLMAccessibilityService.isEnabled() + } + + override fun requestPermission(context: Context): Boolean { + try { + val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + return true + } catch (e: Exception) { + Logger.e(TAG, "Failed to open accessibility settings", e) + return false + } + } + + override suspend fun tap(x: Int, y: Int): String = withContext(Dispatchers.Main) { + val service = AutoGLMAccessibilityService.getInstance() + if (service == null) { + return@withContext "Error: Accessibility service not connected" + } + + val result = service.tap(x, y) + if (result) { + "Tap at ($x, $y) succeeded" + } else { + "Tap at ($x, $y) failed" + } + } + + override suspend fun doubleTap(x: Int, y: Int): String = withContext(Dispatchers.Main) { + val service = AutoGLMAccessibilityService.getInstance() + if (service == null) { + return@withContext "Error: Accessibility service not connected" + } + + val result = service.doubleTap(x, y) + if (result) { + "Double tap at ($x, $y) succeeded" + } else { + "Double tap at ($x, $y) failed" + } + } + + override suspend fun longPress(x: Int, y: Int, durationMs: Int): String = withContext(Dispatchers.Main) { + val service = AutoGLMAccessibilityService.getInstance() + if (service == null) { + return@withContext "Error: Accessibility service not connected" + } + + val result = service.longPress(x, y, durationMs) + if (result) { + "Long press at ($x, $y) for ${durationMs}ms succeeded" + } else { + "Long press at ($x, $y) failed" + } + } + + override suspend fun swipe(points: List, durationMs: Int): String = withContext(Dispatchers.Main) { + val service = AutoGLMAccessibilityService.getInstance() + if (service == null) { + return@withContext "Error: Accessibility service not connected" + } + + if (points.size < 2) { + return@withContext "Error: Swipe requires at least 2 points" + } + + // Perform swipe between each consecutive point + var allSuccess = true + for (i in 0 until points.size - 1) { + val start = points[i] + val end = points[i + 1] + val segmentDuration = durationMs / (points.size - 1) + + val result = service.swipe(start.x, start.y, end.x, end.y, segmentDuration) + if (!result) { + allSuccess = false + } + + // Small delay between segments for smooth multi-point swipe + if (i < points.size - 2) { + kotlinx.coroutines.delay(20) + } + } + + if (allSuccess) { + "Swipe with ${points.size} points succeeded" + } else { + "Swipe completed with some segments failed" + } + } + + override suspend fun pressKey(keyCode: Int): String = withContext(Dispatchers.Main) { + val service = AutoGLMAccessibilityService.getInstance() + if (service == null) { + return@withContext "Error: Accessibility service not connected" + } + + val result = service.pressKey(keyCode) + if (result) { + "Key press $keyCode succeeded" + } else { + "Key press $keyCode failed" + } + } + + override suspend fun launchApp(packageName: String): String = withContext(Dispatchers.Main) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + val service = AutoGLMAccessibilityService.getInstance() + if (service == null) { + return@withContext "Error: Accessibility service not connected" + } + + // For accessibility mode, we can't directly launch apps via shell + // We need to use a different approach or return an error + // One option is to use PackageManager to get the launch intent + try { + val pm = context.packageManager + val intent = pm.getLaunchIntentForPackage(packageName) + if (intent != null) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + "Launched app: $packageName" + } else { + "Error: No launch intent found for $packageName" + } + } catch (e: Exception) { + Logger.e(TAG, "Failed to launch app: $packageName", e) + "Error: Failed to launch $packageName: ${e.message}" + } + } else { + "Error: App launch not supported on Android < 5.0" + } + } + + override suspend fun getCurrentApp(): String = withContext(Dispatchers.Main) { + val service = AutoGLMAccessibilityService.getInstance() + if (service == null) { + return@withContext "" + } + + service.getCurrentApp() + } + + override suspend fun inputText(text: String): String = withContext(Dispatchers.Main) { + val service = AutoGLMAccessibilityService.getInstance() + if (service == null) { + return@withContext "Error: Accessibility service not connected" + } + + val result = service.inputText(text) + if (result) { + "Text input successful: '${text.take(30)}...'" + } else { + "Text input failed: No focused editable field" + } + } + + override suspend fun captureScreen(): Screenshot = withContext(Dispatchers.Main) { + val service = AutoGLMAccessibilityService.getInstance() + if (service == null) { + Logger.e(TAG, "Accessibility service not connected") + return@withContext createFallbackScreenshot() + } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Logger.w(TAG, "Screenshot not supported on Android < 13 (API 33)") + return@withContext createFallbackScreenshot() + } + + try { + Logger.d(TAG, "Attempting to capture screenshot via AccessibilityService") + val bitmap = service.captureScreen() + if (bitmap != null) { + val width = bitmap.width + val height = bitmap.height + Logger.d(TAG, "Screenshot captured successfully: ${width}x${height}") + + // Convert bitmap to Screenshot + val base64Data = encodeBitmapToBase64(bitmap) + bitmap.recycle() + + Screenshot( + base64Data = base64Data, + width = width, + height = height, + originalWidth = width, + originalHeight = height, + isSensitive = false + ) + } else { + Logger.w(TAG, "Screenshot capture returned null bitmap") + createFallbackScreenshot() + } + } catch (e: SecurityException) { + Logger.e(TAG, "Security exception when capturing screenshot - service may not have screenshot capability", e) + createFallbackScreenshot() + } catch (e: Exception) { + Logger.e(TAG, "Failed to capture screenshot via accessibility service", e) + createFallbackScreenshot() + } + } + + /** + * Creates a fallback black screenshot when capture fails. + */ + private fun createFallbackScreenshot(): Screenshot { + val bitmap = Bitmap.createBitmap(1080, 1920, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(android.graphics.Color.BLACK) + + val base64Data = encodeBitmapToBase64(bitmap) + bitmap.recycle() + + return Screenshot( + base64Data = base64Data, + width = 1080, + height = 1920, + isSensitive = true + ) + } + + /** + * Encodes a Bitmap to a base64 string. + * + * @param bitmap The bitmap to encode + * @param format Compression format (default: WEBP_LOSSY) + * @param quality Compression quality 0-100 (default: 80) + * @return Base64-encoded string of the compressed image + */ + private fun encodeBitmapToBase64( + bitmap: Bitmap, + format: Bitmap.CompressFormat? = null, + quality: Int = 80 + ): String { + val compressFormat = format ?: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Bitmap.CompressFormat.WEBP_LOSSY + } else { + @Suppress("DEPRECATION") + Bitmap.CompressFormat.WEBP + } + + val outputStream = ByteArrayOutputStream() + bitmap.compress(compressFormat, quality, outputStream) + return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) + } + + companion object { + private const val TAG = "AccessibilityDeviceController" + } +} diff --git a/app/src/main/java/com/kevinluo/autoglm/device/IDeviceController.kt b/app/src/main/java/com/kevinluo/autoglm/device/IDeviceController.kt new file mode 100644 index 0000000..66cd59e --- /dev/null +++ b/app/src/main/java/com/kevinluo/autoglm/device/IDeviceController.kt @@ -0,0 +1,171 @@ +package com.kevinluo.autoglm.device + +import android.content.Context +import com.kevinluo.autoglm.screenshot.Screenshot +import com.kevinluo.autoglm.util.Point + +/** + * Device control mode enumeration. + * + * Defines the available methods for controlling the device. + * + * @property SHIZUKU Uses Shizuku shell commands (requires Shizuku app) + * @property ACCESSIBILITY Uses Android Accessibility Service + */ +enum class DeviceControlMode(val displayName: String) { + SHIZUKU("Shizuku"), + ACCESSIBILITY("无障碍服务") +} + +/** + * Abstract interface for device control operations. + * + * This interface defines the contract for device manipulation regardless of the + * underlying implementation (Shizuku shell commands or Accessibility Service). + * + * Implementations must handle: + * - Touch operations (tap, swipe, long press, double tap) + * - Key press events + * - App launching and info retrieval + * - Text input (method varies by implementation) + * - Screen capture + * - Permission management + * + * Requirements: Integration with both Shizuku and Accessibility modes + */ +interface IDeviceController { + + /** + * Gets the control mode of this device controller. + * + * @return The device control mode (SHIZUKU or ACCESSIBILITY) + */ + fun getMode(): DeviceControlMode + + /** + * Checks if the required permissions are granted. + * + * For Shizuku mode: Checks if Shizuku service is connected + * For Accessibility mode: Checks if accessibility service is enabled + * + * @return true if all required permissions are granted, false otherwise + */ + fun checkPermission(): Boolean + + /** + * Requests the required permissions from the user. + * + * For Shizuku mode: Launches Shizuku app or shows setup instructions + * For Accessibility mode: Opens system accessibility settings + * + * @param context Android context for starting intents + * @return true if the request was initiated successfully, false otherwise + */ + fun requestPermission(context: Context): Boolean + + // ==================== Touch Operations ==================== + + /** + * Performs a tap at the specified absolute coordinates. + * + * @param x Absolute X coordinate in pixels + * @param y Absolute Y coordinate in pixels + * @return Result of the operation + */ + suspend fun tap(x: Int, y: Int): String + + /** + * Performs a double tap at the specified absolute coordinates. + * + * @param x Absolute X coordinate in pixels + * @param y Absolute Y coordinate in pixels + * @return Result of the operation + */ + suspend fun doubleTap(x: Int, y: Int): String + + /** + * Performs a long press at the specified absolute coordinates. + * + * @param x Absolute X coordinate in pixels + * @param y Absolute Y coordinate in pixels + * @param durationMs Duration of the long press in milliseconds + * @return Result of the operation + */ + suspend fun longPress(x: Int, y: Int, durationMs: Int = 3000): String + + /** + * Performs a swipe gesture using a list of points. + * + * @param points List of points defining the swipe path, must contain at least 2 points + * @param durationMs Total duration of the swipe in milliseconds + * @return Result of the operation + */ + suspend fun swipe(points: List, durationMs: Int): String + + // ==================== Key Press Operations ==================== + + /** + * Presses a key by its keycode. + * + * @param keyCode The Android KeyEvent keycode (e.g., KEYCODE_BACK, KEYCODE_HOME) + * @return Result of the operation + */ + suspend fun pressKey(keyCode: Int): String + + // ==================== App Operations ==================== + + /** + * Launches an app by its package name. + * + * @param packageName The package name of the app to launch + * @return Result of the operation + */ + suspend fun launchApp(packageName: String): String + + /** + * Gets the current foreground app's package name. + * + * @return The package name of the current foreground app, or empty string if not found + */ + suspend fun getCurrentApp(): String + + // ==================== Text Input ==================== + + /** + * Inputs text into the currently focused input field. + * + * Implementation varies by mode: + * - Shizuku: Uses keyboard switching via TextInputManager + * - Accessibility: Directly injects text via AccessibilityNodeInfo + * + * @param text The text to input + * @return Result of the operation + */ + suspend fun inputText(text: String): String + + // ==================== Screenshot ==================== + + /** + * Captures the current screen content. + * + * Implementation varies by mode: + * - Shizuku: Uses screencap shell command + * - Accessibility: Uses AccessibilityService.takeScreenshot() (Android 13+) + * + * @return Screenshot object containing the captured image data and metadata + */ + suspend fun captureScreen(): Screenshot + + // ==================== Constants ==================== + + companion object { + // Android KeyEvent keycodes + const val KEYCODE_BACK = 4 + const val KEYCODE_HOME = 3 + const val KEYCODE_VOLUME_UP = 24 + const val KEYCODE_VOLUME_DOWN = 25 + const val KEYCODE_POWER = 26 + const val KEYCODE_ENTER = 66 + const val KEYCODE_DEL = 67 + } +} diff --git a/app/src/main/java/com/kevinluo/autoglm/device/ShizukuDeviceController.kt b/app/src/main/java/com/kevinluo/autoglm/device/ShizukuDeviceController.kt new file mode 100644 index 0000000..8ff5d5d --- /dev/null +++ b/app/src/main/java/com/kevinluo/autoglm/device/ShizukuDeviceController.kt @@ -0,0 +1,101 @@ +package com.kevinluo.autoglm.device + +import android.content.Context +import com.kevinluo.autoglm.IUserService +import com.kevinluo.autoglm.input.TextInputManager +import com.kevinluo.autoglm.screenshot.Screenshot +import com.kevinluo.autoglm.screenshot.ScreenshotService +import com.kevinluo.autoglm.util.Logger + +/** + * Device controller implementation using Shizuku shell commands. + * + * This controller uses the Shizuku framework to execute shell commands for device control. + * It requires the Shizuku app to be installed and the user service to be connected. + * + * @param userService Shizuku user service for executing shell commands + * @param textInputManager Manager for text input operations (keyboard switching) + * @param screenshotProvider Provider function for screenshot service + * + * Requirements: Shizuku-based device control + */ +class ShizukuDeviceController( + private val userService: IUserService, + private val textInputManager: TextInputManager, + private val screenshotProvider: () -> ScreenshotService +) : IDeviceController { + + private val executor = DeviceExecutor(userService) + + override fun getMode(): DeviceControlMode = DeviceControlMode.SHIZUKU + + override fun checkPermission(): Boolean { + return try { + // Try to execute a simple command to check if Shizuku is connected + val result = userService.executeCommand("echo test") + result.contains("test") + } catch (e: Exception) { + Logger.w(TAG, "Shizuku permission check failed: ${e.message}") + false + } + } + + override fun requestPermission(context: Context): Boolean { + try { + // Launch Shizuku app + val intent = context.packageManager.getLaunchIntentForPackage("moe.shizuku.privileged.api") + if (intent != null) { + context.startActivity(intent) + return true + } + } catch (e: Exception) { + Logger.e(TAG, "Failed to launch Shizuku app", e) + } + return false + } + + override suspend fun tap(x: Int, y: Int): String { + return executor.tap(x, y) + } + + override suspend fun doubleTap(x: Int, y: Int): String { + return executor.doubleTap(x, y) + } + + override suspend fun longPress(x: Int, y: Int, durationMs: Int): String { + return executor.longPress(x, y, durationMs) + } + + override suspend fun swipe(points: List, durationMs: Int): String { + return executor.swipe(points, durationMs) + } + + override suspend fun pressKey(keyCode: Int): String { + return executor.pressKey(keyCode) + } + + override suspend fun launchApp(packageName: String): String { + return executor.launchApp(packageName) + } + + override suspend fun getCurrentApp(): String { + return executor.getCurrentApp() + } + + override suspend fun inputText(text: String): String { + val result = textInputManager.typeText(text) + return if (result.success) { + "Text input successful: ${text.take(30)}..." + } else { + "Text input failed: ${result.message}" + } + } + + override suspend fun captureScreen(): Screenshot { + return screenshotProvider().capture() + } + + companion object { + private const val TAG = "ShizukuDeviceController" + } +} diff --git a/app/src/main/java/com/kevinluo/autoglm/history/HistoryActivity.kt b/app/src/main/java/com/kevinluo/autoglm/history/HistoryActivity.kt index 0dafc97..b15f616 100644 --- a/app/src/main/java/com/kevinluo/autoglm/history/HistoryActivity.kt +++ b/app/src/main/java/com/kevinluo/autoglm/history/HistoryActivity.kt @@ -10,6 +10,7 @@ import android.widget.ImageButton import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView +import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat @@ -39,29 +40,42 @@ import java.util.Locale * */ class HistoryActivity : AppCompatActivity() { - + private lateinit var historyManager: HistoryManager private lateinit var recyclerView: RecyclerView private lateinit var emptyState: LinearLayout private lateinit var adapter: HistoryAdapter - + // Multi-select mode private lateinit var normalToolbar: LinearLayout private lateinit var selectionToolbar: LinearLayout private lateinit var selectionCountText: TextView private var isSelectionMode = false - + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_history) - + historyManager = HistoryManager.getInstance(this) - + Logger.d(TAG, "HistoryActivity created") setupViews() observeHistory() + + // Handle back press using modern OnBackPressedDispatcher + val backCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (isSelectionMode) { + exitSelectionMode() + } else { + isEnabled = false + onBackPressedDispatcher.onBackPressed() + } + } + } + onBackPressedDispatcher.addCallback(this, backCallback) } - + /** * Sets up all view references and click listeners. */ @@ -69,31 +83,31 @@ class HistoryActivity : AppCompatActivity() { normalToolbar = findViewById(R.id.normalToolbar) selectionToolbar = findViewById(R.id.selectionToolbar) selectionCountText = findViewById(R.id.selectionCountText) - + findViewById(R.id.backBtn).setOnClickListener { finish() } - + findViewById(R.id.clearAllBtn).setOnClickListener { showClearAllDialog() } - + // Selection toolbar buttons findViewById(R.id.cancelSelectionBtn).setOnClickListener { exitSelectionMode() } - + findViewById(R.id.selectAllBtn).setOnClickListener { adapter.selectAll() } - + findViewById(R.id.deleteSelectedBtn).setOnClickListener { showDeleteSelectedDialog() } - + recyclerView = findViewById(R.id.historyRecyclerView) emptyState = findViewById(R.id.emptyState) - + adapter = HistoryAdapter( onItemClick = { task -> if (isSelectionMode) { @@ -115,11 +129,11 @@ class HistoryActivity : AppCompatActivity() { } } ) - + recyclerView.layoutManager = LinearLayoutManager(this) recyclerView.adapter = adapter } - + /** * Observes the history list and updates the UI accordingly. */ @@ -129,7 +143,7 @@ class HistoryActivity : AppCompatActivity() { adapter.submitList(list) emptyState.visibility = if (list.isEmpty()) View.VISIBLE else View.GONE recyclerView.visibility = if (list.isEmpty()) View.GONE else View.VISIBLE - + // Exit selection mode if list becomes empty if (list.isEmpty() && isSelectionMode) { exitSelectionMode() @@ -137,7 +151,7 @@ class HistoryActivity : AppCompatActivity() { } } } - + /** * Enters multi-select mode for batch operations. */ @@ -148,7 +162,7 @@ class HistoryActivity : AppCompatActivity() { selectionToolbar.visibility = View.VISIBLE Logger.d(TAG, "Entered selection mode") } - + /** * Exits multi-select mode and clears selection. */ @@ -160,7 +174,7 @@ class HistoryActivity : AppCompatActivity() { selectionToolbar.visibility = View.GONE Logger.d(TAG, "Exited selection mode") } - + /** * Updates the selection count display in the toolbar. * @@ -169,7 +183,7 @@ class HistoryActivity : AppCompatActivity() { private fun updateSelectionCount(count: Int) { selectionCountText.text = getString(R.string.history_selected_count, count) } - + /** * Opens the task detail activity for the given task. * @@ -181,14 +195,14 @@ class HistoryActivity : AppCompatActivity() { intent.putExtra(HistoryDetailActivity.EXTRA_TASK_ID, task.id) startActivity(intent) } - + /** * Shows a confirmation dialog for deleting selected tasks. */ private fun showDeleteSelectedDialog() { val selectedIds = adapter.getSelectedIds() if (selectedIds.isEmpty()) return - + AlertDialog.Builder(this) .setTitle(R.string.history_delete_selected) .setMessage(getString(R.string.history_delete_selected_confirm, selectedIds.size)) @@ -201,7 +215,7 @@ class HistoryActivity : AppCompatActivity() { .setNegativeButton(R.string.dialog_cancel, null) .show() } - + /** * Shows a confirmation dialog for clearing all history. */ @@ -218,16 +232,7 @@ class HistoryActivity : AppCompatActivity() { .setNegativeButton(R.string.dialog_cancel, null) .show() } - - @Deprecated("Deprecated in Java") - override fun onBackPressed() { - if (isSelectionMode) { - exitSelectionMode() - } else { - super.onBackPressed() - } - } - + companion object { private const val TAG = "HistoryActivity" } @@ -250,12 +255,12 @@ class HistoryAdapter( private val onItemLongClick: (TaskHistory) -> Unit, private val onSelectionChanged: (Int) -> Unit ) : RecyclerView.Adapter() { - + private var items: List = emptyList() private val selectedIds = mutableSetOf() private var isSelectionMode = false private val dateFormat = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()) - + /** * Submits a new list of items to display. * @@ -267,7 +272,7 @@ class HistoryAdapter( selectedIds.retainAll(list.map { it.id }.toSet()) notifyDataSetChanged() } - + /** * Enables or disables selection mode. * @@ -277,7 +282,7 @@ class HistoryAdapter( isSelectionMode = enabled notifyDataSetChanged() } - + /** * Toggles the selection state of a task. * @@ -292,7 +297,7 @@ class HistoryAdapter( notifyDataSetChanged() onSelectionChanged(selectedIds.size) } - + /** * Selects all items in the list. */ @@ -302,7 +307,7 @@ class HistoryAdapter( notifyDataSetChanged() onSelectionChanged(selectedIds.size) } - + /** * Clears all selections. */ @@ -311,26 +316,26 @@ class HistoryAdapter( notifyDataSetChanged() onSelectionChanged(0) } - + /** * Gets the set of currently selected task IDs. * * @return Immutable copy of selected task IDs */ fun getSelectedIds(): Set = selectedIds.toSet() - + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.item_history_task, parent, false) return ViewHolder(view) } - + override fun onBindViewHolder(holder: ViewHolder, position: Int) { holder.bind(items[position]) } - + override fun getItemCount(): Int = items.size - + /** * ViewHolder for history list items. * @@ -343,7 +348,7 @@ class HistoryAdapter( private val timeText: TextView = itemView.findViewById(R.id.timeText) private val stepsText: TextView = itemView.findViewById(R.id.stepsText) private val durationText: TextView = itemView.findViewById(R.id.durationText) - + /** * Binds task data to the view. * @@ -357,7 +362,7 @@ class HistoryAdapter( R.string.history_duration_format, formatDuration(task.duration) ) - + if (task.success) { statusIcon.setImageResource(R.drawable.ic_check_circle) statusIcon.setColorFilter( @@ -369,22 +374,22 @@ class HistoryAdapter( ContextCompat.getColor(itemView.context, R.color.status_error) ) } - + // Handle selection mode checkBox.visibility = if (isSelectionMode) View.VISIBLE else View.GONE checkBox.isChecked = selectedIds.contains(task.id) - + checkBox.setOnClickListener { toggleSelection(task.id) } - + itemView.setOnClickListener { onItemClick(task) } itemView.setOnLongClickListener { onItemLongClick(task) true } } - + /** * Formats duration in milliseconds to a human-readable string. * diff --git a/app/src/main/java/com/kevinluo/autoglm/history/HistoryDetailActivity.kt b/app/src/main/java/com/kevinluo/autoglm/history/HistoryDetailActivity.kt index a2deb84..7c5e41e 100644 --- a/app/src/main/java/com/kevinluo/autoglm/history/HistoryDetailActivity.kt +++ b/app/src/main/java/com/kevinluo/autoglm/history/HistoryDetailActivity.kt @@ -50,28 +50,28 @@ import java.util.Locale * */ class HistoryDetailActivity : AppCompatActivity() { - + private lateinit var historyManager: HistoryManager private var taskId: String? = null private var task: TaskHistory? = null - + private lateinit var contentRecyclerView: RecyclerView private lateinit var detailAdapter: HistoryDetailAdapter - + private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) - + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_history_detail) - + historyManager = HistoryManager.getInstance(this) taskId = intent.getStringExtra(EXTRA_TASK_ID) - + Logger.d(TAG, "HistoryDetailActivity created for task: $taskId") setupViews() loadTask() } - + /** * Sets up all view references and click listeners. */ @@ -79,23 +79,23 @@ class HistoryDetailActivity : AppCompatActivity() { findViewById(R.id.backBtn).setOnClickListener { finish() } - + findViewById(R.id.copyPromptBtn).setOnClickListener { copyPromptToClipboard() } - + findViewById(R.id.saveImageBtn).setOnClickListener { saveAsImage() } - + findViewById(R.id.shareBtn).setOnClickListener { shareAsImage() } - + findViewById(R.id.deleteBtn).setOnClickListener { showDeleteDialog() } - + // Setup RecyclerView with true recycling contentRecyclerView = findViewById(R.id.contentRecyclerView) detailAdapter = HistoryDetailAdapter(historyManager, lifecycleScope) @@ -107,27 +107,27 @@ class HistoryDetailActivity : AppCompatActivity() { setItemViewCacheSize(3) } } - + /** * Loads the task from history manager. */ private fun loadTask() { val id = taskId ?: return - + lifecycleScope.launch { task = historyManager.getTask(id) - task?.let { + task?.let { Logger.d(TAG, "Loaded task with ${it.stepCount} steps") - detailAdapter.setTask(it) + detailAdapter.setTask(it) } } } - + override fun onDestroy() { super.onDestroy() detailAdapter.cleanup() } - + /** * Shows a confirmation dialog for deleting the task. */ @@ -140,22 +140,22 @@ class HistoryDetailActivity : AppCompatActivity() { .setNegativeButton(R.string.dialog_cancel, null) .show() } - + /** * Copies the task prompt/description to clipboard. * */ private fun copyPromptToClipboard() { val currentTask = task ?: return - + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clip = ClipData.newPlainText("AutoGLM Prompt", currentTask.taskDescription) clipboard.setPrimaryClip(clip) - + Logger.d(TAG, "Copied prompt to clipboard") Toast.makeText(this, R.string.history_prompt_copied, Toast.LENGTH_SHORT).show() } - + /** * Deletes the current task from history. */ @@ -167,7 +167,7 @@ class HistoryDetailActivity : AppCompatActivity() { finish() } } - + /** * Formats duration in milliseconds to a human-readable string. * @@ -182,7 +182,7 @@ class HistoryDetailActivity : AppCompatActivity() { else -> "${seconds / 3600}时${(seconds % 3600) / 60}分" } } - + /** * Saves the task history as an image to gallery. * @@ -191,22 +191,22 @@ class HistoryDetailActivity : AppCompatActivity() { */ private fun saveAsImage() { val currentTask = task ?: return - + Logger.d(TAG, "Saving task as image") Toast.makeText(this, R.string.history_generating_image, Toast.LENGTH_SHORT).show() - + lifecycleScope.launch { try { val bitmap = withContext(Dispatchers.Default) { generateShareImage(currentTask) } - + val saved = withContext(Dispatchers.IO) { saveBitmapToGallery(bitmap) } - + bitmap.recycle() - + if (saved) { Logger.d(TAG, "Image saved to gallery") Toast.makeText(this@HistoryDetailActivity, R.string.history_save_success, Toast.LENGTH_SHORT).show() @@ -220,7 +220,7 @@ class HistoryDetailActivity : AppCompatActivity() { } } } - + /** * Shares the task history as an image. * @@ -229,24 +229,24 @@ class HistoryDetailActivity : AppCompatActivity() { */ private fun shareAsImage() { val currentTask = task ?: return - + Logger.d(TAG, "Sharing task as image") Toast.makeText(this, R.string.history_generating_image, Toast.LENGTH_SHORT).show() - + lifecycleScope.launch { try { val bitmap = withContext(Dispatchers.Default) { generateShareImage(currentTask) } - + // Save bitmap to cache directory val file = withContext(Dispatchers.IO) { saveBitmapToCache(bitmap) } - + // Share the image shareImageFile(file) - + // Recycle bitmap bitmap.recycle() } catch (e: Exception) { @@ -255,7 +255,7 @@ class HistoryDetailActivity : AppCompatActivity() { } } } - + /** * Generates a share image from task history. * @@ -270,16 +270,16 @@ class HistoryDetailActivity : AppCompatActivity() { val contentWidth = width - padding * 2 val stepSpacing = 40 // Spacing between steps val screenshotWidthRatio = 0.8f // Screenshot width = 80% of image width - + // Calculate heights val headerHeight = 200 val stepBaseHeight = 120 val thinkingLineHeight = 50 - + // First pass: calculate total height (need to load screenshots to get their heights) var totalHeight = headerHeight + padding * 2 val screenshotHeights = mutableMapOf() - + for ((index, step) in task.steps.withIndex()) { totalHeight += stepBaseHeight if (step.thinking.isNotBlank()) { @@ -301,52 +301,52 @@ class HistoryDetailActivity : AppCompatActivity() { totalHeight += stepSpacing } totalHeight += 80 // footer - + // Create bitmap val bitmap = Bitmap.createBitmap(width, totalHeight, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) - + // Background canvas.drawColor(Color.parseColor("#1A1A1A")) - + // Paints val titlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE textSize = 48f typeface = Typeface.DEFAULT_BOLD } - + val subtitlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#AAAAAA") textSize = 32f } - + val stepNumberPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#4CAF50") textSize = 36f typeface = Typeface.DEFAULT_BOLD } - + val actionPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE textSize = 34f } - + val thinkingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#888888") textSize = 28f } - + val cardPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#2A2A2A") } - + var y = padding.toFloat() - + // Draw header canvas.drawText("AutoGLM 任务记录", padding.toFloat(), y + 50, titlePaint) y += 70 - + // Task description val descLines = wrapText(task.taskDescription, actionPaint, contentWidth.toFloat()) for (line in descLines) { @@ -354,18 +354,18 @@ class HistoryDetailActivity : AppCompatActivity() { y += 45 } y += 20 - + // Status and info val statusColor = if (task.success) Color.parseColor("#4CAF50") else Color.parseColor("#F44336") val statusPaint = Paint(subtitlePaint).apply { color = statusColor } val statusText = if (task.success) "✓ 成功" else "✗ 失败" canvas.drawText(statusText, padding.toFloat(), y + 35, statusPaint) - + val duration = formatDuration(task.duration) val infoStr = "${dateFormat.format(Date(task.startTime))} · ${task.stepCount}步 · $duration" canvas.drawText(infoStr, padding + 150f, y + 35, subtitlePaint) y += 60 - + // Draw steps for ((index, step) in task.steps.withIndex()) { // Add spacing before each step (except first one uses smaller spacing) @@ -374,7 +374,7 @@ class HistoryDetailActivity : AppCompatActivity() { } else { y += stepSpacing } - + // Step card background val cardTop = y var cardHeight = stepBaseHeight.toFloat() @@ -385,32 +385,32 @@ class HistoryDetailActivity : AppCompatActivity() { screenshotHeights[index]?.let { h -> cardHeight += h + 40 } - + canvas.drawRoundRect( padding.toFloat(), cardTop, (width - padding).toFloat(), cardTop + cardHeight, 20f, 20f, cardPaint ) - + y += 15 - + // Step number canvas.drawText("步骤 ${step.stepNumber}", padding + 20f, y + 40, stepNumberPaint) - + // Status indicator val stepStatusPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = if (step.success) Color.parseColor("#4CAF50") else Color.parseColor("#F44336") } canvas.drawCircle(width - padding - 30f, y + 25, 10f, stepStatusPaint) y += 50 - + // Action description val actionLines = wrapText(step.actionDescription, actionPaint, contentWidth - 40f) for (line in actionLines) { canvas.drawText(line, padding + 20f, y + 30, actionPaint) y += 40 } - + // Thinking if (step.thinking.isNotBlank()) { y += 10 @@ -420,7 +420,7 @@ class HistoryDetailActivity : AppCompatActivity() { y += 35 } } - + // Screenshot val screenshotPath = step.annotatedScreenshotPath ?: step.screenshotPath if (screenshotPath != null) { @@ -432,29 +432,29 @@ class HistoryDetailActivity : AppCompatActivity() { val scale = targetWidth.toFloat() / screenshotBitmap.width val scaledWidth = targetWidth val scaledHeight = (screenshotBitmap.height * scale).toInt() - + val scaledBitmap = Bitmap.createScaledBitmap(screenshotBitmap, scaledWidth, scaledHeight, true) val left = (width - scaledWidth) / 2 // Center horizontally canvas.drawBitmap(scaledBitmap, left.toFloat(), y, null) - + y += scaledHeight scaledBitmap.recycle() screenshotBitmap.recycle() } } - + // Move y to end of card (cardTop + cardHeight) y = cardTop + cardHeight } - + // Footer y += 30 val footerPaint = Paint(subtitlePaint).apply { textSize = 24f } canvas.drawText("由 AutoGLM For Android 生成", padding.toFloat(), y + 30, footerPaint) - + return bitmap } - + /** * Wraps text to fit within a given width. * @@ -466,11 +466,11 @@ class HistoryDetailActivity : AppCompatActivity() { private fun wrapText(text: String, paint: Paint, maxWidth: Float): List { val lines = mutableListOf() var remaining = text - + while (remaining.isNotEmpty()) { val count = paint.breakText(remaining, true, maxWidth, null) if (count == 0) break - + // Try to break at word boundary var breakAt = count if (count < remaining.length) { @@ -479,14 +479,14 @@ class HistoryDetailActivity : AppCompatActivity() { breakAt = lastSpace + 1 } } - + lines.add(remaining.substring(0, breakAt).trim()) remaining = remaining.substring(breakAt).trim() } - + return lines.ifEmpty { listOf("") } } - + /** * Saves bitmap to cache directory. * @@ -496,14 +496,21 @@ class HistoryDetailActivity : AppCompatActivity() { private fun saveBitmapToCache(bitmap: Bitmap): File { val cacheDir = File(cacheDir, "share") if (!cacheDir.exists()) cacheDir.mkdirs() - + + val compressFormat = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Bitmap.CompressFormat.WEBP_LOSSY + } else { + @Suppress("DEPRECATION") + Bitmap.CompressFormat.WEBP + } + val file = File(cacheDir, "autoglm_task_${System.currentTimeMillis()}.webp") FileOutputStream(file).use { out -> - bitmap.compress(Bitmap.CompressFormat.WEBP_LOSSY, 90, out) + bitmap.compress(compressFormat, 90, out) } return file } - + /** * Shares the image file using system share sheet. * @@ -515,16 +522,16 @@ class HistoryDetailActivity : AppCompatActivity() { "${packageName}.fileprovider", file ) - + val intent = Intent(Intent.ACTION_SEND).apply { type = "image/webp" putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - + startActivity(Intent.createChooser(intent, getString(R.string.history_share_title))) } - + /** * Saves bitmap to device gallery. * @@ -535,7 +542,7 @@ class HistoryDetailActivity : AppCompatActivity() { */ private fun saveBitmapToGallery(bitmap: Bitmap): Boolean { val filename = "AutoGLM_${System.currentTimeMillis()}.webp" - + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // Android 10+ use MediaStore val contentValues = ContentValues().apply { @@ -544,15 +551,21 @@ class HistoryDetailActivity : AppCompatActivity() { put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/AutoGLM") put(MediaStore.Images.Media.IS_PENDING, 1) } - + val resolver = contentResolver val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) - + uri?.let { resolver.openOutputStream(it)?.use { out -> - bitmap.compress(Bitmap.CompressFormat.WEBP_LOSSY, 90, out) + val compressFormat = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Bitmap.CompressFormat.WEBP_LOSSY + } else { + @Suppress("DEPRECATION") + Bitmap.CompressFormat.WEBP + } + bitmap.compress(compressFormat, 90, out) } - + contentValues.clear() contentValues.put(MediaStore.Images.Media.IS_PENDING, 0) resolver.update(it, contentValues, null, null) @@ -564,24 +577,34 @@ class HistoryDetailActivity : AppCompatActivity() { val picturesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) val autoglmDir = File(picturesDir, "AutoGLM") if (!autoglmDir.exists()) autoglmDir.mkdirs() - + val file = File(autoglmDir, filename) FileOutputStream(file).use { out -> - bitmap.compress(Bitmap.CompressFormat.WEBP_LOSSY, 90, out) + val compressFormat = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Bitmap.CompressFormat.WEBP_LOSSY + } else { + @Suppress("DEPRECATION") + Bitmap.CompressFormat.WEBP + } + bitmap.compress(compressFormat, 90, out) } - - // Notify gallery - val intent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE) - intent.data = android.net.Uri.fromFile(file) - sendBroadcast(intent) - + + // Notify gallery using MediaScannerConnection (recommended approach) + android.media.MediaScannerConnection.scanFile( + this, + arrayOf(file.absolutePath), + arrayOf("image/webp") + ) { path, uri -> + Logger.d(TAG, "Scanned $path: $uri") + } + true } } - + companion object { private const val TAG = "HistoryDetailActivity" - + /** Intent extra key for task ID. */ const val EXTRA_TASK_ID = "task_id" } diff --git a/app/src/main/java/com/kevinluo/autoglm/history/HistoryManager.kt b/app/src/main/java/com/kevinluo/autoglm/history/HistoryManager.kt index 0ca4298..101bb53 100644 --- a/app/src/main/java/com/kevinluo/autoglm/history/HistoryManager.kt +++ b/app/src/main/java/com/kevinluo/autoglm/history/HistoryManager.kt @@ -3,6 +3,7 @@ package com.kevinluo.autoglm.history import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.os.Build import android.util.Base64 import com.kevinluo.autoglm.action.AgentAction import com.kevinluo.autoglm.util.Logger @@ -38,33 +39,33 @@ import java.util.Locale * */ class HistoryManager private constructor(private val context: Context) { - + /** Directory for storing task history files. */ private val historyDir: File by lazy { File(context.filesDir, HISTORY_DIR).also { it.mkdirs() } } - + /** Currently recording task, null if no task is being recorded. */ private var currentTask: TaskHistory? = null - + /** Base64-encoded screenshot data for the current step. */ private var currentScreenshotBase64: String? = null - + /** Width of the current screenshot in pixels. */ private var currentScreenshotWidth: Int = 0 - + /** Height of the current screenshot in pixels. */ private var currentScreenshotHeight: Int = 0 - + private val _historyList = MutableStateFlow>(emptyList()) - + /** Observable list of all task histories, sorted by most recent first. */ val historyList: StateFlow> = _historyList.asStateFlow() - + init { loadHistoryIndex() } - + /** * Starts recording a new task. * @@ -81,7 +82,7 @@ class HistoryManager private constructor(private val context: Context) { Logger.d(TAG, "Started recording task: ${task.id}") return task } - + /** * Sets the current screenshot for the next step. * @@ -123,19 +124,19 @@ class HistoryManager private constructor(private val context: Context) { message: String? = null ) = withContext(Dispatchers.IO) { val task = currentTask ?: return@withContext - + var screenshotPath: String? = null var annotatedPath: String? = null - + // Save screenshot if available currentScreenshotBase64?.let { base64 -> try { // Decode base64 to raw bytes (already WebP format) val webpBytes = Base64.decode(base64, Base64.DEFAULT) - + // Save original screenshot directly without re-compression screenshotPath = saveScreenshotBytes(task.id, stepNumber, webpBytes, false) - + // Create and save annotated screenshot if action has visual annotation if (action != null) { val annotation = ScreenshotAnnotator.createAnnotation( @@ -163,7 +164,7 @@ class HistoryManager private constructor(private val context: Context) { Logger.e(TAG, "Failed to save screenshot for step $stepNumber", e) } } - + val step = HistoryStep( stepNumber = stepNumber, thinking = thinking, @@ -174,14 +175,14 @@ class HistoryManager private constructor(private val context: Context) { success = success, message = message ) - + task.steps.add(step) Logger.d(TAG, "Recorded step $stepNumber for task ${task.id}") - + // Clear current screenshot currentScreenshotBase64 = null } - + /** * Completes the current task recording. * @@ -195,38 +196,38 @@ class HistoryManager private constructor(private val context: Context) { */ suspend fun completeTask(success: Boolean, message: String?) = withContext(Dispatchers.IO) { val task = currentTask ?: return@withContext - + // Don't save empty tasks (no steps recorded) if (task.steps.isEmpty()) { Logger.d(TAG, "Skipping empty task ${task.id}") currentTask = null return@withContext } - + task.endTime = System.currentTimeMillis() task.success = success task.completionMessage = message - + // Save task to disk saveTask(task) - + // Update history list val updatedList = _historyList.value.toMutableList() updatedList.add(0, task) - + // Trim old history if needed while (updatedList.size > MAX_HISTORY_COUNT) { val removed = updatedList.removeAt(updatedList.size - 1) deleteTaskFiles(removed.id) } - + _historyList.value = updatedList saveHistoryIndex() - + Logger.d(TAG, "Completed task ${task.id}, success=$success") currentTask = null } - + /** * Gets a task history by ID. * @@ -239,7 +240,7 @@ class HistoryManager private constructor(private val context: Context) { suspend fun getTask(taskId: String): TaskHistory? = withContext(Dispatchers.IO) { loadTask(taskId) } - + /** * Deletes a task history. * @@ -253,7 +254,7 @@ class HistoryManager private constructor(private val context: Context) { _historyList.value = _historyList.value.filter { it.id != taskId } saveHistoryIndex() } - + /** * Deletes multiple task histories. * @@ -269,7 +270,7 @@ class HistoryManager private constructor(private val context: Context) { _historyList.value = _historyList.value.filter { it.id !in taskIds } saveHistoryIndex() } - + /** * Clears all history. * @@ -281,7 +282,7 @@ class HistoryManager private constructor(private val context: Context) { _historyList.value = emptyList() saveHistoryIndex() } - + /** * Gets the screenshot bitmap for a step. * @@ -297,9 +298,9 @@ class HistoryManager private constructor(private val context: Context) { if (!file.exists()) return null return BitmapFactory.decodeFile(path) } - + // Private helper methods - + /** * Saves raw WebP bytes directly to file (no re-compression). * @@ -318,14 +319,14 @@ class HistoryManager private constructor(private val context: Context) { val taskDir = File(historyDir, taskId).also { it.mkdirs() } val suffix = if (annotated) "_annotated" else "" val file = File(taskDir, "step_${stepNumber}${suffix}.webp") - + FileOutputStream(file).use { out -> out.write(webpBytes) } - + return file.absolutePath } - + /** * Saves bitmap as WebP (used for annotated screenshots). * @@ -344,14 +345,21 @@ class HistoryManager private constructor(private val context: Context) { val taskDir = File(historyDir, taskId).also { it.mkdirs() } val suffix = if (annotated) "_annotated" else "" val file = File(taskDir, "step_${stepNumber}${suffix}.webp") - + + val compressFormat = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Bitmap.CompressFormat.WEBP_LOSSY + } else { + @Suppress("DEPRECATION") + Bitmap.CompressFormat.WEBP + } + FileOutputStream(file).use { out -> - bitmap.compress(Bitmap.CompressFormat.WEBP_LOSSY, 85, out) + bitmap.compress(compressFormat, 85, out) } - + return file.absolutePath } - + /** * Decodes a Base64 string to a Bitmap. * @@ -367,7 +375,7 @@ class HistoryManager private constructor(private val context: Context) { null } } - + /** * Saves a task's metadata to JSON file. * @@ -376,7 +384,7 @@ class HistoryManager private constructor(private val context: Context) { private fun saveTask(task: TaskHistory) { val taskDir = File(historyDir, task.id).also { it.mkdirs() } val metaFile = File(taskDir, "meta.json") - + val json = JSONObject().apply { put("id", task.id) put("taskDescription", task.taskDescription) @@ -384,7 +392,7 @@ class HistoryManager private constructor(private val context: Context) { put("endTime", task.endTime) put("success", task.success) put("completionMessage", task.completionMessage) - + val stepsArray = JSONArray() task.steps.forEach { step -> stepsArray.put(JSONObject().apply { @@ -400,10 +408,10 @@ class HistoryManager private constructor(private val context: Context) { } put("steps", stepsArray) } - + metaFile.writeText(json.toString(2)) } - + /** * Loads a task from its JSON metadata file. * @@ -413,11 +421,11 @@ class HistoryManager private constructor(private val context: Context) { private fun loadTask(taskId: String): TaskHistory? { val metaFile = File(historyDir, "$taskId/meta.json") if (!metaFile.exists()) return null - + return try { val json = JSONObject(metaFile.readText()) val steps = mutableListOf() - + val stepsArray = json.optJSONArray("steps") if (stepsArray != null) { for (i in 0 until stepsArray.length()) { @@ -436,7 +444,7 @@ class HistoryManager private constructor(private val context: Context) { )) } } - + TaskHistory( id = json.getString("id"), taskDescription = json.getString("taskDescription"), @@ -451,7 +459,7 @@ class HistoryManager private constructor(private val context: Context) { null } } - + /** * Deletes all files associated with a task. * @@ -460,7 +468,7 @@ class HistoryManager private constructor(private val context: Context) { private fun deleteTaskFiles(taskId: String) { File(historyDir, taskId).deleteRecursively() } - + /** * Loads the history index from persistent storage. * @@ -469,22 +477,22 @@ class HistoryManager private constructor(private val context: Context) { private fun loadHistoryIndex() { val indexFile = File(historyDir, INDEX_FILE) if (!indexFile.exists()) return - + try { val json = JSONArray(indexFile.readText()) val list = mutableListOf() - + for (i in 0 until json.length()) { val taskId = json.getString(i) loadTask(taskId)?.let { list.add(it) } } - + _historyList.value = list } catch (e: Exception) { Logger.e(TAG, "Failed to load history index", e) } } - + /** * Saves the history index to persistent storage. * @@ -496,16 +504,16 @@ class HistoryManager private constructor(private val context: Context) { _historyList.value.forEach { json.put(it.id) } indexFile.writeText(json.toString()) } - + companion object { private const val TAG = "HistoryManager" private const val HISTORY_DIR = "task_history" private const val INDEX_FILE = "history_index.json" private const val MAX_HISTORY_COUNT = 50 - + @Volatile private var instance: HistoryManager? = null - + /** * Gets the singleton instance of HistoryManager. * diff --git a/app/src/main/java/com/kevinluo/autoglm/input/AutoGLMKeyboardService.kt b/app/src/main/java/com/kevinluo/autoglm/input/AutoGLMKeyboardService.kt index 484a688..7dbf7ab 100644 --- a/app/src/main/java/com/kevinluo/autoglm/input/AutoGLMKeyboardService.kt +++ b/app/src/main/java/com/kevinluo/autoglm/input/AutoGLMKeyboardService.kt @@ -47,7 +47,7 @@ class AutoGLMKeyboardService : InputMethodService() { private val inputReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { Logger.d(TAG, "Received broadcast: ${intent.action}") - + when (intent.action) { ACTION_INPUT_TEXT, ACTION_INPUT_B64 -> { handleInputText(intent) @@ -90,13 +90,13 @@ class AutoGLMKeyboardService : InputMethodService() { */ override fun onCreateInputView(): View { Logger.d(TAG, "onCreateInputView called") - + // Register receiver when input view is created registerInputReceiver() - + // Create a minimal status view val view = layoutInflater.inflate(R.layout.keyboard_autoglm, null) - + return view } @@ -122,7 +122,7 @@ class AutoGLMKeyboardService : InputMethodService() { override fun onStartInputView(info: EditorInfo?, restarting: Boolean) { super.onStartInputView(info, restarting) Logger.d(TAG, "onStartInputView: restarting=$restarting") - + // Ensure receiver is registered registerInputReceiver() } @@ -165,7 +165,7 @@ class AutoGLMKeyboardService : InputMethodService() { try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(inputReceiver, filter, Context.RECEIVER_EXPORTED) + registerReceiver(inputReceiver, filter, RECEIVER_EXPORTED) } else { @Suppress("UnspecifiedRegisterReceiverFlag") registerReceiver(inputReceiver, filter) @@ -211,7 +211,7 @@ class AutoGLMKeyboardService : InputMethodService() { val decodedBytes = Base64.decode(encodedText, Base64.DEFAULT) val text = String(decodedBytes, Charsets.UTF_8) Logger.d(TAG, "Decoded text: '${text.take(50)}${if (text.length > 50) "..." else ""}'") - + commitText(text) } catch (e: Exception) { Logger.e(TAG, "Failed to decode Base64 text", e) @@ -240,7 +240,7 @@ class AutoGLMKeyboardService : InputMethodService() { */ private fun handleClearText() { Logger.d(TAG, "Clearing text") - + val ic = currentInputConnection ?: run { Logger.w(TAG, "No input connection for clear text") return @@ -249,10 +249,10 @@ class AutoGLMKeyboardService : InputMethodService() { try { // Perform select all ic.performContextMenuAction(android.R.id.selectAll) - + // Delete selected text by committing empty string ic.commitText("", 0) - + Logger.d(TAG, "Text cleared successfully") } catch (e: Exception) { Logger.e(TAG, "Failed to clear text", e) diff --git a/app/src/main/java/com/kevinluo/autoglm/input/KeyboardHelper.kt b/app/src/main/java/com/kevinluo/autoglm/input/KeyboardHelper.kt index 9a754ee..816c20e 100644 --- a/app/src/main/java/com/kevinluo/autoglm/input/KeyboardHelper.kt +++ b/app/src/main/java/com/kevinluo/autoglm/input/KeyboardHelper.kt @@ -4,7 +4,9 @@ import android.content.Context import android.content.Intent import android.provider.Settings import android.view.inputmethod.InputMethodManager +import com.kevinluo.autoglm.BuildConfig import com.kevinluo.autoglm.util.Logger +import kotlin.coroutines.coroutineContext /** * Helper class for keyboard-related operations. @@ -16,17 +18,24 @@ import com.kevinluo.autoglm.util.Logger object KeyboardHelper { private const val TAG = "KeyboardHelper" - + /** AutoGLM package name. */ - private const val PACKAGE_NAME = "com.kevinluo.autoglm" - + private const val PACKAGE_NAME = BuildConfig.APPLICATION_ID + /** AutoGLM Keyboard IME ID (Android system format). */ - const val IME_ID = "$PACKAGE_NAME/.input.AutoGLMKeyboardService" - + + val IME_ID = "${BuildConfig.APPLICATION_ID}/${AutoGLMKeyboardService::class.java.name}" + + fun getImeId(context: Context): String { + val pkg = context.packageName + val service = AutoGLMKeyboardService::class.java.name + return "$pkg/$service" + } + /** * Checks if the given IME ID belongs to AutoGLM Keyboard. */ - fun isAutoGLMKeyboard(imeId: String): Boolean = + fun isAutoGLMKeyboard(imeId: String): Boolean = imeId.startsWith("$PACKAGE_NAME/") /** @@ -53,7 +62,7 @@ object KeyboardHelper { for (ime in enabledInputMethods) { Logger.d(TAG, "Found IME: package=${ime.packageName}, service=${ime.serviceName}") - if (ime.packageName == PACKAGE_NAME && + if (ime.packageName.contains(PACKAGE_NAME) && ime.serviceName.endsWith(".AutoGLMKeyboardService")) { Logger.d(TAG, "AutoGLM Keyboard is enabled") return KeyboardStatus.ENABLED diff --git a/app/src/main/java/com/kevinluo/autoglm/input/TextInputManager.kt b/app/src/main/java/com/kevinluo/autoglm/input/TextInputManager.kt index c9cf994..802ca83 100644 --- a/app/src/main/java/com/kevinluo/autoglm/input/TextInputManager.kt +++ b/app/src/main/java/com/kevinluo/autoglm/input/TextInputManager.kt @@ -1,6 +1,7 @@ package com.kevinluo.autoglm.input import android.util.Base64 +import com.kevinluo.autoglm.BuildConfig import com.kevinluo.autoglm.IUserService import com.kevinluo.autoglm.util.Logger import kotlinx.coroutines.Dispatchers @@ -36,7 +37,7 @@ class TextInputManager(private val userService: IUserService) { /** Cached original IME for restoration after text input. */ private var originalIme: String? = null - + /** * Types text into the currently focused input field. * @@ -112,11 +113,11 @@ class TextInputManager(private val userService: IUserService) { // Save original IME originalIme = currentIme Logger.d(TAG, "Saved original IME: $originalIme") - + // List all enabled IMEs to debug val enabledImes = shell("ime list -s") Logger.d(TAG, "Enabled IMEs:\n$enabledImes") - + // Get the IME ID val imeId = KeyboardHelper.IME_ID Logger.d(TAG, "AutoGLM Keyboard IME ID: $imeId") @@ -153,7 +154,7 @@ class TextInputManager(private val userService: IUserService) { val newIme = getCurrentIme() return KeyboardHelper.isAutoGLMKeyboard(newIme) } - + /** * Gets the current default input method. * @@ -173,7 +174,7 @@ class TextInputManager(private val userService: IUserService) { */ private fun clearText(): String { Logger.d(TAG, "Clearing text") - return shell("am broadcast -a $ACTION_CLEAR_TEXT -p $PACKAGE_NAME") + return shell("am broadcast -a $ACTION_CLEAR_TEXT -p $PACKAGE_NAME --user current") } /** @@ -192,7 +193,7 @@ class TextInputManager(private val userService: IUserService) { private fun inputTextViaB64(text: String): String { val encoded = Base64.encodeToString(text.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) Logger.d(TAG, "Input text via B64: '$text' -> '$encoded'") - return shell("am broadcast -a $ACTION_INPUT_B64 -p $PACKAGE_NAME --es msg '$encoded'") + return shell("am broadcast -a $ACTION_INPUT_B64 -p $PACKAGE_NAME --es msg '$encoded' --user current") } /** @@ -232,9 +233,9 @@ class TextInputManager(private val userService: IUserService) { // Broadcast actions private const val ACTION_INPUT_B64 = "ADB_INPUT_B64" private const val ACTION_CLEAR_TEXT = "ADB_CLEAR_TEXT" - + // Package name - private const val PACKAGE_NAME = "com.kevinluo.autoglm" + private const val PACKAGE_NAME = BuildConfig.APPLICATION_ID // Timing constants (increased for stability) // Wait after switching keyboard to ensure it's fully active diff --git a/app/src/main/java/com/kevinluo/autoglm/screenshot/ScreenshotService.kt b/app/src/main/java/com/kevinluo/autoglm/screenshot/ScreenshotService.kt index 40f04f7..17b9285 100644 --- a/app/src/main/java/com/kevinluo/autoglm/screenshot/ScreenshotService.kt +++ b/app/src/main/java/com/kevinluo/autoglm/screenshot/ScreenshotService.kt @@ -2,6 +2,7 @@ package com.kevinluo.autoglm.screenshot import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.os.Build import android.util.Base64 import com.kevinluo.autoglm.IUserService import com.kevinluo.autoglm.util.ErrorHandler @@ -78,29 +79,39 @@ interface FloatingWindowController { * */ class ScreenshotService( - private val userService: IUserService, + userService: IUserService? = null, + private val screenshotProvider: (suspend () -> Screenshot)? = null, private val floatingWindowControllerProvider: () -> FloatingWindowController? = { null } ) { - + // Store userService for internal use (only used for Shizuku mode via shell commands) + private val userService: IUserService? = userService + + init { + // At least one capture method must be available + require(userService != null || screenshotProvider != null) { + "Either userService or screenshotProvider must be provided" + } + } + companion object { private const val TAG = "ScreenshotService" private const val HIDE_DELAY_MS = 200L private const val SHOW_DELAY_MS = 100L private const val FALLBACK_WIDTH = 1080 private const val FALLBACK_HEIGHT = 1920 - + // Screenshot compression settings - optimized for API upload private const val WEBP_QUALITY = 65 // Reduced from 70 for better compression - + // Screenshot scaling settings - use max dimensions instead of fixed scale factor // This ensures consistent output size regardless of device resolution private const val MAX_WIDTH = 720 // Max width after scaling private const val MAX_HEIGHT = 1280 // Max height after scaling - + // Base64 output chunk size for reading (safe for Binder) private const val BASE64_CHUNK_SIZE = 500000 } - + /** * Captures the current screen content. * @@ -115,7 +126,7 @@ class ScreenshotService( val floatingWindowController = floatingWindowControllerProvider() val hasFloatingWindow = floatingWindowController != null Logger.d(TAG, "Starting screenshot capture, window visible: ${floatingWindowController?.isVisible()}") - + // Hide floating window before capture if (hasFloatingWindow) { Logger.d(TAG, "Hiding floating window") @@ -124,7 +135,7 @@ class ScreenshotService( } delay(HIDE_DELAY_MS) } - + try { // Capture screenshot val result = captureScreen() @@ -145,7 +156,7 @@ class ScreenshotService( } } } - + /** * Captures the screen using Shizuku shell command. * @@ -156,31 +167,43 @@ class ScreenshotService( * */ private suspend fun captureScreen(): Screenshot = withContext(Dispatchers.IO) { + // Use screenshotProvider if available (Accessibility mode) + if (screenshotProvider != null) { + Logger.d(TAG, "Using screenshot provider (Accessibility mode)") + return@withContext try { + screenshotProvider.invoke() + } catch (e: Exception) { + Logger.e(TAG, "Screenshot provider failed, using fallback", e) + createFallbackScreenshot() + } + } + + // Otherwise use shell command method (Shizuku mode) try { - Logger.d(TAG, "Executing screencap command") - + Logger.d(TAG, "Executing screencap command (Shizuku mode)") + val pngData = executeScreencapToBytes() - + if (pngData == null || pngData.isEmpty()) { Logger.w(TAG, "Failed to capture screenshot, returning fallback") return@withContext createFallbackScreenshot() } - + Logger.d(TAG, "PNG data captured: ${pngData.size} bytes") - + // Decode PNG to bitmap var bitmap = BitmapFactory.decodeByteArray(pngData, 0, pngData.size) if (bitmap == null) { Logger.w(TAG, "Failed to decode PNG, returning fallback") return@withContext createFallbackScreenshot() } - + val originalWidth = bitmap.width val originalHeight = bitmap.height - + // Calculate scaled dimensions based on max size constraints val (scaledWidth, scaledHeight) = calculateOptimalDimensions(originalWidth, originalHeight) - + // Scale bitmap if needed if (scaledWidth != originalWidth || scaledHeight != originalHeight) { val scaledBitmap = Bitmap.createScaledBitmap(bitmap, scaledWidth, scaledHeight, true) @@ -188,19 +211,25 @@ class ScreenshotService( bitmap = scaledBitmap Logger.d(TAG, "Scaled from ${originalWidth}x${originalHeight} to ${scaledWidth}x${scaledHeight}") } - + // Convert to WebP for better compression val webpStream = ByteArrayOutputStream() - bitmap.compress(Bitmap.CompressFormat.WEBP_LOSSY, WEBP_QUALITY, webpStream) + val compressFormat = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Bitmap.CompressFormat.WEBP_LOSSY + } else { + @Suppress("DEPRECATION") + Bitmap.CompressFormat.WEBP + } + bitmap.compress(compressFormat, WEBP_QUALITY, webpStream) bitmap.recycle() - + val webpData = webpStream.toByteArray() val compressionRatio = if (pngData.isNotEmpty()) 100 * webpData.size / pngData.size else 0 Logger.d(TAG, "Converted to WebP: ${webpData.size} bytes ($compressionRatio% of PNG)") - + val base64Data = encodeToBase64(webpData) Logger.d(TAG, "Screenshot captured: ${scaledWidth}x${scaledHeight}, base64 length: ${base64Data.length}") - + Screenshot( base64Data = base64Data, width = scaledWidth, @@ -214,7 +243,7 @@ class ScreenshotService( createFallbackScreenshot() } } - + /** * Calculates optimal dimensions based on max size constraints. * @@ -232,21 +261,21 @@ class ScreenshotService( Logger.d(TAG, "Image already within limits: ${originalWidth}x${originalHeight}") return originalWidth to originalHeight } - + // Calculate scale ratios for both dimensions val widthRatio = MAX_WIDTH.toFloat() / originalWidth val heightRatio = MAX_HEIGHT.toFloat() / originalHeight - + // Use the smaller ratio to ensure both dimensions fit within limits val ratio = minOf(widthRatio, heightRatio) - + val scaledWidth = (originalWidth * ratio).toInt() val scaledHeight = (originalHeight * ratio).toInt() - + Logger.d(TAG, "Scaling with ratio $ratio: ${originalWidth}x${originalHeight} -> ${scaledWidth}x${scaledHeight}") return scaledWidth to scaledHeight } - + /** * Executes screencap command and returns raw bytes. * @@ -257,106 +286,64 @@ class ScreenshotService( * */ private suspend fun executeScreencapToBytes(): ByteArray? = coroutineScope { - val timestamp = System.currentTimeMillis() - val pngFile = "/data/local/tmp/screenshot_$timestamp.png" - val base64File = "$pngFile.b64" - + val service = userService ?: run { + Logger.w(TAG, "UserService not available for shell command screenshot") + return@coroutineScope null + } + try { - Logger.d(TAG, "Attempting screenshot capture") + Logger.d(TAG, "Attempting screenshot capture (streamed, no temp files)") val startTime = System.currentTimeMillis() - - // Capture screenshot and pipe to base64 - val captureResult = userService.executeCommand( - "screencap -p | base64 > $base64File && stat -c %s $base64File" - ) - - val captureTime = System.currentTimeMillis() - startTime - Logger.d(TAG, "Screenshot capture took ${captureTime}ms") - - // Check for errors - if (captureResult.contains("Error") || captureResult.contains("permission denied", ignoreCase = true)) { - Logger.w(TAG, "Screenshot capture failed: $captureResult") - return@coroutineScope null - } - - // Parse base64 file size from output - val base64Size = captureResult.lines() - .firstOrNull { it.trim().all { c -> c.isDigit() } } - ?.trim()?.toLongOrNull() ?: 0L - - if (base64Size == 0L) { - Logger.w(TAG, "Base64 file not created or empty") + + // Trigger capture in the service and get total base64 size + val totalSize = service.captureScreenshotAndGetSize() + if (totalSize <= 0) { + Logger.w(TAG, "Screenshot capture failed or returned empty data") return@coroutineScope null } - - Logger.d(TAG, "Base64 file size: $base64Size bytes") - - // Read base64 file + val readStartTime = System.currentTimeMillis() - val base64Data: String - - if (base64Size <= BASE64_CHUNK_SIZE) { - // Small file - read in one go - val result = userService.executeCommand("cat $base64File") - base64Data = result.lines() - .filter { line -> - !line.startsWith("[") && - line.isNotBlank() && - !line.contains("exit code", ignoreCase = true) - } - .joinToString("") - } else { - // Large file - read chunks sequentially to avoid Binder buffer overflow - val chunkCount = ((base64Size + BASE64_CHUNK_SIZE - 1) / BASE64_CHUNK_SIZE).toInt() - Logger.d(TAG, "Reading $chunkCount chunks sequentially") - - val chunks = mutableListOf() - - for (index in 0 until chunkCount) { - val offset = index.toLong() * BASE64_CHUNK_SIZE - val remaining = base64Size - offset - val currentChunkSize = minOf(BASE64_CHUNK_SIZE.toLong(), remaining) - - val chunkResult = userService.executeCommand( - "tail -c +${offset + 1} $base64File | head -c $currentChunkSize" - ) - - val chunkData = chunkResult.lines() - .filter { line -> - !line.startsWith("[") && - line.isNotBlank() && - !line.contains("exit code", ignoreCase = true) - } - .joinToString("") - - chunks.add(chunkData) + val chunkCount = ((totalSize + BASE64_CHUNK_SIZE - 1) / BASE64_CHUNK_SIZE) + Logger.d(TAG, "Reading $chunkCount chunks sequentially, total size: $totalSize") + + val chunks = StringBuilder(totalSize) + var offset = 0 + for (i in 0 until chunkCount) { + val currentChunkSize = kotlin.math.min(BASE64_CHUNK_SIZE, totalSize - offset) + val chunk = service.readScreenshotChunk(offset, currentChunkSize) + if (chunk.isEmpty()) { + Logger.w(TAG, "Empty chunk at index $i, offset=$offset size=$currentChunkSize") + // Bail out and return null to fallback + return@coroutineScope null } - - base64Data = chunks.joinToString("") + chunks.append(chunk) + offset += currentChunkSize } - + + val base64Data = chunks.toString() val readTime = System.currentTimeMillis() - readStartTime - Logger.d(TAG, "Base64 read took ${readTime}ms, total length: ${base64Data.length}") - + val captureTime = System.currentTimeMillis() - startTime + Logger.d(TAG, "Base64 read took ${readTime}ms, capture total ${captureTime}ms, length=${base64Data.length}") + if (base64Data.isBlank()) { Logger.w(TAG, "No base64 data read") return@coroutineScope null } - + Base64.decode(base64Data, Base64.DEFAULT) } catch (e: Exception) { - Logger.e(TAG, "Failed to capture screenshot to bytes", e) + Logger.e(TAG, "Failed to capture screenshot to bytes (stream)", e) null } finally { - // Clean up temp files + // Always clear internal buffer after reading try { - userService.executeCommand("rm -f $pngFile $base64File") + service.clearScreenshotBuffer() } catch (_: Exception) { // Ignore cleanup errors } } } - + /** * Creates a fallback black screenshot in WebP format. * @@ -368,13 +355,19 @@ class ScreenshotService( */ private fun createFallbackScreenshot(): Screenshot { val bitmap = Bitmap.createBitmap(FALLBACK_WIDTH, FALLBACK_HEIGHT, Bitmap.Config.ARGB_8888) - + val outputStream = ByteArrayOutputStream() - bitmap.compress(Bitmap.CompressFormat.WEBP_LOSSY, WEBP_QUALITY, outputStream) + val compressFormat = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Bitmap.CompressFormat.WEBP_LOSSY + } else { + @Suppress("DEPRECATION") + Bitmap.CompressFormat.WEBP + } + bitmap.compress(compressFormat, WEBP_QUALITY, outputStream) bitmap.recycle() - + val base64Data = Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) - + return Screenshot( base64Data = base64Data, width = FALLBACK_WIDTH, @@ -382,7 +375,7 @@ class ScreenshotService( isSensitive = true ) } - + /** * Encodes byte array to base64 string. * @@ -393,7 +386,7 @@ class ScreenshotService( fun encodeToBase64(data: ByteArray): String { return Base64.encodeToString(data, Base64.NO_WRAP) } - + /** * Decodes base64 string to byte array. * @@ -404,7 +397,7 @@ class ScreenshotService( fun decodeFromBase64(base64Data: String): ByteArray { return Base64.decode(base64Data, Base64.DEFAULT) } - + /** * Decodes base64 screenshot data to a Bitmap. * @@ -420,7 +413,7 @@ class ScreenshotService( null } } - + /** * Encodes a Bitmap to base64 string. * @@ -432,14 +425,20 @@ class ScreenshotService( */ fun encodeBitmapToBase64( bitmap: Bitmap, - format: Bitmap.CompressFormat = Bitmap.CompressFormat.WEBP_LOSSY, + format: Bitmap.CompressFormat? = null, quality: Int = WEBP_QUALITY ): String { + val compressFormat = format ?: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Bitmap.CompressFormat.WEBP_LOSSY + } else { + @Suppress("DEPRECATION") + Bitmap.CompressFormat.WEBP + } val outputStream = ByteArrayOutputStream() - bitmap.compress(format, quality, outputStream) + bitmap.compress(compressFormat, quality, outputStream) return encodeToBase64(outputStream.toByteArray()) } - + /** * Creates a Screenshot object from a Bitmap. * diff --git a/app/src/main/java/com/kevinluo/autoglm/settings/SettingsActivity.kt b/app/src/main/java/com/kevinluo/autoglm/settings/SettingsActivity.kt index 1f14a08..7d171ef 100644 --- a/app/src/main/java/com/kevinluo/autoglm/settings/SettingsActivity.kt +++ b/app/src/main/java/com/kevinluo/autoglm/settings/SettingsActivity.kt @@ -22,6 +22,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.kevinluo.autoglm.R import com.kevinluo.autoglm.agent.AgentConfig +import com.kevinluo.autoglm.device.DeviceControlMode import com.kevinluo.autoglm.model.ModelClient import com.kevinluo.autoglm.model.ModelConfig import com.kevinluo.autoglm.util.LogFileManager @@ -41,7 +42,12 @@ import kotlinx.coroutines.launch class SettingsActivity : AppCompatActivity() { private lateinit var settingsManager: SettingsManager - + + // Device control mode views + private lateinit var controlModeRadioGroup: RadioGroup + private lateinit var controlModeShizuku: RadioButton + private lateinit var controlModeAccessibility: RadioButton + // Profile selector views private lateinit var profileSelectorLayout: TextInputLayout private lateinit var profileSelector: AutoCompleteTextView @@ -115,6 +121,11 @@ class SettingsActivity : AppCompatActivity() { * Initializes all view references. */ private fun initViews() { + // Device control mode + controlModeRadioGroup = findViewById(R.id.controlModeRadioGroup) + controlModeShizuku = findViewById(R.id.controlModeShizuku) + controlModeAccessibility = findViewById(R.id.controlModeAccessibility) + // Profile selector profileSelectorLayout = findViewById(R.id.profileSelectorLayout) profileSelector = findViewById(R.id.profileSelector) @@ -177,7 +188,14 @@ class SettingsActivity : AppCompatActivity() { Logger.d(TAG, "Loading current settings") val modelConfig = settingsManager.getModelConfig() val agentConfig = settingsManager.getAgentConfig() - + val controlMode = settingsManager.getDeviceControlMode() + + // Load device control mode + when (controlMode) { + DeviceControlMode.SHIZUKU -> controlModeShizuku.isChecked = true + DeviceControlMode.ACCESSIBILITY -> controlModeAccessibility.isChecked = true + } + // Load saved profiles loadSavedProfiles() @@ -316,6 +334,16 @@ class SettingsActivity : AppCompatActivity() { modelNameInput.setOnFocusChangeListener { _, _ -> modelNameLayout.error = null } maxStepsInput.setOnFocusChangeListener { _, _ -> maxStepsLayout.error = null } screenshotDelayInput.setOnFocusChangeListener { _, _ -> screenshotDelayLayout.error = null } + + // Device control mode change listener + controlModeRadioGroup.setOnCheckedChangeListener { _, checkedId -> + val newMode = when (checkedId) { + R.id.controlModeShizuku -> DeviceControlMode.SHIZUKU + R.id.controlModeAccessibility -> DeviceControlMode.ACCESSIBILITY + else -> return@setOnCheckedChangeListener + } + Logger.d(TAG, "Device control mode changed to: ${newMode.name}") + } } /** @@ -585,14 +613,22 @@ class SettingsActivity : AppCompatActivity() { Logger.i(TAG, "Saving settings") val baseUrl = baseUrlInput.text?.toString()?.trim() ?: "" val modelName = modelNameInput.text?.toString()?.trim() ?: "" - val apiKey = apiKeyInput.text?.toString()?.trim().let { - if (it.isNullOrEmpty()) "EMPTY" else it + val apiKey = apiKeyInput.text?.toString()?.trim().let { + if (it.isNullOrEmpty()) "EMPTY" else it } val maxSteps = maxStepsInput.text?.toString()?.trim()?.toIntOrNull() ?: 100 val screenshotDelaySeconds = screenshotDelayInput.text?.toString()?.trim()?.toDoubleOrNull() ?: 2.0 val screenshotDelayMs = (screenshotDelaySeconds * 1000).toLong() val language = if (languageEnglish.isChecked) "en" else "cn" - + + // Save device control mode + val controlMode = when (controlModeRadioGroup.checkedRadioButtonId) { + R.id.controlModeShizuku -> DeviceControlMode.SHIZUKU + R.id.controlModeAccessibility -> DeviceControlMode.ACCESSIBILITY + else -> DeviceControlMode.SHIZUKU + } + settingsManager.saveDeviceControlMode(controlMode) + // Create and save model config val modelConfig = ModelConfig( baseUrl = baseUrl, @@ -600,7 +636,7 @@ class SettingsActivity : AppCompatActivity() { modelName = modelName ) settingsManager.saveModelConfig(modelConfig) - + // Create and save agent config val agentConfig = AgentConfig( maxSteps = maxSteps, @@ -608,7 +644,7 @@ class SettingsActivity : AppCompatActivity() { screenshotDelayMs = screenshotDelayMs ) settingsManager.saveAgentConfig(agentConfig) - + Toast.makeText(this, R.string.settings_saved, Toast.LENGTH_SHORT).show() finish() } diff --git a/app/src/main/java/com/kevinluo/autoglm/settings/SettingsManager.kt b/app/src/main/java/com/kevinluo/autoglm/settings/SettingsManager.kt index 9c8dc5e..545ffd0 100644 --- a/app/src/main/java/com/kevinluo/autoglm/settings/SettingsManager.kt +++ b/app/src/main/java/com/kevinluo/autoglm/settings/SettingsManager.kt @@ -6,6 +6,7 @@ import android.os.Build import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import com.kevinluo.autoglm.agent.AgentConfig +import com.kevinluo.autoglm.device.DeviceControlMode import com.kevinluo.autoglm.model.ModelConfig import com.kevinluo.autoglm.util.Logger import org.json.JSONArray @@ -95,7 +96,10 @@ class SettingsManager(private val context: Context) { // Dev profiles import key private const val KEY_DEV_PROFILES_IMPORTED = "dev_profiles_imported" - + + // Device control mode key + private const val KEY_DEVICE_CONTROL_MODE = "device_control_mode" + // Default values private val DEFAULT_MODEL_CONFIG = ModelConfig() private val DEFAULT_AGENT_CONFIG = AgentConfig() @@ -104,6 +108,7 @@ class SettingsManager(private val context: Context) { // Cache for detecting config changes private var lastModelConfig: ModelConfig? = null private var lastAgentConfig: AgentConfig? = null + private var lastDeviceControlMode: DeviceControlMode? = null // Regular preferences for non-sensitive data private val prefs: SharedPreferences by lazy { @@ -285,13 +290,17 @@ class SettingsManager(private val context: Context) { fun hasConfigChanged(): Boolean { val currentModelConfig = getModelConfig() val currentAgentConfig = getAgentConfig() - - val changed = lastModelConfig != currentModelConfig || lastAgentConfig != currentAgentConfig - + val currentDeviceControlMode = getDeviceControlMode() + + val changed = lastModelConfig != currentModelConfig || + lastAgentConfig != currentAgentConfig || + lastDeviceControlMode != currentDeviceControlMode + // Update cache lastModelConfig = currentModelConfig lastAgentConfig = currentAgentConfig - + lastDeviceControlMode = currentDeviceControlMode + return changed } @@ -689,4 +698,42 @@ class SettingsManager(private val context: Context) { -1 } } + + // ==================== Device Control Mode ==================== + + /** + * Gets the current device control mode. + * + * Returns SHIZUKU as default if not previously set. + * + * @return The current device control mode + */ + fun getDeviceControlMode(): DeviceControlMode { + val modeName = prefs.getString(KEY_DEVICE_CONTROL_MODE, DeviceControlMode.SHIZUKU.name) + return try { + DeviceControlMode.valueOf(modeName ?: DeviceControlMode.SHIZUKU.name) + } catch (e: Exception) { + Logger.w(TAG, "Invalid device control mode: $modeName, using SHIZUKU") + DeviceControlMode.SHIZUKU + } + } + + /** + * Saves the device control mode. + * + * @param mode The device control mode to save + */ + fun saveDeviceControlMode(mode: DeviceControlMode) { + Logger.d(TAG, "Saving device control mode: ${mode.name}") + prefs.edit().putString(KEY_DEVICE_CONTROL_MODE, mode.name).apply() + } + + /** + * Gets the display name of the current device control mode. + * + * @return The display name (e.g., "Shizuku" or "无障碍服务") + */ + fun getDeviceControlModeDisplayName(): String { + return getDeviceControlMode().displayName + } } diff --git a/app/src/main/java/com/kevinluo/autoglm/ui/FloatingWindowService.kt b/app/src/main/java/com/kevinluo/autoglm/ui/FloatingWindowService.kt index 2f1fd1d..c236879 100644 --- a/app/src/main/java/com/kevinluo/autoglm/ui/FloatingWindowService.kt +++ b/app/src/main/java/com/kevinluo/autoglm/ui/FloatingWindowService.kt @@ -775,7 +775,9 @@ class FloatingWindowService : Service(), FloatingWindowController { gravity = Gravity.TOP or Gravity.START x = 0 y = 0 - // Allow keyboard input + // Allow keyboard input - Note: SOFT_INPUT_ADJUST_RESIZE is deprecated but still + // required for floating windows to resize when keyboard is shown + @Suppress("DEPRECATION") softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE } } diff --git a/app/src/main/java/com/kevinluo/autoglm/ui/FloatingWindowTileService.kt b/app/src/main/java/com/kevinluo/autoglm/ui/FloatingWindowTileService.kt index 8cf79ec..1521690 100644 --- a/app/src/main/java/com/kevinluo/autoglm/ui/FloatingWindowTileService.kt +++ b/app/src/main/java/com/kevinluo/autoglm/ui/FloatingWindowTileService.kt @@ -47,7 +47,7 @@ class FloatingWindowTileService : TileService() { } startActivityAndCollapseCompat(intent) } - + /** * Compatibility wrapper for startActivityAndCollapse. * API 34+ requires PendingIntent, older versions use Intent directly. @@ -64,7 +64,7 @@ class FloatingWindowTileService : TileService() { startActivityAndCollapse(pendingIntent) } else { // API < 34 - @Suppress("DEPRECATION") + @Suppress("StartActivityAndCollapseDeprecated") startActivityAndCollapse(intent) } } @@ -88,14 +88,14 @@ class FloatingWindowTileService : TileService() { private fun updateTileState() { val tile = qsTile ?: return - + val service = FloatingWindowService.getInstance() val isVisible = service?.isVisible() == true - + tile.state = if (isVisible) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE tile.label = getString(com.kevinluo.autoglm.R.string.tile_floating_window) tile.contentDescription = getString(com.kevinluo.autoglm.R.string.tile_floating_window_desc) - + tile.updateTile() } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index ec9eaff..6ffee13 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -59,14 +59,20 @@ android:paddingHorizontal="16dp" android:paddingBottom="32dp"> - - + + android:layout_marginBottom="12dp"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + AutoGLM For Android - + Shizuku 状态 未知 @@ -10,26 +10,26 @@ 已连接 授权 打开 Shizuku - + 悬浮窗权限 已授权 未授权 授权 - + 输入法 已启用 未启用 去启用 需要启用 AutoGLM Keyboard 才能输入文字 - + 任务输入 描述你想要执行的任务... 启动 停止 - + 状态 空闲 @@ -42,12 +42,12 @@ 步骤: %d 暂停 继续 - + 执行日志 查看任务执行的详细日志 日志将显示在这里... - + 请先启动 Shizuku Shizuku 版本过低 @@ -61,7 +61,8 @@ 任务已开始 任务已取消 需要悬浮窗权限 - + 后台服务需要通知权限 + 设置 模型配置 @@ -108,7 +109,7 @@ 连接失败: %s 连接超时: %s 请先填写完整的配置信息 - + 任务模板 保存常用任务,快速填入 @@ -124,7 +125,7 @@ 确定要删除此模板吗? 暂无模板 选择模板 - + 高级设置 自定义系统提示词 @@ -139,7 +140,7 @@ 已重置为默认提示词 已自定义 默认 - + 确认操作 确定要执行此操作吗? @@ -147,13 +148,13 @@ 取消 选择选项 请选择以下选项之一 - + 需要手动操作 请完成以下操作 操作说明将显示在这里 已完成,继续 - + 思考 操作 @@ -164,15 +165,15 @@ 等待确认 打开悬浮窗 新任务 - + 悬浮窗 历史记录 - + AutoGLM 显示/隐藏悬浮窗 - + 历史记录 任务详情 @@ -204,7 +205,7 @@ 提示词已复制到剪贴板 加载更多 加载更多 (剩余 %d 步) - + 调试日志 导出日志用于问题排查 @@ -215,11 +216,28 @@ 导出失败 暂无日志 确定要清空所有日志吗? - + AutoGLM Keyboard 就绪 文本输入由 AutoGLM 控制 AutoGLM 键盘 已启用 + + + AutoGLM 无障碍服务,用于辅助残障人士进行设备自动化控制。 + 无障碍权限 + 已开启 + 未开启 + 去开启 + 无障碍模式不支持 Android 13 以下版本 + + + 设备控制模式 + 选择控制设备的方式 + Shizuku(推荐) + 无障碍服务 + 需要安装 Shizuku 应用,功能完整,兼容性好 + 无需安装额外应用,需要 Android 13+ + 控制模式已更改,请重启应用以生效 diff --git a/app/src/main/res/xml/accessibility_service_config.xml b/app/src/main/res/xml/accessibility_service_config.xml new file mode 100644 index 0000000..8e4efdb --- /dev/null +++ b/app/src/main/res/xml/accessibility_service_config.xml @@ -0,0 +1,26 @@ + + + + diff --git a/app/src/test/java/com/kevinluo/autoglm/app/AppResolverPropertyTest.kt b/app/src/test/java/com/kevinluo/autoglm/app/AppResolverPropertyTest.kt index cb1e11a..f55e6b0 100644 --- a/app/src/test/java/com/kevinluo/autoglm/app/AppResolverPropertyTest.kt +++ b/app/src/test/java/com/kevinluo/autoglm/app/AppResolverPropertyTest.kt @@ -7,42 +7,42 @@ import io.kotest.property.forAll /** * Property-based tests for AppResolver. - * + * * **Feature: autoglm-phone-agent, Property 20: App name resolution consistency** * **Validates: Requirements 9.1** - * + * * These tests verify the core similarity calculation and matching logic * of the AppResolver without requiring Android framework dependencies. */ class AppResolverPropertyTest : StringSpec({ - + /** * Property 20: App name resolution consistency - * + * * For any installed app with a known display name, querying the AppResolver * with that exact name should return the correct package name. - * + * * Since we can't use PackageManager in unit tests, we test the underlying * similarity calculation logic which is the core of the resolution algorithm. - * + * * **Validates: Requirements 9.1** */ - + // Test that exact matches always return similarity of 1.0 "exact match should always return similarity of 1.0" { val stringArb = Arb.string(1..50, Codepoint.alphanumeric()) - + forAll(100, stringArb) { name -> val resolver = createTestableAppResolver() val similarity = resolver.calculateSimilarity(name.lowercase(), name.lowercase()) similarity == 1.0 } } - + // Test that similarity is symmetric for Levenshtein distance "levenshtein distance should be symmetric" { val stringArb = Arb.string(0..30, Codepoint.alphanumeric()) - + forAll(100, stringArb, stringArb) { s1, s2 -> val resolver = createTestableAppResolver() val dist1 = resolver.levenshteinDistance(s1, s2) @@ -50,33 +50,33 @@ class AppResolverPropertyTest : StringSpec({ dist1 == dist2 } } - + // Test that levenshtein distance is non-negative "levenshtein distance should be non-negative" { val stringArb = Arb.string(0..30, Codepoint.alphanumeric()) - + forAll(100, stringArb, stringArb) { s1, s2 -> val resolver = createTestableAppResolver() val distance = resolver.levenshteinDistance(s1, s2) distance >= 0 } } - + // Test that levenshtein distance is zero only for identical strings "levenshtein distance should be zero only for identical strings" { val stringArb = Arb.string(0..30, Codepoint.alphanumeric()) - + forAll(100, stringArb) { s -> val resolver = createTestableAppResolver() val distance = resolver.levenshteinDistance(s, s) distance == 0 } } - + // Test triangle inequality for levenshtein distance "levenshtein distance should satisfy triangle inequality" { val stringArb = Arb.string(0..20, Codepoint.alphanumeric()) - + forAll(100, stringArb, stringArb, stringArb) { s1, s2, s3 -> val resolver = createTestableAppResolver() val d12 = resolver.levenshteinDistance(s1, s2) @@ -85,46 +85,49 @@ class AppResolverPropertyTest : StringSpec({ d13 <= d12 + d23 } } - + // Test that similarity is always in range [0, 1] "similarity should always be in range 0 to 1" { val stringArb = Arb.string(0..30, Codepoint.alphanumeric()) - + forAll(100, stringArb, stringArb) { query, target -> val resolver = createTestableAppResolver() val similarity = resolver.calculateSimilarity(query.lowercase(), target.lowercase()) similarity >= 0.0 && similarity <= 1.0 } } - + // Test that contains match has higher similarity than fuzzy match "contains match should have higher similarity than non-contains fuzzy match" { val prefixArb = Arb.string(1..10, Codepoint.alphanumeric()) val suffixArb = Arb.string(1..10, Codepoint.alphanumeric()) val queryArb = Arb.string(3..15, Codepoint.alphanumeric()) - + forAll(100, prefixArb, queryArb, suffixArb) { prefix, query, suffix -> val resolver = createTestableAppResolver() val normalizedQuery = query.lowercase() - + // Target that contains the query val containsTarget = (prefix + query + suffix).lowercase() - + // Target that doesn't contain the query (completely different) val differentTarget = "zzz${prefix}zzz".lowercase() - + + // Skip valid match cases in the "different" target + if (differentTarget.contains(normalizedQuery)) return@forAll true + val containsSimilarity = resolver.calculateSimilarity(normalizedQuery, containsTarget) val differentSimilarity = resolver.calculateSimilarity(normalizedQuery, differentTarget) - + // Contains match should score higher (unless different target happens to be similar) containsSimilarity >= differentSimilarity || differentSimilarity < AppResolver.MIN_SIMILARITY_THRESHOLD } } - + // Test that levenshtein distance to empty string equals string length "levenshtein distance to empty string should equal string length" { val stringArb = Arb.string(0..30, Codepoint.alphanumeric()) - + forAll(100, stringArb) { s -> val resolver = createTestableAppResolver() val distanceToEmpty = resolver.levenshteinDistance(s, "") @@ -132,19 +135,19 @@ class AppResolverPropertyTest : StringSpec({ distanceToEmpty == s.length && distanceFromEmpty == s.length } } - + // Test that startsWith match has high similarity "startsWith match should have high similarity" { val queryArb = Arb.string(3..15, Codepoint.alphanumeric()) val suffixArb = Arb.string(1..10, Codepoint.alphanumeric()) - + forAll(100, queryArb, suffixArb) { query, suffix -> val resolver = createTestableAppResolver() val normalizedQuery = query.lowercase() val target = (query + suffix).lowercase() - + val similarity = resolver.calculateSimilarity(normalizedQuery, target) - + // StartsWith should have similarity >= 0.75 similarity >= 0.75 } @@ -153,7 +156,7 @@ class AppResolverPropertyTest : StringSpec({ /** * Creates a testable AppResolver instance. - * + * * Since AppResolver requires PackageManager which is an Android system service, * we create a minimal mock that allows us to test the core similarity logic. */ @@ -164,12 +167,12 @@ private fun createTestableAppResolver(): TestableAppResolver { /** * A testable version of AppResolver that exposes the internal similarity * calculation methods for property-based testing. - * + * * This class duplicates the core logic from AppResolver to enable testing * without Android framework dependencies. */ class TestableAppResolver { - + /** * Calculates the similarity between two strings using a combination of: * 1. Exact match (highest priority) @@ -182,57 +185,57 @@ class TestableAppResolver { if (query == target) { return 1.0 } - + // Target contains query exactly if (target.contains(query)) { val coverageScore = query.length.toDouble() / target.length return 0.8 + (coverageScore * 0.15) } - + // Target starts with query if (target.startsWith(query)) { val coverageScore = query.length.toDouble() / target.length return 0.75 + (coverageScore * 0.15) } - + // Query starts with target if (query.startsWith(target)) { val coverageScore = target.length.toDouble() / query.length return 0.7 + (coverageScore * 0.15) } - + // Fuzzy matching using Levenshtein distance val distance = levenshteinDistance(query, target) val maxLength = maxOf(query.length, target.length) - + if (maxLength == 0) { return 0.0 } - + val similarity = 1.0 - (distance.toDouble() / maxLength) return similarity * 0.7 } - + /** * Calculates the Levenshtein distance between two strings. */ fun levenshteinDistance(s1: String, s2: String): Int { val m = s1.length val n = s2.length - + if (m == 0) return n if (n == 0) return m - + val dp = Array(m + 1) { IntArray(n + 1) } - + for (i in 0..m) { dp[i][0] = i } - + for (j in 0..n) { dp[0][j] = j } - + for (i in 1..m) { for (j in 1..n) { val cost = if (s1[i - 1] == s2[j - 1]) 0 else 1 @@ -243,10 +246,10 @@ class TestableAppResolver { ) } } - + return dp[m][n] } - + companion object { const val MIN_SIMILARITY_THRESHOLD = 0.3 }