diff --git a/.idea/misc.xml b/.idea/misc.xml index 360e6d4..e33adf8 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,10 +1,10 @@ <?xml version="1.0" encoding="UTF-8"?> <project version="4"> <component name="ExternalStorageConfigurationManager" enabled="true" /> - <component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="1.8" project-jdk-type="JavaSDK"> + <component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK"> <output url="file://$PROJECT_DIR$/build/classes" /> </component> <component name="ProjectType"> <option name="id" value="Android" /> </component> -</project> \ No newline at end of file +</project> diff --git a/.idea/saveactions_settings.xml b/.idea/saveactions_settings.xml index 6025467..2ff724a 100644 --- a/.idea/saveactions_settings.xml +++ b/.idea/saveactions_settings.xml @@ -9,5 +9,10 @@ </set> </option> <option name="configurationPath" value="" /> + <option name="exclusions"> + <set> + <option value=".*\.md" /> + </set> + </option> </component> -</project> \ No newline at end of file +</project> diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md new file mode 100644 index 0000000..52484d4 --- /dev/null +++ b/DOCUMENTATION.md @@ -0,0 +1,10 @@ +# Documentation + +## Settings.Secure +Many accessibity settings can be queried using constants from [Settings.secure](https://developer.android.com/reference/android/provider/Settings.Secure). + +Our AccessiblityCollector has a convenience method for it: `getSystemIntAsBool` + +## Attempts to read accessibility settings + +- Mono audio: Only found a private system setting: `MASTER_MONO`. These settings require root access. diff --git a/README.md b/README.md index 03f4b49..26e6f7e 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,11 @@ Add the Jitpack repo and include the library: ## Usage -1. Get the API key from [The Api project](https://github.com/Q42/accessibility-pipeline/tree/main/api). Use this key in the next step. +1. Get the API key + from [The Api project](https://github.com/Q42/accessibility-pipeline/tree/main/api). Use this key + in the next step. -1. Call `Q42Stats().runAsync(Context)` from anywhere in your app. +1. Call `Q42Stats().runAsync(Context)` from anywhere in your app. ```kotlin class SampleApplication : Application() { override fun onCreate() { @@ -36,7 +38,7 @@ Add the Jitpack repo and include the library: Q42Stats( Q42StatsConfig( apiKey = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - firestoreCollectionId = "theCollection", + firestoreCollectionId = "yourExistingFirestoreCollectionId", // wait at least 7.5 days between data collections. the extra .5 is for time-of-day randomization minimumSubmitIntervalSeconds = (60 * 60 * 24 * 7.5).toLong() ) @@ -44,7 +46,8 @@ Add the Jitpack repo and include the library: } } ``` - This can be safely called from the main thread since all work (both collecting statistics and sending them to the server) are done on an IO thread. + This can be safely called from the main thread since all work (both collecting statistics and + sending them to the server) are done on an IO thread. It is safe to call this function multiple times, as it will exit immediately if it is already running or when a data collection interval has not passed yet. @@ -76,20 +79,24 @@ versions of Android. If unsupported, the corresponding key is omitted. ### Accessibliity -| Key | Value | Notes | |-|-|-| | `isAccessibilityManagerEnabled` | bool | true when any -accessibility service (eg. Talkback) is Enabled | | `isClosedCaptioningEnabled` | bool | Live -transcription of any spoken audio (min sdk 19) | | `isTouchExplorationEnabled` | bool | Whether any -assistive feature is enabled where the user navigates the interface by touch. Most probably -TalkbBack, or similar | `isTalkBackEnabled` | bool | iOS: VoiceOver | `isSamsungTalkBackEnabled` | -bool | Specifically checks whether com.samsung.android.app.talkback.talkbackservice is enabled +| Key | Value | Notes | +|-|-|-| +| `isClosedCaptioningEnabled` | bool | Live transcription of any spoken audio (minSdk >= 19) | +| `isTouchExplorationEnabled` | bool | Whether any assistive feature is enabled where the user navigates the interface by touch. Most probably TalkBack, or similar +| `isTalkBackEnabled` | bool | iOS: VoiceOver +| `isSamsungTalkBackEnabled` | bool | Specifically checks whether com.samsung.android.app.talkback.talkbackservice is enabled | `isSelectToSpeakEnabled` | bool | iOS: Speak Selection | `isSwitchAccessEnabled` | bool | Control the device by a switch such as a foot pedal | `isBrailleBackEnabled` | bool | Navigate the screen with an external Braille display | `isVoiceAccessEnabled` | bool | iOS: Voice Control | `fontScale` | float | Default value depends on device model. Some devices have a default font scaling of 1.1, for example | -| `displayScale` | float | Overall interface scaling ie. display density scaling. Default value may depend on device model (minSdk 24)| -| `isColorInversionEnabled` | bool | | +| `fontWeightAdjustment` | float | Default value is: 0. When bold text is enabled this value is greater than 0 (minSdk >= 31). Known issue: Always returns 0 on Samsung | +| `displayScale` | float | Overall interface scaling ie. display density scaling. Default value may depend on device model (minSdk >= 24)| +| `isMagnificationEnabled` | bool | Whether magnification is enabled (more specifically, whether magnification shortcuts are enabled) (minSdk >= 17). | +| `isColorInversionEnabled` | bool | Available starting from Android 5.0 (>=21) | | `isColorBlindModeEnabled` | bool | | +| `isHighTextContrastEnabled` | bool | When enabled, all text has a thin outline. Available starting from Android 5.0 (>=21) | +| `isAnimationsDisabled` | bool | Can be disabled pre-Android 9 (<28) through Developer Options, starting from Android 9 possible to any user (minSdk >= 19). | | `enabledAccessibilityServices` | Array\<String\> | List of enabled accessibility package names, eg ['com.accessibility.service1', 'nl.accessibility.service2'] | ### Preferences @@ -107,12 +114,14 @@ bool | Specifically checks whether com.samsung.android.app.talkback.talkbackserv ### System -| Key | Value | Notes | |-|-|-| | `applicationId` | String | identifier for the app for which data -is collected, as set in the app's Manifest. iOS: bundleId | nl.hema.mobiel | | `defaultLanguage`| -en, nl, ... | | `sdkVersion` | int | 29 for Android -10. [See this list](https://source.android.com/setup/start/build-numbers) -|`manufacturer`|String|eg. `samsung`| |`modelName`|String| May be a marketing name, but more often -an internal code name. eg. `SM-G980F` for a particular variant of a Samsung Galaxy S10| +| Key | Value | Notes | +|-|-|-| +| `applicationId` | String | identifier for the app for which data is collected, as set in the app's Manifest. iOS: bundleId | nl.hema.mobiel | +| `defaultLanguage`| en-GB, nl-BE, nl, ... | If the country part (-BE) is not available, the value is just the language part ("nl") +| `sdkVersion` | int | 29 for Android 10. [See this list](https://source.android.com/setup/start/build-numbers) +|`manufacturer`|String|eg. `samsung`| +|`modelName`|String| May be a marketing name, but more often an internal code name. eg. `SM-G980F` for a particular variant of a Samsung Galaxy S10| + ## Development @@ -126,6 +135,8 @@ exceptions don't crash the implementing apps. Catch Throwable; not Exception. Since Throwabl is the superclass of Exception, this will make the lib more resilient to crashes. +For accessibility properties we want to track but could not find a property for, see [DOCUMENTATION.md](DOCUMENTATION.md) + ### Setup 1. Get the API key diff --git a/q42stats/src/main/java/com/q42/q42stats/library/HttpService.kt b/q42stats/src/main/java/com/q42/q42stats/library/HttpService.kt index 5235d78..0845ea2 100644 --- a/q42stats/src/main/java/com/q42/q42stats/library/HttpService.kt +++ b/q42stats/src/main/java/com/q42/q42stats/library/HttpService.kt @@ -56,7 +56,7 @@ private fun sendPostRequestContent(conn: HttpURLConnection, data: String): Strin try { conn.outputStream.use { os -> BufferedWriter(OutputStreamWriter(os, "UTF-8")).use { writer -> - writer.write(data.toString()) + writer.write(data) Q42StatsLogger.d(TAG, "Sending JSON: $data") writer.flush() } diff --git a/q42stats/src/main/java/com/q42/q42stats/library/Q42Stats.kt b/q42stats/src/main/java/com/q42/q42stats/library/Q42Stats.kt index 18671db..ccbed37 100644 --- a/q42stats/src/main/java/com/q42/q42stats/library/Q42Stats.kt +++ b/q42stats/src/main/java/com/q42/q42stats/library/Q42Stats.kt @@ -19,7 +19,7 @@ internal const val TAG = "Q42Stats" * Version code for the data format that is sent to the server. Increment by 1 every time * you add / remove / change a field in any of the Collector classes */ -internal const val DATA_MODEL_VERSION = 3 +internal const val DATA_MODEL_VERSION = 4 class Q42Stats(private val config: Q42StatsConfig) { @@ -60,26 +60,25 @@ class Q42Stats(private val config: Q42StatsConfig) { val currentMeasurement = collect(context) val previousMeasurement: Map<String, Any?>? = - prefs.previousMeasurement?.let { deserializeMeasurement(it) } - val payload: Map<String, Any> = mapOf<String, Any?>( - "Stats Version" to "Android ${BuildConfig.LIB_BUILD_DATE}", - "currentMeasurement" to currentMeasurement, - "previousMeasurement" to previousMeasurement, + prefs.previousMeasurement?.let { deserializeMeasurement(it) } + val payload: Map<String, Any> = mapOf<String, Any?>( + "currentMeasurement" to currentMeasurement, + "previousMeasurement" to previousMeasurement, ).filterValueNotNull() val serializedPayload = serializeMeasurement(payload.toQ42StatsApiFormat()) - val responseBody = HttpService.sendStatsSync( - config, - serializedPayload, - prefs.lastBatchId - ) - responseBody?.let { body -> + val responseBody = HttpService.sendStatsSync( + config, + serializedPayload, + prefs.lastBatchId + ) + responseBody?.let { body -> val batchId = JSONObject(body).getString("batchId") // throws if not found - prefs.lastBatchId = batchId - prefs.previousMeasurement = currentMeasurement - .toQ42StatsApiFormat() - .let { q42StatsCurrentMeasurement -> - serializeMeasurement(q42StatsCurrentMeasurement) - } + prefs.lastBatchId = batchId + prefs.previousMeasurement = currentMeasurement + .toQ42StatsApiFormat() + .let { q42StatsCurrentMeasurement -> + serializeMeasurement(q42StatsCurrentMeasurement) + } } } catch (e: Throwable) { handleException(e) @@ -103,6 +102,7 @@ class Q42Stats(private val config: Q42StatsConfig) { private fun collect(context: Context): MutableMap<String, Serializable> { val collected = mutableMapOf<String, Serializable>() + collected["Stats Version"] = "Android ${BuildConfig.LIB_BUILD_DATE}" collected["Stats Model Version"] = DATA_MODEL_VERSION collected["Stats timestamp"] = System.currentTimeMillis() / 1000L diff --git a/q42stats/src/main/java/com/q42/q42stats/library/collector/AccessibilityCollector.kt b/q42stats/src/main/java/com/q42/q42stats/library/collector/AccessibilityCollector.kt index c616bfa..4c2e475 100644 --- a/q42stats/src/main/java/com/q42/q42stats/library/collector/AccessibilityCollector.kt +++ b/q42stats/src/main/java/com/q42/q42stats/library/collector/AccessibilityCollector.kt @@ -1,7 +1,6 @@ package com.q42.q42stats.library.collector import android.accessibilityservice.AccessibilityServiceInfo -import android.annotation.TargetApi import android.content.Context import android.content.Context.ACCESSIBILITY_SERVICE import android.content.Context.CAPTIONING_SERVICE @@ -12,6 +11,7 @@ import android.provider.Settings import android.util.DisplayMetrics import android.view.accessibility.AccessibilityManager import android.view.accessibility.CaptioningManager +import androidx.annotation.RequiresApi import com.q42.q42stats.library.Q42StatsLogger import com.q42.q42stats.library.TAG import java.io.Serializable @@ -31,7 +31,6 @@ internal object AccessibilityCollector { it.resolveInfo?.serviceInfo?.name?.lowercase(Locale.ROOT) } - put("isAccessibilityManagerEnabled", accessibilityManager.isEnabled) put("isTouchExplorationEnabled", accessibilityManager.isTouchExplorationEnabled) put( "isTalkBackEnabled", @@ -57,7 +56,18 @@ internal object AccessibilityCollector { put( "isVoiceAccessEnabled", serviceNamesLower.any { it.contains("voiceaccess", ignoreCase = true) }) - put("fontScale", configuration.fontScale) + put( + "fontScale", + configuration.fontScale + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + put("fontWeightAdjustment", configuration.fontWeightAdjustment) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + isMagnificationEnabled(context, serviceNamesLower)?.let { + put("isMagnificationEnabled", it) + } + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { put( "displayScale", @@ -72,6 +82,12 @@ internal object AccessibilityCollector { ) } } + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1){ + put( + "isAnimationsDisabled", + isAnimationsDisabled(context) + ) + } put("enabledAccessibilityServices", serviceNamesLower.toString()) @@ -104,9 +120,17 @@ internal object AccessibilityCollector { it ) } + + getSystemIntAsBool(context, "high_text_contrast_enabled")?.let { + put( + "isHighTextContrastEnabled", + it + ) + } + } - @TargetApi(Build.VERSION_CODES.KITKAT) + @RequiresApi(Build.VERSION_CODES.KITKAT) private fun isClosedCaptioningEnabled(context: Context): Boolean? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { (context.getSystemService(CAPTIONING_SERVICE) as CaptioningManager).isEnabled @@ -115,6 +139,35 @@ internal object AccessibilityCollector { getSystemIntAsBool(context, "accessibility_captioning_enabled") } + /** + * This is a best-effort means of checking whether magnification is enabled or not. It involves checking by which + * method the user can toggle magnification. Ideally, we want to read MagnificationController for this check, but this would + * require creating an AccessibilityService together with necessary permissions which this library should certainly not do. + */ + @RequiresApi(Build.VERSION_CODES.JELLY_BEAN_MR1) + private fun isMagnificationEnabled(context: Context, serviceNames: List<String>): Boolean? = try { + val isMagnificationByTripleTapGesturesEnabled = getSystemIntAsBool(context,"accessibility_display_magnification_enabled") ?: false + val isMagnificationByVolumeButtonsEnabled = serviceNames.map { s -> s.lowercase() }.contains("com.example.android.apis.accessibility.magnificationservice") + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val isMagnificationByNavigationButtonEnabled = + Settings.Secure.getString(context.contentResolver, "accessibility_button_targets").lowercase().contains("com.android.server.accessibility.magnificationcontroller") + + isMagnificationByTripleTapGesturesEnabled || isMagnificationByVolumeButtonsEnabled || isMagnificationByNavigationButtonEnabled + }else{ + isMagnificationByTripleTapGesturesEnabled || isMagnificationByVolumeButtonsEnabled + } + } catch (e: Throwable) { + Q42StatsLogger.e(TAG, "Could not read magnification. Returning null", e) + null + } + + @RequiresApi(Build.VERSION_CODES.JELLY_BEAN_MR1) + private fun isAnimationsDisabled(context: Context): Boolean = + (Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1.0f) == 0f + && Settings.Global.getFloat(context.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE, 1.0f) == 0f + && Settings.Global.getFloat(context.contentResolver, Settings.Global.WINDOW_ANIMATION_SCALE, 1.0f) == 0f) + /** * @return null when the value could not be read */