diff --git a/app/build.gradle b/app/build.gradle index 78d2fac..dce0baf 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,18 +2,18 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' id 'kotlin-android' - id 'kotlin-android-extensions' + id 'com.google.gms.google-services' id 'com.google.firebase.crashlytics' } android { - compileSdk 33 + compileSdk 36 defaultConfig { applicationId 'app.screenreader' minSdk 24 - targetSdk 33 + targetSdk 36 versionCode 9 versionName '1.1.0' } @@ -29,11 +29,11 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '1.8' + jvmTarget = '17' } namespace 'app.screenreader' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a0ed5a4..1750a72 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -31,5 +31,18 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/app/screenreader/extensions/_Context.kt b/app/src/main/java/app/screenreader/extensions/_Context.kt index 317d6a1..956dd7a 100644 --- a/app/src/main/java/app/screenreader/extensions/_Context.kt +++ b/app/src/main/java/app/screenreader/extensions/_Context.kt @@ -1,8 +1,13 @@ package app.screenreader.extensions +import android.annotation.SuppressLint import android.app.Activity +import android.content.BroadcastReceiver import android.content.Context +import android.content.IntentFilter import android.net.Uri +import android.os.Build +import android.os.Build.VERSION.SDK_INT import android.os.LocaleList import android.text.SpannableString import android.text.Spanned @@ -164,4 +169,23 @@ fun Context.openWebsite(uri: Uri) { .build() intent.launchUrl(this, uri) +} + +@SuppressLint("UnspecifiedRegisterReceiverFlag") +fun Context.registerBroadcastReceiver( + broadcastReceiver: BroadcastReceiver, + intentFilter: IntentFilter?, + exported: Boolean = true, +) { + + when { + SDK_INT >= Build.VERSION_CODES.TIRAMISU -> { + val exportedFlag = + if (exported) Context.RECEIVER_EXPORTED else Context.RECEIVER_NOT_EXPORTED + + registerReceiver(broadcastReceiver, intentFilter, exportedFlag) + } + + else -> registerReceiver(broadcastReceiver, intentFilter) + } } \ No newline at end of file diff --git a/app/src/main/java/app/screenreader/model/Constants.kt b/app/src/main/java/app/screenreader/model/Constants.kt index 88b4d65..84d3619 100644 --- a/app/src/main/java/app/screenreader/model/Constants.kt +++ b/app/src/main/java/app/screenreader/model/Constants.kt @@ -11,5 +11,6 @@ class Constants { val SERVICE_ACTION = "SCREENREADER_SERVICE" val SERVICE_GESTURE = "GESTURE" val SERVICE_KILLED = "KILLED" + val SERVICE_MOTION_EVENT = "MOTION_EVENT" } } \ No newline at end of file diff --git a/app/src/main/java/app/screenreader/services/ScreenReaderService.kt b/app/src/main/java/app/screenreader/services/ScreenReaderService.kt index 7a10e0d..1248a58 100644 --- a/app/src/main/java/app/screenreader/services/ScreenReaderService.kt +++ b/app/src/main/java/app/screenreader/services/ScreenReaderService.kt @@ -2,6 +2,7 @@ package app.screenreader.services import android.accessibilityservice.AccessibilityService import android.accessibilityservice.AccessibilityServiceInfo +import android.accessibilityservice.TouchInteractionController import android.app.ActivityManager import android.content.Context import android.content.Intent @@ -10,6 +11,7 @@ import android.os.Build import android.provider.Settings import android.util.Log import android.view.KeyEvent +import android.view.MotionEvent import android.view.WindowManager import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityManager @@ -18,6 +20,8 @@ import androidx.core.content.ContextCompat import androidx.core.hardware.display.DisplayManagerCompat import app.screenreader.MainActivity import app.screenreader.R +import app.screenreader.tabs.actions.ActionActivity +import app.screenreader.tabs.gestures.GestureActivity import app.screenreader.extensions.getSpannable import app.screenreader.model.Constants import app.screenreader.model.Gesture @@ -34,16 +38,18 @@ import java.io.Serializable class ScreenReaderService: AccessibilityService() { private val TAG = "ScreenReaderService" - private val GESTURE_TRAINING_CLASS_NAME = MainActivity::class.java.name + private val MAIN_ACTIVITY_CLASS_NAME = MainActivity::class.java.name + private val GESTURE_ACTIVITY_CLASS_NAME = GestureActivity::class.java.name + private val ACTION_ACTIVITY_CLASS_NAME = ActionActivity::class.java.name + + private var touchController: TouchInteractionController? = null + private var touchControllerCallback: TouchInteractionController.Callback? = null override fun onCreate() { super.onCreate() Log.i(TAG, "onCreate") - // Set passthrough regions setPassthroughRegions() - - // Start GestureActivity startGestureTraining() } @@ -71,10 +77,95 @@ class ScreenReaderService: AccessibilityService() { override fun onServiceConnected() { Log.i(TAG, "Service connected") super.onServiceConnected() + + // Setup TouchInteractionController (API 32+) to intercept touch events + // and pass them through to the app via requestDelegating(), bypassing TalkBack's gesture handling + setupTouchInteractionController() + } + + private fun setupTouchInteractionController() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S_V2) { + try { + // Use default display (ID = 0) + val displayId = android.view.Display.DEFAULT_DISPLAY + + touchController = getTouchInteractionController(displayId) + Log.i(TAG, "Got TouchInteractionController for display $displayId") + + touchControllerCallback = object : TouchInteractionController.Callback { + override fun onMotionEvent(event: MotionEvent) { + Log.d(TAG, "TouchController onMotionEvent: action=${event.action}, pointerCount=${event.pointerCount}") + + // When gesture training is active, request delegating to pass ALL events + // through to the app without TalkBack processing them. + // This allows the app's gesture recognizers to handle both swipes and taps. + if (isGestureTraining()) { + touchController?.let { controller -> + if (controller.state == TouchInteractionController.STATE_TOUCH_INTERACTING) { + Log.d(TAG, "Requesting delegating mode to bypass TalkBack") + controller.requestDelegating() + } + } + } + } + + override fun onStateChanged(state: Int) { + val stateName = when (state) { + TouchInteractionController.STATE_CLEAR -> "CLEAR" + TouchInteractionController.STATE_TOUCH_INTERACTING -> "TOUCH_INTERACTING" + TouchInteractionController.STATE_TOUCH_EXPLORING -> "TOUCH_EXPLORING" + TouchInteractionController.STATE_DRAGGING -> "DRAGGING" + TouchInteractionController.STATE_DELEGATING -> "DELEGATING" + else -> "UNKNOWN($state)" + } + Log.d(TAG, "TouchController state changed to: $stateName") + } + } + + touchController?.registerCallback(mainExecutor, touchControllerCallback!!) + Log.i(TAG, "Registered TouchInteractionController callback") + + } catch (e: Exception) { + Log.e(TAG, "Failed to setup TouchInteractionController", e) + } + } + } + + /** + * Called when raw motion events are received from the configured motion event sources. + * On API 32+, we use TouchInteractionController with requestDelegating() instead, + * which passes events directly to the app's normal touch pipeline. + */ + override fun onMotionEvent(event: MotionEvent) { + Log.d(TAG, "onMotionEvent: action=${event.action}, pointerCount=${event.pointerCount}, x=${event.x}, y=${event.y}") + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S_V2) { + if (isGestureTraining()) { + broadcastMotionEvent(event) + } + } + } + + private fun broadcastMotionEvent(event: MotionEvent) { + val intent = Intent(Constants.SERVICE_ACTION) + intent.setPackage(packageName) + // MotionEvent must be copied because the original may be recycled + intent.putExtra(Constants.SERVICE_MOTION_EVENT, MotionEvent.obtain(event)) + sendBroadcast(intent) } override fun onUnbind(intent: Intent?): Boolean { Log.i(TAG, "onUnbind") + + // Cleanup TouchInteractionController + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S_V2) { + touchControllerCallback?.let { callback -> + touchController?.unregisterCallback(callback) + } + touchController = null + touchControllerCallback = null + } + return super.onUnbind(intent) } @@ -85,34 +176,28 @@ class ScreenReaderService: AccessibilityService() { override fun onAccessibilityEvent(event: AccessibilityEvent?) { Log.i(TAG, "onAccessibilityEvent: $event") - // Continue if eventType = TYPE_WINDOW_STATE_CHANGED if (event == null || event.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) { return } - // Continue if packageName is empty if (event.packageName == null || event.packageName.isEmpty()) { return } - // Continue if event does not come from own package + if (event.packageName == this.packageName) { return } - - // Continue if event does not come from accessibility package if (event.packageName.contains("accessibility")) { return } - // Continue if text does not contain the service label val serviceName = getString(R.string.service_label) if (event.text.contains(serviceName)) { return } - // Continue if the gesture training is not active - if (isGestureTraining()) { + if (isInApp()) { return } @@ -121,11 +206,16 @@ class ScreenReaderService: AccessibilityService() { } override fun onGesture(gestureId: Int): Boolean { - Log.i(TAG, "onGesture: $gestureId") + Log.i(TAG, "onGesture called with gestureId: $gestureId") // Broadcast gesture to GestureActivity - Gesture.from(gestureId)?.let { gesture -> + val gesture = Gesture.from(gestureId) + Log.i(TAG, "Mapped gestureId $gestureId to gesture: $gesture") + + if (gesture != null) { broadcast(Constants.SERVICE_GESTURE, gesture) + } else { + Log.w(TAG, "Unknown gestureId: $gestureId - not mapped to any Gesture") } // Kill service if touch exploration is disabled @@ -158,7 +248,6 @@ class ScreenReaderService: AccessibilityService() { val flags = service.capabilities val capability = AccessibilityServiceInfo.CAPABILITY_CAN_REQUEST_TOUCH_EXPLORATION - // Check if Touch Exploration capability is granted if (flags and capability == capability) { count++ } @@ -171,7 +260,19 @@ class ScreenReaderService: AccessibilityService() { private fun isGestureTraining(): Boolean { val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager activityManager.getRunningTasks(1).firstOrNull()?.topActivity?.let { activity -> - return activity.className == GESTURE_TRAINING_CLASS_NAME + // Check if the user is in the GestureActivity (where gesture training happens) + return activity.className == GESTURE_ACTIVITY_CLASS_NAME + } + return false + } + + private fun isInApp(): Boolean { + val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + activityManager.getRunningTasks(1).firstOrNull()?.topActivity?.let { activity -> + // Check if user is in any of the app's activities + return activity.className == MAIN_ACTIVITY_CLASS_NAME || + activity.className == GESTURE_ACTIVITY_CLASS_NAME || + activity.className == ACTION_ACTIVITY_CLASS_NAME } return false } @@ -181,15 +282,15 @@ class ScreenReaderService: AccessibilityService() { gestures.forEach { gesture -> gesture.completed(this, false) } - -// val intent = Intent(this, GestureActivity::class.java) -// intent.setGestures(gestures) -// intent.setInstructions(instructions) -// intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) -// startActivity(intent) } companion object { + const val MIN_API_FOR_TALKBACK_COMPATIBILITY = Build.VERSION_CODES.TIRAMISU + + fun supportsTalkBackCompatibility(): Boolean { + return Build.VERSION.SDK_INT >= MIN_API_FOR_TALKBACK_COMPATIBILITY + } + fun isEnabled(context: Context): Boolean { (context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager).let { manager -> val services = manager.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK) diff --git a/app/src/main/java/app/screenreader/tabs/actions/ActionActivity.kt b/app/src/main/java/app/screenreader/tabs/actions/ActionActivity.kt index bd0995a..56bb5f7 100644 --- a/app/src/main/java/app/screenreader/tabs/actions/ActionActivity.kt +++ b/app/src/main/java/app/screenreader/tabs/actions/ActionActivity.kt @@ -3,16 +3,15 @@ package app.screenreader.tabs.actions import android.accessibilityservice.AccessibilityServiceInfo import android.content.Intent import android.text.SpannableString +import android.widget.ScrollView import app.screenreader.R import app.screenreader.extensions.doGetAction -import app.screenreader.extensions.identifier import app.screenreader.extensions.showDialog import app.screenreader.helpers.Accessibility import app.screenreader.helpers.Events import app.screenreader.model.Action import app.screenreader.views.actions.ActionViewCallback import app.screenreader.widgets.ToolbarActivity -import kotlinx.android.synthetic.main.activity_action.* /** * Created by Jan Jaap de Groot on 16/11/2020 @@ -22,6 +21,7 @@ class ActionActivity: ToolbarActivity(), ActionViewCallback { private val startTime = System.currentTimeMillis() + private val scrollView get() = findViewById(R.id.scrollView) private val action: Action by lazy { intent.doGetAction() ?: Action.SELECT } diff --git a/app/src/main/java/app/screenreader/tabs/gestures/GestureActivity.kt b/app/src/main/java/app/screenreader/tabs/gestures/GestureActivity.kt index 5bd6e8b..334df32 100644 --- a/app/src/main/java/app/screenreader/tabs/gestures/GestureActivity.kt +++ b/app/src/main/java/app/screenreader/tabs/gestures/GestureActivity.kt @@ -3,12 +3,15 @@ package app.screenreader.tabs.gestures import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.content.* +import android.os.Build import android.text.SpannableString import android.text.TextUtils import android.util.Log import android.view.Menu import android.view.MenuItem +import android.view.MotionEvent import android.view.View +import android.widget.FrameLayout import androidx.appcompat.app.AlertDialog import app.screenreader.R import app.screenreader.extensions.* @@ -20,7 +23,6 @@ import app.screenreader.services.ScreenReaderService import app.screenreader.views.gestures.GestureView import app.screenreader.views.gestures.GestureViewCallback import app.screenreader.widgets.ToolbarActivity -import kotlinx.android.synthetic.main.activity_gesture.* import java.util.* import kotlin.concurrent.schedule @@ -51,6 +53,12 @@ class GestureActivity: ToolbarActivity(), GestureViewCallback { private var errorCount = 0 private var finished = false + private val container get() = findViewById(R.id.container) + private val titleTextView get() = findViewById(R.id.titleTextView) + private val descriptionTextView get() = findViewById(R.id.descriptionTextView) + private val gestureImageView get() = findViewById(R.id.gestureImageView) + private val feedbackTextView get() = findViewById(R.id.feedbackTextView) + private val isPracticing: Boolean get() = gestures.isNotEmpty() @@ -69,6 +77,15 @@ class GestureActivity: ToolbarActivity(), GestureViewCallback { gestureView.onAccessibilityGesture(gesture) // Pass gesture to GestureView. } } + + // Received motion event (from ScreenReaderService intercepting raw touch events) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + (intent?.getParcelableExtra(Constants.SERVICE_MOTION_EVENT, MotionEvent::class.java))?.let { event -> + Log.d(TAG, "Received motion event from service: action=${event.action}, pointerCount=${event.pointerCount}") + gestureView.dispatchTouchEvent(event) + event.recycle() + } + } } } @@ -102,7 +119,7 @@ class GestureActivity: ToolbarActivity(), GestureViewCallback { // Listen to events from ScreenReaderService val filter = IntentFilter() filter.addAction(Constants.SERVICE_ACTION) - registerReceiver(receiver, filter) + registerBroadcastReceiver(receiver, filter) } override fun onCreateOptionsMenu(menu: Menu?): Boolean { diff --git a/app/src/main/java/app/screenreader/tabs/gestures/GesturesFragment.kt b/app/src/main/java/app/screenreader/tabs/gestures/GesturesFragment.kt index 2bebab9..9f12769 100644 --- a/app/src/main/java/app/screenreader/tabs/gestures/GesturesFragment.kt +++ b/app/src/main/java/app/screenreader/tabs/gestures/GesturesFragment.kt @@ -101,9 +101,18 @@ class GesturesFragment : ListFragment() { } private fun onGestureClicked(gesture: Gesture) { + val context = this.context ?: return + + // If TalkBack is enabled, check if device supports gesture training with TalkBack if (Accessibility.screenReader(context)) { - context?.showDialog(R.string.service_talkback_enabled_title, R.string.service_talkback_enabled_message) - return + if (!ScreenReaderService.supportsTalkBackCompatibility()) { + showTalkBackNotSupportedDialog() + return + } + if (!ScreenReaderService.isEnabled(context)) { + ScreenReaderService.enable(context, true) + return + } } startActivity(REQUEST_CODE_SINGLE) { @@ -112,10 +121,7 @@ class GesturesFragment : ListFragment() { } private fun onPracticeClicked() { - if (Accessibility.screenReader(context)) { - context?.showDialog(R.string.service_talkback_enabled_title, R.string.service_talkback_enabled_message) - return - } + // Note: TalkBack compatibility is handled in startPractice() by enabling ScreenReaderService AlertDialog.Builder(requireContext()) .setTitle(context?.getSpannable(R.string.gestures_practice_title)) @@ -136,6 +142,10 @@ class GesturesFragment : ListFragment() { val context = this.context ?: return if (Accessibility.screenReader(context)) { + if (!ScreenReaderService.supportsTalkBackCompatibility()) { + showTalkBackNotSupportedDialog() + return + } if (!ScreenReaderService.isEnabled(context)) { ScreenReaderService.enable(context, instructions) return @@ -150,6 +160,16 @@ class GesturesFragment : ListFragment() { } } + private fun showTalkBackNotSupportedDialog() { + AlertDialog.Builder(requireContext()) + .setTitle(context?.getSpannable(R.string.talkback_not_supported_title)) + .setMessage(context?.getSpannable(R.string.talkback_not_supported_message)) + .setPositiveButton(context?.getSpannable(R.string.action_ok)) { _, _ -> + // Dismiss + } + .show() + } + companion object { private const val REQUEST_CODE_SINGLE = 1 private const val REQUEST_CODE_MULTIPLE = 2 diff --git a/app/src/main/java/app/screenreader/views/actions/CopyActionView.kt b/app/src/main/java/app/screenreader/views/actions/CopyActionView.kt index 6c68475..768fba4 100644 --- a/app/src/main/java/app/screenreader/views/actions/CopyActionView.kt +++ b/app/src/main/java/app/screenreader/views/actions/CopyActionView.kt @@ -4,7 +4,7 @@ import android.content.ClipboardManager import android.content.Context import app.screenreader.R import app.screenreader.model.Action -import kotlinx.android.synthetic.main.action_copy.view.* +import app.screenreader.views.TrainingField /** * Created by Jan Jaap de Groot on 23/11/2020 @@ -24,7 +24,7 @@ class CopyActionView(context: Context): ActionView( if (clip.itemCount > 0) { val text = clip.getItemAt(0).text - if (trainingField.text.toString().contains(text, false)) { + if (findViewById(R.id.trainingField).text.toString().contains(text, false)) { correct() } else { incorrect(R.string.action_copy_incorrect) diff --git a/app/src/main/java/app/screenreader/views/actions/PasteActionView.kt b/app/src/main/java/app/screenreader/views/actions/PasteActionView.kt index 3f585fa..abd2b82 100644 --- a/app/src/main/java/app/screenreader/views/actions/PasteActionView.kt +++ b/app/src/main/java/app/screenreader/views/actions/PasteActionView.kt @@ -2,9 +2,9 @@ package app.screenreader.views.actions import android.content.Context import androidx.core.widget.addTextChangedListener -import kotlinx.android.synthetic.main.action_paste.view.* import app.screenreader.R import app.screenreader.model.Action +import app.screenreader.views.TrainingField /** * Created by Jan Jaap de Groot on 23/11/2020 @@ -17,7 +17,7 @@ class PasteActionView(context: Context) : ActionView( ) { init { - trainingField.addTextChangedListener(beforeTextChanged = { _, _, _, after -> + findViewById(R.id.trainingField).addTextChangedListener(beforeTextChanged = { _, _, _, after -> if (after > 1) { correct() } diff --git a/app/src/main/java/app/screenreader/views/actions/SelectionActionView.kt b/app/src/main/java/app/screenreader/views/actions/SelectionActionView.kt index 63ba8a3..3fe1978 100644 --- a/app/src/main/java/app/screenreader/views/actions/SelectionActionView.kt +++ b/app/src/main/java/app/screenreader/views/actions/SelectionActionView.kt @@ -1,7 +1,6 @@ package app.screenreader.views.actions import android.content.Context -import kotlinx.android.synthetic.main.action_selection.view.* import app.screenreader.R import app.screenreader.model.Action import app.screenreader.views.TrainingField @@ -17,7 +16,7 @@ class SelectionActionView(context: Context) : ActionView( ), TrainingField.OnSelectionChangedListener { init { - trainingField.callback = this + findViewById(R.id.trainingField).callback = this } override fun onSelectionChanged(start: Int, end: Int) { diff --git a/app/src/main/java/app/screenreader/views/gestures/SwipeGestureView.kt b/app/src/main/java/app/screenreader/views/gestures/SwipeGestureView.kt index ad94b6c..1c8d631 100644 --- a/app/src/main/java/app/screenreader/views/gestures/SwipeGestureView.kt +++ b/app/src/main/java/app/screenreader/views/gestures/SwipeGestureView.kt @@ -9,7 +9,6 @@ import app.screenreader.extensions.isEnd import app.screenreader.extensions.isStart import app.screenreader.model.Direction import app.screenreader.model.Gesture -import app.screenreader.services.ScreenReaderService /** * Created by Jan Jaap de Groot on 15/10/2020 @@ -27,6 +26,7 @@ open class SwipeGestureView( super.onTouchEvent(event) if (event != null) { + Log.d(TAG, "onTouchEvent: action=${event.action}, pointerCount=${event.pointerCount}") gestureDetector.onTouchEvent(event) if (event.isStart()) { @@ -42,14 +42,18 @@ open class SwipeGestureView( } override fun onAccessibilityGesture(gesture: Gesture) { + Log.d(TAG, "onAccessibilityGesture received: $gesture (expected: ${this.gesture})") when { this.gesture == gesture -> { + Log.d(TAG, "Gesture matches! Marking correct.") correct() } gesture.directions.isNotEmpty() -> { + Log.d(TAG, "Gesture has directions but doesn't match, calling onSwipe") onSwipe(gesture.directions) } else -> { + Log.d(TAG, "Unknown gesture, marking incorrect") incorrect(R.string.gestures_feedback_swipe) } } @@ -59,7 +63,7 @@ open class SwipeGestureView( swiped = true val fingers = directions.map { it.fingers }.average().toInt() - Log.d(TAG, "onSwipe: ${directions.joinToString { it.toString() }}, fingers: $fingers") + Log.d(TAG, "onSwipe (touch-based): directions=${directions.joinToString { it.toString() }}, fingers=$fingers, expected=${gesture.fingers}") when { fingers != gesture.fingers -> { @@ -82,32 +86,30 @@ open class SwipeGestureView( private val THRESHOLD = 15 private var path = arrayListOf() - override fun onScroll(e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean { + override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean { Log.d(TAG, "onScroll, distanceX: $distanceX, distanceY: $distanceY") - // Determine direction + // Determine direction based on the DOMINANT axis (larger absolute movement) var direction = Direction.UNKNOWN - when { - distanceX > THRESHOLD -> { - direction = Direction.LEFT - } - distanceX < -THRESHOLD -> { - direction = Direction.RIGHT - } - distanceY > THRESHOLD -> { - direction = Direction.UP - } - distanceY < -THRESHOLD -> { - direction = Direction.DOWN + val absX = kotlin.math.abs(distanceX) + val absY = kotlin.math.abs(distanceY) + + // Only detect direction if movement exceeds threshold + // Prioritize the axis with larger movement to avoid detecting + // slight horizontal drift during vertical swipes (and vice versa) + if (absX > THRESHOLD || absY > THRESHOLD) { + if (absY >= absX) { + // Vertical movement is dominant + direction = if (distanceY > 0) Direction.UP else Direction.DOWN + } else { + // Horizontal movement is dominant + direction = if (distanceX > 0) Direction.LEFT else Direction.RIGHT } } if (direction != Direction.UNKNOWN) { // Determine amount of fingers direction.fingers = e2.pointerCount ?: 1 - if (ScreenReaderService.isEnabled(context)) { - direction.fingers++ - } if (path.isEmpty()) { // Add first direction @@ -127,7 +129,7 @@ open class SwipeGestureView( return super.onScroll(e1, e2, distanceX, distanceY) } - override fun onFling(e1: MotionEvent, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean { + override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean { Log.d(TAG, "onFling, velocityX: $velocityX, velocityY: $velocityY") if (path.isNotEmpty()) { diff --git a/app/src/main/java/app/screenreader/views/gestures/TapGestureView.kt b/app/src/main/java/app/screenreader/views/gestures/TapGestureView.kt index fc3c723..bc07688 100644 --- a/app/src/main/java/app/screenreader/views/gestures/TapGestureView.kt +++ b/app/src/main/java/app/screenreader/views/gestures/TapGestureView.kt @@ -7,7 +7,6 @@ import app.screenreader.extensions.isEnd import app.screenreader.extensions.isStart import app.screenreader.model.Gesture import app.screenreader.model.Touch -import app.screenreader.services.ScreenReaderService import app.screenreader.R /** @@ -36,10 +35,6 @@ class TapGestureView( hold = true } - if (ScreenReaderService.isEnabled(context)) { - taps += 1 - } - when { fingers != gesture.fingers -> { diff --git a/app/src/main/java/app/screenreader/widgets/ListFragment.kt b/app/src/main/java/app/screenreader/widgets/ListFragment.kt index 49cebe6..463acb6 100644 --- a/app/src/main/java/app/screenreader/widgets/ListFragment.kt +++ b/app/src/main/java/app/screenreader/widgets/ListFragment.kt @@ -3,12 +3,13 @@ package app.screenreader.widgets import android.os.Bundle import android.view.View import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.RecyclerView import app.screenreader.R import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter -import kotlinx.android.synthetic.main.view_list.* abstract class ListFragment: BaseFragment() { + private val recyclerView get() = view?.findViewById(R.id.recyclerView) override fun getLayoutId(): Int { return R.layout.view_list } @@ -22,10 +23,10 @@ abstract class ListFragment: BaseFragment() { super.onViewCreated(view, savedInstanceState) adapter.items = items - recyclerView.adapter = adapter + recyclerView?.adapter = adapter if (decoration) { - recyclerView.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)) + recyclerView?.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)) } } } \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index f24154b..b3d9402 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -379,4 +379,6 @@ Je moet de \'ScreenReader gebaren\'-service aanzetten zodat de ScreenReader app gebaren kan detecteren wanneer TalkBack geactiveerd is.\n\nVolg deze stappen:\n\n1. Navigeer naar de \'Activeren\' knop. Dubbeltik om de toegankelijkheidsinstellingen van je toestel te openen.\n2. Navigeer naar geïnstalleerde service met de naam \'ScreenReader gebaren\'. Dubbeltik om het instellingenscherm te openen.\n3. Navigeer naar de schakelaar. Dubbeltik om de service toe te staan.\n4. Navigeer naar \'Toestaan\'. Dubbeltik om de service te activeren.\n5. Je hebt het activatieproces succesvol doorlopen. De gebaren training wordt automatisch gestart.\n\nNavigeer nu naar de \'Activeren\' knop om het activatieproces te starten. TalkBack staat aan Wegens technische beperkingen kun je op dit moment geen gebaren oefenen wanneer TalkBack aan staat. + Android-versie niet ondersteund + Gebaren oefenen met TalkBack ingeschakeld vereist Android 12L (API 32) of hoger.\n\nJe apparaat draait op een oudere versie van Android. Schakel TalkBack uit om gebaren te oefenen, of werk je apparaat bij naar een nieuwere Android-versie. \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ae8d4ef..03e6d48 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -384,4 +384,6 @@ You need to enable the \'ScreenReader gestures\' service to allow the ScreenReader app to recognize gestures when TalkBack is activated.\n\nFollow these steps:\n\n1. Navigate to the \'Activate\' button. Double-tap to open the accessibility settings of your device.\n\n2. Navigate to installed service named \'ScreenReader gestures\'. Double tap to open the settings screen.\n\n3. Navigate to the switch. Double-tap to enable the service.\n\n4. Navigate to the \'Allow\' option. Double tap to activate the service.\n\n5. You have successfully completed the activation process. The gesture training should start automatically. TalkBack is enabled Due to technical limitations, you can\'t practice gestures while TalkBack is enabled at this time. + Android version not supported + Gesture training with TalkBack enabled requires Android 12L (API 32) or higher.\n\nYour device is running an older version of Android. Please disable TalkBack to practice gestures, or update your device to a newer Android version. \ No newline at end of file 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..2815cc6 --- /dev/null +++ b/app/src/main/res/xml/accessibility_service_config.xml @@ -0,0 +1,9 @@ + + diff --git a/build.gradle b/build.gradle index 02eec65..174d152 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '8.1.1' apply false - id 'com.android.library' version '8.1.1' apply false + id 'com.android.application' version '8.13.0' apply false + id 'com.android.library' version '8.13.0' apply false id 'org.jetbrains.kotlin.android' version '1.7.10' apply false id 'com.google.gms.google-services' version '4.3.15' apply false id 'com.google.firebase.crashlytics' version '2.9.9' apply false diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c..d64cd49 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 218a1a0..37f853b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Wed Feb 16 13:49:22 CET 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0..1aa94a4 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,67 +17,99 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +119,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,88 +130,120 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index 107acd3..93e3f59 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal