diff --git a/android/app/build.gradle b/android/app/build.gradle index 8f2788f..7d7c52b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -130,6 +130,9 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8.toString() + } defaultConfig { applicationId "com.dismoi.scout" @@ -193,19 +196,30 @@ applicationVariants.all { variant -> dependencies { implementation fileTree(dir: "libs", include: ["*.jar"]) - //noinspection GradleDynamicVersion - implementation "com.facebook.react:react-native:+" // From node_modules - implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" - implementation 'org.jsoup:jsoup:1.10.3' + implementation 'android.arch.work:work-runtime-ktx:1.0.0-beta02' + implementation "com.android.support:cardview-v7:28.0.0" + implementation 'com.google.android.material:material:1.3.0' + + implementation "androidx.core:core-ktx:1.3.2" + implementation 'androidx.appcompat:appcompat:1.2.0' + implementation 'androidx.lifecycle:lifecycle-service:2.3.1' + implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" implementation 'androidx.multidex:multidex:2.0.1' implementation 'androidx.constraintlayout:constraintlayout:2.0.4' implementation 'androidx.recyclerview:recyclerview:1.2.0' - implementation 'com.google.android.material:material:1.3.0' + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2' + + implementation "com.facebook.react:react-native:+" // From node_modules implementation 'com.synnapps:carouselview:0.1.5' + implementation 'com.squareup.retrofit2:retrofit:2.9.0' + implementation 'com.squareup.retrofit2:converter-gson:2.5.0' + +// implementation "net.sourceforge.htmlcleaner:htmlcleaner:2.16" debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") { exclude group:'com.facebook.fbjni' @@ -227,11 +241,6 @@ dependencies { } else { implementation jscFlavor } - implementation "androidx.core:core-ktx:1.3.2" - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - implementation 'androidx.appcompat:appcompat:1.2.0' - implementation "com.android.support:cardview-v7:28.0.0" - implementation "net.sourceforge.htmlcleaner:htmlcleaner:2.16" } // Run this once to be able to run the application with BUCK diff --git a/android/app/src/main/java/com/dismoi/scout/MainApplication.kt b/android/app/src/main/java/com/dismoi/scout/MainApplication.kt index 6a3169f..9bb3c99 100644 --- a/android/app/src/main/java/com/dismoi/scout/MainApplication.kt +++ b/android/app/src/main/java/com/dismoi/scout/MainApplication.kt @@ -40,7 +40,7 @@ class MainApplication : Application(), ReactApplication { override fun onCreate() { super.onCreate() SoLoader.init(this, /* native exopackage */false) - initializeFlipper(this, reactNativeHost.reactInstanceManager) +// initializeFlipper(this, reactNativeHost.reactInstanceManager) } companion object { diff --git a/android/app/src/main/java/com/dismoi/scout/accessibility/BackgroundService.kt b/android/app/src/main/java/com/dismoi/scout/accessibility/BackgroundService.kt index a86fef5..04b2f89 100644 --- a/android/app/src/main/java/com/dismoi/scout/accessibility/BackgroundService.kt +++ b/android/app/src/main/java/com/dismoi/scout/accessibility/BackgroundService.kt @@ -1,193 +1,229 @@ package com.dismoi.scout.accessibility -/* - The configuration of an accessibility service is contained in the - AccessibilityServiceInfo class -*/ import android.accessibilityservice.AccessibilityService import android.accessibilityservice.AccessibilityServiceInfo +import android.content.ComponentName import android.content.Context import android.content.Intent -import android.os.* +import android.content.ServiceConnection +import android.os.Build +import android.os.IBinder import android.provider.Settings.canDrawOverlays +import android.util.Log import android.view.accessibility.AccessibilityEvent -import android.view.accessibility.AccessibilityNodeInfo import androidx.annotation.RequiresApi -import com.dismoi.scout.accessibility.BackgroundModule.Companion.sendEventFromAccessibilityServicePermission -import com.dismoi.scout.accessibility.browser.Chrome -import com.facebook.react.HeadlessJsTaskService +import com.dismoi.scout.accessibility.browser.NoUrlInBrowserException +import com.dismoi.scout.accessibility.browser.SupportedBrowserConfig +import com.dismoi.scout.accessibility.browser.SupportedBrowsers +import com.dismoi.scout.floating.FloatingService +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.CONFLATED +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.debounce +import okhttp3.* +import org.json.JSONArray +import org.json.JSONObject +import java.io.IOException class BackgroundService : AccessibilityService() { - private var _hide: String? = "" - private var _eventTime: String? = "" - private var _packageName: String? = "" - val chrome: Chrome = Chrome() - - private val NOTIFICATION_TIMEOUT: Long = 500 - - private val handler = Handler(Looper.getMainLooper()) - private val runnableCode: Runnable = object : Runnable { - override fun run() { - val context = applicationContext - val myIntent = Intent(context, BackgroundEventService::class.java) - val bundle = Bundle() - - bundle.putString("packageName", _packageName) - bundle.putString("url", chrome._url) - bundle.putString("hide", _hide) - bundle.putString("eventTime", _eventTime) - - myIntent.putExtras(bundle) + private val TAG = "Accessibility" + private var bound: Boolean = false + private var floatingService: FloatingService? = null + private var jsonMatchingContexts: JSONArray = JSONArray("[]") + private var lastNotices: List = listOf() + private var eventsChannel: Channel = Channel(CONFLATED) + + private val disMoiServiceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, floatingServiceBinder: IBinder) { + val binder = floatingServiceBinder as FloatingService.FloatingServiceBinder + floatingService = binder.service + bound = true + } - context.startService(myIntent) - HeadlessJsTaskService.acquireWakeLockNow(context) + override fun onServiceDisconnected(name: ComponentName) { + bound = false + floatingService = null } } - private fun getEventType(event: AccessibilityEvent): String? { - when (event.eventType) { - AccessibilityEvent.TYPE_VIEW_CLICKED -> return "TYPE_VIEW_CLICKED" - AccessibilityEvent.TYPE_VIEW_FOCUSED -> return "TYPE_VIEW_FOCUSED" - AccessibilityEvent.TYPE_VIEW_SELECTED -> return "TYPE_VIEW_SELECTED" - AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> return "TYPE_WINDOW_STATE_CHANGED" - AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> return "TYPE_WINDOW_CONTENT_CHANGED" - AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED -> return "TYPE_VIEW_TEXT_CHANGED" - AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED -> return "TYPE_VIEW_TEXT_SELECTION_CHANGED" - AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED -> return "TYPE_VIEW_ACCESSIBILITY_FOCUSED" - AccessibilityEvent.TYPE_WINDOWS_CHANGED -> return "TYPE_WINDOWS_CHANGED" + @RequiresApi(Build.VERSION_CODES.N) + override fun onCreate() { + super.onCreate() + + CoroutineScope(Dispatchers.Default).launch { + eventsChannel.consumeAsFlow().debounce(500L).collect { + matchContext(it) + } } - return event.eventType.toString() + + // TODO clear coroutine on service stop + fetchMatchingContexts() + + bindService( + Intent(applicationContext, FloatingService::class.java), disMoiServiceConnection, Context.BIND_AUTO_CREATE + ) } - /* - This system calls this method when it successfully connects to your accessibility service - */ - // configure my service in there @RequiresApi(Build.VERSION_CODES.JELLY_BEAN_MR2) override fun onServiceConnected() { - val info = serviceInfo - // Set the type of events that this service wants to listen to. Others - // won't be passed to this service - /* - Represents the event of changing the content of a window and more specifically - the sub-tree rooted at the event's source - */ info.eventTypes = AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED or AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED or AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED - - //AccessibilityEvent.TYPE_WINDOWS_CHANGED or - /* - info.packageNames is not set because we want to receive event from - all packages - */ - + // info.packageNames is not set because we want to receive event from all packages info.feedbackType = AccessibilityServiceInfo.FEEDBACK_VISUAL - info.flags = AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS - - /* - The minimal period in milliseconds between two accessibility events of - the same type are sent to this service - */ - info.notificationTimeout = NOTIFICATION_TIMEOUT + info.flags = AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS or AccessibilityServiceInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS this.serviceInfo = info } - private fun overlayIsActivated(applicationContext: Context): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - canDrawOverlays(applicationContext) - } else { - false - } + @RequiresApi(Build.VERSION_CODES.N) + override fun onAccessibilityEvent(event: AccessibilityEvent) { + runBlocking { launch { + eventsChannel.send(event) + } } } - private fun isWindowChangeEvent(event: AccessibilityEvent): Boolean { - return AccessibilityEvent.eventTypeToString(event.eventType).contains("WINDOW") - } + @RequiresApi(Build.VERSION_CODES.N) + private fun matchContext(event: AccessibilityEvent) { + val eventType = AccessibilityEvent.eventTypeToString(event.eventType) + Log.d(TAG, "Event : ${eventType}") + Log.d(TAG, "Package: ${event.packageName}") - private fun chromeSearchBarEditingIsActivated(info: AccessibilityNodeInfo): Boolean { - return info.childCount > 0 && - info.className.toString() == "android.widget.FrameLayout" && - info.getChild(0).className.toString() == "android.widget.EditText" - } + if (!canDrawOverlays(applicationContext)) return - fun isLauncherActivated(packageName: String): Boolean { - return "com.android.launcher3" == packageName - } + val root = rootInActiveWindow + val packageName = root?.packageName?.toString() - /* - This method is called back by the system when it detects an - AccessibilityEvent that matches the event filtering parameters - specified by your accessibility service - */ - @RequiresApi(Build.VERSION_CODES.JELLY_BEAN_MR2) - override fun onAccessibilityEvent(event: AccessibilityEvent) { - if (rootInActiveWindow == null) { + if (root == null || packageName == null) { + hide() + return + } + + if (packageName == "com.dismoi.scout") { // TODO extract + // We’re interacting with our own UI return } - if (overlayIsActivated(applicationContext)) { - val packageName = event.packageName.toString() - - if (getEventType(event) == "TYPE_WINDOW_STATE_CHANGED" && packageName != "com.android.chrome") { - if (packageName.contains("com.google.android.inputmethod") || - packageName == "com.google.android.googlequicksearchbox" || - packageName == "com.android.systemui" - ) { - _packageName = packageName - _hide = "true" - handler.post(runnableCode) + if (SupportedBrowsers.isSupported(packageName)) { + Log.d(TAG, "Supported window packageName : ${root.packageName}, className: ${root.className}") + } else { + Log.d(TAG, "Unknown window packageName : ${root.packageName}, className: ${root.className}") + hide() + return + } + + // we have a supported browser active ! Yay ! + val activeBrowserConfig = SupportedBrowsers.find(packageName) as SupportedBrowserConfig + + try { + val currentUrl = activeBrowserConfig.getCurrentUrlIn(root) + + if (currentUrl == null) return + + Log.d(TAG, "current URL is $currentUrl") + + val matchingContexts = getContextsMatchingUrl(currentUrl) + + Log.d(TAG, "found ${matchingContexts.size} contexts matching current url") + + if (matchingContexts.size > 0) { + + val noticesIds = + matchingContexts + .filter { context -> !context.has("xpath") || context.isNull("xpath") } // TODO Use domain fields instead of xpath + .map { context -> context.getInt("noticeId") } + .distinct() + + if (noticesIds.size == lastNotices.size && noticesIds.containsAll(lastNotices)) { return } - } - if (isLauncherActivated(packageName)) { - _hide = "true" - _packageName = packageName - handler.post(runnableCode) - return + show(noticesIds) + } else { + hide() } + } catch (e: NoUrlInBrowserException) + { + // When page is scrolled, the URL is hidden, but we may still be on the same page … + } - val parentNodeInfo: AccessibilityNodeInfo = event.source ?: return - - chrome.parentNodeInfo = parentNodeInfo - chrome._packageName = packageName - - if (chrome.checkIfChrome()) { - if ( - getEventType(event) == "TYPE_WINDOW_STATE_CHANGED" || - getEventType(event) == "TYPE_WINDOW_CONTENT_CHANGED" - ) { - chrome.captureUrl() - if (chrome.chromeSearchBarEditingIsActivated()) { - _hide = "true" - handler.post(runnableCode) - return - } - } - if (getEventType(event) == "TYPE_VIEW_ACCESSIBILITY_FOCUSED") { - _eventTime = event.eventTime.toString() - _hide = "false" - _packageName = packageName - handler.post(runnableCode) - } + // Just an example of code of how to search for a product name +// val webview = Helpers.findWebview(root) +// if (webview != null) { +// Amazon.getProductTitle(webview) +// } + } - parentNodeInfo.recycle() - } + private fun hide() { + if (bound) { + lastNotices = listOf() + floatingService!!.hide() + } - return + } + + @RequiresApi(Build.VERSION_CODES.N) + private fun show(noticesIds: List) { + if (bound) { + lastNotices = noticesIds + floatingService!!.showNotices(noticesIds) } } - override fun onInterrupt() { - sendEventFromAccessibilityServicePermission("false") + // TODO To be moved to repository + private fun fetchMatchingContexts() { + val matchingContextsEndpoint = "https://notices.bulles.fr/api/v3/matching-contexts" // TODO to be moved to env configuration + val request = Request.Builder().url(matchingContextsEndpoint).build() + val client = OkHttpClient() + + client.newCall(request).enqueue(object: Callback { + override fun onFailure(call: Call, e: IOException) { + Log.d(TAG, "Failed to fetch : ${e.toString()}") + } + + override fun onResponse(call: Call, response: Response) { + val body = response.body()?.string() + + jsonMatchingContexts = JSONArray(body) + + Log.d(TAG, "Fetched ${jsonMatchingContexts.length()} matching contexts") + } + }) } - override fun onDestroy() { - super.onDestroy() + // TODO To be moved to a repository + private fun getContextsMatchingUrl(url: String): MutableList { + val wwwUrl = "www.$url" + try { + val contextsMatchingUrl = mutableListOf() + for (i in 0 until jsonMatchingContexts.length()) { + val aMatchingContext = jsonMatchingContexts.getJSONObject(i) + + if (aMatchingContext.has("urlRegex")) { + val regex = Regex(aMatchingContext.getString("urlRegex")) + if (!regex.matches(url) && !regex.matches(wwwUrl)) { + continue + } + } - sendEventFromAccessibilityServicePermission("false") - handler.removeCallbacks(runnableCode) + if (aMatchingContext.has("excludeUrlRegex")) { + val excludeRegex = Regex(aMatchingContext.getString("excludeUrlRegex")) + if (excludeRegex.matches(url) || excludeRegex.matches(wwwUrl)) { + continue + } + } + + contextsMatchingUrl.add(aMatchingContext) + } + return contextsMatchingUrl + } + catch (exception: Exception) { + Log.d(TAG, "oups : ${exception.toString()}") + } + return mutableListOf() } + + override fun onInterrupt() {} } diff --git a/android/app/src/main/java/com/dismoi/scout/accessibility/browser/Browser.kt b/android/app/src/main/java/com/dismoi/scout/accessibility/browser/Browser.kt deleted file mode 100644 index cff6c52..0000000 --- a/android/app/src/main/java/com/dismoi/scout/accessibility/browser/Browser.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.dismoi.scout.accessibility.browser - -open class Browser() { - var _packageName: String = "" - - open fun getBrowserConfig(): SupportedBrowserConfig? { - return SupportedBrowsers.find(_packageName) - } - - object SupportedBrowsers { - val supportedBrowsers = listOf( - SupportedBrowserConfig("com.android.chrome", "com.android.chrome:id/url_bar") - ) - - fun get(): List { - return supportedBrowsers - } - - fun find(packageName: String): SupportedBrowserConfig? { - return supportedBrowsers.find { it.packageName == packageName } - } - } -} \ No newline at end of file diff --git a/android/app/src/main/java/com/dismoi/scout/accessibility/browser/Chrome.kt b/android/app/src/main/java/com/dismoi/scout/accessibility/browser/Chrome.kt deleted file mode 100644 index 387b88a..0000000 --- a/android/app/src/main/java/com/dismoi/scout/accessibility/browser/Chrome.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.dismoi.scout.accessibility.browser - -import android.os.Build -import android.view.accessibility.AccessibilityNodeInfo -import androidx.annotation.RequiresApi - -class Chrome(): Browser() { - var _url: String? = "" - var eventType: String? = "" - var className: String? = "" - var eventText: String? = "" - var hide: String? = "" - var eventTime: String? = "" - - lateinit var parentNodeInfo: AccessibilityNodeInfo - - @RequiresApi(Build.VERSION_CODES.JELLY_BEAN_MR2) - fun captureUrl() { - // Can get URL with FLAG_REPORT_VIEW_IDS - val nodes = parentNodeInfo.findAccessibilityNodeInfosByViewId( - getBrowserConfig()!!.addressBarId - ) - if (nodes != null && nodes.size > 0) { - val addressBarNodeInfo = nodes[0] - var url: String? = null - if (addressBarNodeInfo.text != null) { - url = addressBarNodeInfo.text.toString() - } - if (url != null) { - _url = url - } - addressBarNodeInfo.recycle() - } - } - - fun checkIfChrome(): Boolean { - return _packageName == "com.android.chrome" - } - - fun chromeSearchBarEditingIsActivated(): Boolean { - return parentNodeInfo.childCount > 0 && - parentNodeInfo.className.toString() == "android.widget.FrameLayout" && - parentNodeInfo.getChild(0).className.toString() == "android.widget.EditText" - } -} diff --git a/android/app/src/main/java/com/dismoi/scout/accessibility/browser/NoUrlInBrowserException.kt b/android/app/src/main/java/com/dismoi/scout/accessibility/browser/NoUrlInBrowserException.kt new file mode 100644 index 0000000..655bbd2 --- /dev/null +++ b/android/app/src/main/java/com/dismoi/scout/accessibility/browser/NoUrlInBrowserException.kt @@ -0,0 +1,7 @@ +package com.dismoi.scout.accessibility.browser + +import java.lang.Exception + +class NoUrlInBrowserException : Exception() { + +} diff --git a/android/app/src/main/java/com/dismoi/scout/accessibility/browser/SupportedBrowserConfig.kt b/android/app/src/main/java/com/dismoi/scout/accessibility/browser/SupportedBrowserConfig.kt index dabfa64..340804a 100644 --- a/android/app/src/main/java/com/dismoi/scout/accessibility/browser/SupportedBrowserConfig.kt +++ b/android/app/src/main/java/com/dismoi/scout/accessibility/browser/SupportedBrowserConfig.kt @@ -1,3 +1,24 @@ package com.dismoi.scout.accessibility.browser -class SupportedBrowserConfig(var packageName: String, var addressBarId: String) \ No newline at end of file +import android.os.Build +import android.view.accessibility.AccessibilityNodeInfo +import androidx.annotation.RequiresApi + +class SupportedBrowserConfig(var packageName: String, var addressBarId: String) { + @RequiresApi(Build.VERSION_CODES.JELLY_BEAN_MR2) + public fun getCurrentUrlIn(node: AccessibilityNodeInfo): String? { + var addressBars = node.findAccessibilityNodeInfosByViewId(addressBarId) + + if (addressBars == null || addressBars.size == 0) { + throw NoUrlInBrowserException() + } + + val addressBar = addressBars.first() + + val currentUrl = addressBar?.text?.toString() ?: "EMPTY URL" + + addressBar.recycle() + + return currentUrl + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/dismoi/scout/accessibility/browser/SupportedBrowsers.kt b/android/app/src/main/java/com/dismoi/scout/accessibility/browser/SupportedBrowsers.kt new file mode 100644 index 0000000..68278f7 --- /dev/null +++ b/android/app/src/main/java/com/dismoi/scout/accessibility/browser/SupportedBrowsers.kt @@ -0,0 +1,20 @@ +package com.dismoi.scout.accessibility.browser + +val supportedBrowsers = listOf( + SupportedBrowserConfig("com.android.chrome", "com.android.chrome:id/url_bar"), + SupportedBrowserConfig("org.mozilla.firefox", "org.mozilla.firefox:id/url_bar") +) + +object SupportedBrowsers { + fun getList(): List { + return supportedBrowsers + } + + fun find(packageName: String): SupportedBrowserConfig? { + return supportedBrowsers.find { it.packageName == packageName } + } + + fun isSupported(packageName: String): Boolean { + return find(packageName) != null + } +} diff --git a/android/app/src/main/java/com/dismoi/scout/browser/Amazon.kt b/android/app/src/main/java/com/dismoi/scout/browser/Amazon.kt new file mode 100644 index 0000000..d917b93 --- /dev/null +++ b/android/app/src/main/java/com/dismoi/scout/browser/Amazon.kt @@ -0,0 +1,18 @@ +package com.dismoi.scout.browser + +import android.os.Build +import android.util.Log +import android.view.accessibility.AccessibilityNodeInfo +import androidx.annotation.RequiresApi + +val TAG = "Amazon" + +object Amazon { + @RequiresApi(Build.VERSION_CODES.KITKAT) + fun getProductTitle(webviewNodeInfo: AccessibilityNodeInfo): String? { + val titleView = Helpers.findFirstHeading(webviewNodeInfo) + val title = titleView?.text ?: titleView?.contentDescription + Log.d(TAG, "Found Amazon page title : $title") + return title?.toString() + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/dismoi/scout/browser/Helpers.kt b/android/app/src/main/java/com/dismoi/scout/browser/Helpers.kt new file mode 100644 index 0000000..643882a --- /dev/null +++ b/android/app/src/main/java/com/dismoi/scout/browser/Helpers.kt @@ -0,0 +1,142 @@ +package com.dismoi.scout.browser + +import android.os.Build +import android.util.Log +import android.view.accessibility.AccessibilityNodeInfo +import androidx.annotation.RequiresApi + +object Helpers { + + private val TAG: String = "WebviewHelpers" + + @RequiresApi(Build.VERSION_CODES.KITKAT) + fun findByClassName(node: AccessibilityNodeInfo, className: String, level: Int = 0): AccessibilityNodeInfo? { + node.refresh() + val count = node.childCount + for (i in 0 until count) { + val child = node.getChild(i) + if (child != null) { + if (child.className.toString() == className) { + return child + } + val foundInChild = findByClassName(child, className, level + 1) + if (foundInChild != null) return foundInChild + } + } + return null + } + + @RequiresApi(Build.VERSION_CODES.KITKAT) + fun nodeIsHeading(node: AccessibilityNodeInfo, headingLevel: Int? = null): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + if (node.isHeading) return true + } + + if (node.extras == null) return false + + val roleDescription = node.extras?.get("AccessibilityNodeInfo.roleDescription")?.toString() ?: return false + + if (roleDescription.startsWith("heading", true)) { + if(headingLevel==null || roleDescription.equals("heading")) + return true + + return roleDescription.equals("heading ${headingLevel}") + } + + return false + } + + @RequiresApi(Build.VERSION_CODES.KITKAT) + fun findFirstHeading(node: AccessibilityNodeInfo, level: Int = 0): AccessibilityNodeInfo? { + node.refresh() + val count = node.childCount + for (i in 0 until count) { + val child = node.getChild(i) + if (child != null) { + if (nodeIsHeading(child, 1)) { + return child + } + val foundInChild = findFirstHeading(child, level + 1) + if (foundInChild != null) return foundInChild + } + } + return null + } + + @RequiresApi(Build.VERSION_CODES.KITKAT) + fun findById(node: AccessibilityNodeInfo, id: String, level: Int = 0): AccessibilityNodeInfo? { + val count = node.childCount + for (i in 0 until count) { + val child = node.getChild(i) + + if (child != null) { + if (child?.viewIdResourceName?.toString() == id) { + return child + } + val foundInChild = findById(child, id, level + 1) + if (foundInChild != null) return foundInChild + } + } + return null + } + + + @RequiresApi(Build.VERSION_CODES.KITKAT) + fun findTexts(node: AccessibilityNodeInfo, level: Int = 0): String { + val count = node.childCount + var texts = "" + for (i in 0 until count) { + val child = node.getChild(i) + + texts += child.text?.toString() ?: child.contentDescription?.toString() ?: "" + texts += "\n" + findTexts(child, level + 1) + "\n" + } + return texts + } + + @RequiresApi(Build.VERSION_CODES.KITKAT) + fun findWebview(node: AccessibilityNodeInfo): AccessibilityNodeInfo? { + return findByClassName(node, "android.webkit.WebView") + } + + @RequiresApi(Build.VERSION_CODES.KITKAT) + fun logHierarchy(node: AccessibilityNodeInfo, level: Int = 0) { + node.refresh() + val id = node.viewIdResourceName + val text = node.text?.toString() + val content = node.contentDescription?.toString() +// val avExtras = node.availableExtraData?.joinToString(", ") Needs API level 26 + val count = node.childCount + + val extras = node.extras + val extrasList = mutableListOf() + for (key in extras.keySet()) { + extrasList.add("$key: ${extras.get(key)}") + } + val allExtras = extrasList.joinToString(" / ") + + Log.d(TAG, "${" ".repeat(level)} ($level) " + + "className: ${node.className}, " + + "id: ${id ?: "NO ID"}, " + + "text: $text, " + + "content: $content, " + + "extras: $allExtras, " + +// "avExtras: $avExtras, " + Needs API level 26 +// "hint: ${node.hintText}, " + Needs API level 26 +// "heading: ${node.isHeading}, " + Needs API 30 + "inputType: ${node.inputType}, " +// "state: ${node.stateDescription}" Needs API 30 + ) + + for (i in 0 until count) { + val child = node.getChild(i) + if (child != null) { + logHierarchy(child, level + 1) + } + } + } + + fun getNodeTextOrContent(node: AccessibilityNodeInfo?): String? { + return if (node != null) "${node.text?.toString()} / ${node.contentDescription?.toString()}" else null + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/dismoi/scout/floating/FloatingModule.kt b/android/app/src/main/java/com/dismoi/scout/floating/FloatingModule.kt index 7491ceb..8f4ebd9 100644 --- a/android/app/src/main/java/com/dismoi/scout/floating/FloatingModule.kt +++ b/android/app/src/main/java/com/dismoi/scout/floating/FloatingModule.kt @@ -195,7 +195,7 @@ class FloatingModule( configureCloseButton(messageDisMoiView) - messagesManager!!.addDisMoiMessage(messageDisMoiView!!, y) +// messagesManager!!.addDisMoiMessage(messageDisMoiView!!, y) } private fun configureNumberOfNoticeIcon(bubbleDisMoiView: Bubble?, numberOfNotice: String) { @@ -204,11 +204,11 @@ class FloatingModule( } private fun configureClickOnBubbleAction(bubbleDisMoiView: Bubble?) { - bubbleDisMoiView!!.setOnBubbleClickListener(object : OnBubbleClickListener { - override fun onBubbleClick(bubble: Bubble?) { - sendEventToReactNative("floating-dismoi-bubble-press", "") - } - }) +// bubbleDisMoiView!!.setOnBubbleClickListener(object : OnBubbleClickListener { +// override fun onBubbleClick(bubble: Bubble?) { +// sendEventToReactNative("floating-dismoi-bubble-press", "") +// } +// }) } private fun addNewFloatingDisMoiBubble(x: Int, y: Int, numberOfNotice: String) { @@ -221,19 +221,19 @@ class FloatingModule( configureClickOnBubbleAction(bubbleDisMoiView) bubbleDisMoiView!!.setShouldStickToWall(true) - bubblesManager!!.addDisMoiBubble(bubbleDisMoiView, x, y) +// bubblesManager!!.addDisMoiBubble(bubbleDisMoiView, x, y) } private fun removeDisMoiBubble() { if (bubbleDisMoiView != null) { - bubblesManager!!.removeDisMoiBubble(bubbleDisMoiView) +// bubblesManager!!.removeDisMoiBubble(bubbleDisMoiView) bubbleDisMoiView = null } } private fun removeDisMoiMessage() { if (messageDisMoiView != null) { - messagesManager!!.removeDisMoiMessage(messageDisMoiView) +// messagesManager!!.removeDisMoiMessage(messageDisMoiView) messageDisMoiView = null } } diff --git a/android/app/src/main/java/com/dismoi/scout/floating/FloatingService.kt b/android/app/src/main/java/com/dismoi/scout/floating/FloatingService.kt index 22f15ae..ec3e182 100644 --- a/android/app/src/main/java/com/dismoi/scout/floating/FloatingService.kt +++ b/android/app/src/main/java/com/dismoi/scout/floating/FloatingService.kt @@ -4,139 +4,252 @@ import android.app.Service import android.content.Intent import android.graphics.PixelFormat import android.os.* +import android.util.Log import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.WindowManager +import androidx.annotation.RequiresApi +import androidx.lifecycle.MutableLiveData +import com.dismoi.scout.R import com.dismoi.scout.floating.layout.* import com.dismoi.scout.floating.layout.Message +import okhttp3.* +import org.json.JSONObject +import java.io.IOException +enum class State { + HIDDEN, BUBBLE, MESSAGE +} class FloatingService : Service() { + private val TAG = "FloatingService" + private lateinit var handler: Handler + + private lateinit var windowManager: WindowManager + private lateinit var inflater: LayoutInflater private val binder = FloatingServiceBinder() - private var bubblesTrash: Trash? = null - private var windowManager: WindowManager? = null - private var layoutCoordinator: Coordinator? = null - override fun onBind(intent: Intent): IBinder? { + private var noticesIds: List = listOf() + private var liveNotices: List> = mutableListOf() + + private lateinit var bubbleView: Bubble + private lateinit var bubbleLayoutParams: WindowManager.LayoutParams + + private lateinit var messageView: Message + private lateinit var messageLayoutParams: WindowManager.LayoutParams + + private lateinit var trashView: Trash + private lateinit var trashLayoutParams: WindowManager.LayoutParams + + private var state: State = State.HIDDEN + + @RequiresApi(Build.VERSION_CODES.O) + override fun onCreate() { + super.onCreate() + + windowManager = getSystemService(WINDOW_SERVICE) as WindowManager + inflater = getSystemService(LAYOUT_INFLATER_SERVICE) as LayoutInflater + handler = Handler(Looper.getMainLooper()) + + createBubbleView() + createMessageView() + createTrashView() + + Log.d(TAG, "Service created") + } + + + override fun onBind(intent: Intent?): IBinder { return binder } - override fun onUnbind(intent: Intent): Boolean { - return super.onUnbind(intent) + // TODO Proper typed an mapped object ! + @RequiresApi(Build.VERSION_CODES.N) + fun showNotices(noticesIds: List) { + Log.d(TAG, "Receiving notices") + if (this.noticesIds.size == noticesIds.size && this.noticesIds.containsAll(noticesIds)) { + Log.d(TAG, "Same as before") + if (state == State.HIDDEN) { + Log.d(TAG, "current: HIDDEN -> Showing Bubble") + showBubble(noticesIds.size) + } + return + } + + Log.d(TAG, "Got new notices ${noticesIds.joinToString(",")}") + + liveNotices = noticesIds.map { noticeId -> + val liveNotice = MutableLiveData() + fetchNotice(noticeId, liveNotice) + liveNotice + } + + messageView.notices = liveNotices + showBubble(noticesIds.size) } - private fun getWindowManager(): WindowManager? { - if (windowManager == null) { - windowManager = getSystemService(WINDOW_SERVICE) as WindowManager + fun hide() { + if (state !== State.HIDDEN) { + Log.d(TAG, "Hide everything !") + hideBubble() + hideMessage() + hideTrash() + state = State.HIDDEN } - return windowManager } - fun addDisMoiBubble(bubble: Bubble, x: Int, y: Int) { - val layoutParams = buildLayoutParamsForBubble(x, y) + @RequiresApi(Build.VERSION_CODES.O) + private fun buildLayoutParamsForBubble() { + bubbleLayoutParams = WindowManager.LayoutParams( + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, + PixelFormat.TRANSPARENT + ) + bubbleLayoutParams.gravity = Gravity.TOP or Gravity.START + bubbleLayoutParams.x = 10 + bubbleLayoutParams.y = 1500 + } - bubble.setWindowManager(getWindowManager()) - bubble.setViewParams(layoutParams) - bubble.setLayoutCoordinator(layoutCoordinator) - addViewToWindow(bubble) + @RequiresApi(Build.VERSION_CODES.O) + private fun createBubbleView() { + buildLayoutParamsForBubble() + bubbleView = inflater.inflate(R.layout.bubble, null, false) as Bubble + bubbleView.setViewParams(bubbleLayoutParams) + bubbleView.setOnBubbleClickListener(object : Bubble.OnBubbleClickListener { + override fun onBubbleClick(bubble: Bubble?) { + onBubbleClick() + } + }) } - fun addDisMoiMessage(message: Message?, y: Int) { - val layoutParams = buildLayoutParamsForMessage(y) - message!!.setWindowManager(getWindowManager()) - message!!.setViewParams(layoutParams) - message.setLayoutCoordinator(layoutCoordinator) - addViewToWindow(message) + private fun showView(view: View, layoutParams: WindowManager.LayoutParams) + { + handler.post { + try { + windowManager.addView(view, layoutParams) + } catch (exception: IllegalArgumentException) { + Log.d("FloatingService", "${view.id} already in window : $exception : ${exception.stackTrace}") + } catch (exception: IllegalStateException) { + Log.d("FloatingService", "${view.id} already in window : $exception : ${exception.stackTrace}") + } + } } - fun addTrash(trashLayoutResourceId: Int) { - if (trashLayoutResourceId != 0) { - bubblesTrash = Trash(this) - bubblesTrash!!.setWindowManager(windowManager) - bubblesTrash!!.setViewParams(buildLayoutParamsForTrash()) - bubblesTrash!!.visibility = View.GONE - LayoutInflater.from(this).inflate(trashLayoutResourceId, bubblesTrash, true) - addViewToWindow(bubblesTrash!!) - initializeLayoutCoordinator() + private fun removeView(view: View) { + handler.post { + try { + windowManager.removeView(view) + } catch (exception: IllegalArgumentException) { + Log.d("FloatingService", "${view.id} not in view") + } catch (exception: IllegalStateException) { + Log.d("FloatingService", "${view.id} not in view") + } } } - private fun initializeLayoutCoordinator() { - layoutCoordinator = Coordinator.Builder(this) - .setWindowManager(getWindowManager()) - .setTrashView(bubblesTrash) - .build() + private fun showBubble(count: Int) { + Log.d(TAG, "Showing Bubble for $count") + bubbleView.setCount(count) + showView(bubbleView, bubbleLayoutParams) + state = State.BUBBLE } - private fun addViewToWindow(view: Layout) { - Handler(Looper.getMainLooper()).post { - getWindowManager()!!.addView( - view, - view.getViewParams() - ) - } + private fun onBubbleClick() { + showMessage() + hideBubble() } - private fun buildLayoutParamsForBubble(x: Int, y: Int): WindowManager.LayoutParams? { - var params: WindowManager.LayoutParams? = null - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - params = WindowManager.LayoutParams( - WindowManager.LayoutParams.WRAP_CONTENT, - WindowManager.LayoutParams.WRAP_CONTENT, - WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, - WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, - PixelFormat.TRANSPARENT - ) - } - params!!.gravity = Gravity.TOP or Gravity.START - params.x = x - params.y = y - return params - } - - private fun buildLayoutParamsForMessage(y: Int): WindowManager.LayoutParams? { - var params: WindowManager.LayoutParams? = null - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - params = WindowManager.LayoutParams( - WindowManager.LayoutParams.MATCH_PARENT, - WindowManager.LayoutParams.WRAP_CONTENT, - WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, - WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN, - PixelFormat.TRANSPARENT - ) - } - params!!.gravity = Gravity.END - params.y = y - return params - } - - private fun buildLayoutParamsForTrash(): WindowManager.LayoutParams? { - val x = 0 - val y = 0 - var params: WindowManager.LayoutParams? = null - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - params = WindowManager.LayoutParams( - WindowManager.LayoutParams.MATCH_PARENT, - WindowManager.LayoutParams.MATCH_PARENT, - WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, - WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, - PixelFormat.TRANSPARENT - ) - } - params!!.x = x - params.y = y - return params + private fun hideBubble() { + removeView(bubbleView) + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun buildLayoutParamsForMessage() { + messageLayoutParams = WindowManager.LayoutParams( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, + WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN, + PixelFormat.TRANSPARENT + ) + messageLayoutParams.gravity = Gravity.END + messageLayoutParams.y = 1500 + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createMessageView() { + buildLayoutParamsForMessage() + messageView = inflater.inflate(R.layout.highlight_messages, null, false) as Message + messageView.setViewParams(bubbleLayoutParams) + } + + private fun showMessage() { + showView(messageView, messageLayoutParams) + state = State.MESSAGE + } + + private fun hideMessage() { + removeView(messageView) + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun buildLayoutParamsForTrash() { + trashLayoutParams = WindowManager.LayoutParams( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, + PixelFormat.TRANSPARENT + ) + trashLayoutParams.x = 0 + trashLayoutParams.y = 0 + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createTrashView() { + buildLayoutParamsForTrash() + trashView = Trash(this) + trashView.setViewParams(trashLayoutParams) + trashView.visibility = View.GONE + inflater.inflate(R.layout.bubble_trash, trashView, true) } - fun removeBubble(bubble: Bubble?) { - getWindowManager()!!.removeView(bubble) - bubble!!.notifyBubbleRemoved() + private fun showTrash() { + showView(trashView, trashLayoutParams) + } + private fun hideTrash() { + removeView(trashView) } - fun removeMessage(message: Message?) { - getWindowManager()!!.removeView(message) + // TODO To be moved to a repository + private fun fetchNotice(noticeId: Int, liveNotice: MutableLiveData) { + val noticeEndpoint = "https://notices.bulles.fr/api/v3/notices/$noticeId" // TODO to be moved to env configuration + val request = Request.Builder().url(noticeEndpoint).build() + val client = OkHttpClient() + + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.d(TAG, "Failed calling $noticeEndpoint") + + } + + @RequiresApi(Build.VERSION_CODES.N) + override fun onResponse(call: Call, response: Response) { + val body = response.body()?.string() + if (body == null) { + Log.e(TAG, "Empty response $noticeEndpoint") + return + } + val notice = JSONObject(body) + Log.d(TAG, "Fetched notice $noticeId") + + liveNotice.postValue(notice) + } + }) } inner class FloatingServiceBinder : Binder() { diff --git a/android/app/src/main/java/com/dismoi/scout/floating/Manager.kt b/android/app/src/main/java/com/dismoi/scout/floating/Manager.kt index 91de348..29d172d 100644 --- a/android/app/src/main/java/com/dismoi/scout/floating/Manager.kt +++ b/android/app/src/main/java/com/dismoi/scout/floating/Manager.kt @@ -6,9 +6,6 @@ import android.content.Intent import android.content.ServiceConnection import android.os.IBinder import android.util.Log -import com.dismoi.scout.floating.layout.Bubble -import com.dismoi.scout.floating.layout.Layout -import com.dismoi.scout.floating.layout.Message import com.dismoi.scout.floating.FloatingService.FloatingServiceBinder class Manager private constructor(private val context: Context) { @@ -16,11 +13,11 @@ class Manager private constructor(private val context: Context) { private var floatingService: FloatingService? = null private var trashLayoutResourceId = 0 private var listener: OnCallback? = null + private val disMoiServiceConnection: ServiceConnection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName, service: IBinder) { val binder = service as FloatingServiceBinder floatingService = binder.service - configureBubblesService() bounded = true listener?.onInitialized() } @@ -30,10 +27,6 @@ class Manager private constructor(private val context: Context) { } } - private fun configureBubblesService() { - floatingService!!.addTrash(trashLayoutResourceId) - } - fun initialize() { context.bindService( Intent(context, FloatingService::class.java), @@ -46,30 +39,6 @@ class Manager private constructor(private val context: Context) { context.unbindService(disMoiServiceConnection) } - fun addDisMoiBubble(bubble: Layout?, x: Int, y: Int) { - if (bounded) { - floatingService!!.addDisMoiBubble(bubble as Bubble, x, y) - } - } - - fun addDisMoiMessage(message: Layout?, y: Int) { - if (bounded) { - floatingService!!.addDisMoiMessage(message as Message, y) - } - } - - fun removeDisMoiBubble(bubble: Layout?) { - if (bounded) { - floatingService!!.removeBubble(bubble as Bubble?) - } - } - - fun removeDisMoiMessage(message: Layout?) { - if (bounded) { - floatingService!!.removeMessage(message as Message) - } - } - class Builder(context: Context) { private val disMoiManager: Manager fun setInitializationCallback(listener: OnCallback): Builder { diff --git a/android/app/src/main/java/com/dismoi/scout/floating/layout/Bubble.kt b/android/app/src/main/java/com/dismoi/scout/floating/layout/Bubble.kt index 6156eed..fd8102e 100644 --- a/android/app/src/main/java/com/dismoi/scout/floating/layout/Bubble.kt +++ b/android/app/src/main/java/com/dismoi/scout/floating/layout/Bubble.kt @@ -11,6 +11,7 @@ import android.util.AttributeSet import android.util.DisplayMetrics import android.view.MotionEvent import android.view.WindowManager +import android.widget.TextView import com.dismoi.scout.R class Bubble(context: Context, attrs: AttributeSet?) : Layout(context, attrs) { @@ -52,6 +53,11 @@ class Bubble(context: Context, attrs: AttributeSet?) : Layout(context, attrs) { playAnimation() } + fun setCount(count: Int) { + var textView: TextView? = findViewById(R.id.number_of_notice) + textView?.text = count.toString() + } + @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { when (event.action) { diff --git a/android/app/src/main/java/com/dismoi/scout/floating/layout/Coordinator.kt b/android/app/src/main/java/com/dismoi/scout/floating/layout/Coordinator.kt index e8ee356..899e6e4 100644 --- a/android/app/src/main/java/com/dismoi/scout/floating/layout/Coordinator.kt +++ b/android/app/src/main/java/com/dismoi/scout/floating/layout/Coordinator.kt @@ -61,7 +61,7 @@ class Coordinator private constructor() { fun notifyBubbleRelease(bubble: Bubble) { if (_trashView != null) { if (checkIfBubbleIsOverTrash(bubble)) { - _bubblesService!!.removeBubble(bubble) +// _bubblesService!!.removeBubble(bubble) } _trashView!!.visibility = View.GONE } diff --git a/android/app/src/main/java/com/dismoi/scout/floating/layout/Message.kt b/android/app/src/main/java/com/dismoi/scout/floating/layout/Message.kt index 818e3a6..c9f6fd8 100644 --- a/android/app/src/main/java/com/dismoi/scout/floating/layout/Message.kt +++ b/android/app/src/main/java/com/dismoi/scout/floating/layout/Message.kt @@ -1,20 +1,157 @@ package com.dismoi.scout.floating.layout +import android.animation.* +import android.app.Service import android.content.Context +import android.graphics.BitmapFactory +import android.os.Build +import android.os.StrictMode +import android.text.Html +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.text.style.URLSpan import android.util.AttributeSet -import android.animation.* +import android.util.Log +import android.view.LayoutInflater import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.RequiresApi +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import com.dismoi.scout.R +import com.synnapps.carouselview.CarouselView +import com.synnapps.carouselview.ViewListener +import org.json.JSONObject +import java.net.URL +@RequiresApi(Build.VERSION_CODES.N) class Message(context: Context, attrs: AttributeSet?) : Layout(context, attrs) { + // TODO have correct class for notices + var notices: List> = listOf() + set (notices) { + val carouselView = findViewById(R.id.carouselView) + carouselView.pageCount = notices.size + field = notices + } + + private var inflater: LayoutInflater = context.getSystemService(Service.LAYOUT_INFLATER_SERVICE) as LayoutInflater + + // TODO Fix fonts usage + // val type = Typeface.createFromAsset(context.assets, "fonts/Helvetica.ttf") + // val typeBold = Typeface.createFromAsset(context.assets, "fonts/Helvetica-Bold.ttf") + + private var carouselListener = ViewListener { position -> + val liveNotice = notices[position] + + // TODO Extract in fun + val noticeView = inflater.inflate(R.layout.message, null) + + val noticeObserver = Observer { noticeContent -> + if (liveNotice.value == null) { + return@Observer + } + + val message = noticeContent.getString("message") + val contributor = noticeContent.getJSONObject("contributor") + val contributorName = contributor.getString("name") // TODO don’t crash if no name + val contributorAvatarUrl = + if (contributor.has("avatar") && !contributor.isNull("avatar")) + URL( + contributor.getJSONObject("avatar").getJSONObject("normal").getString("url") + ) + else null + + val modified = noticeContent.getString("modified") // TODO should be parsed + + val messageView = noticeView.findViewById(R.id.link) + messageView.text = message.toSpanned() +// messageView.typeface = type + messageView.handleUrlClicks { urlLinkToClick -> + Log.d("Message", "Clicked link") + // TODO Legacy : sendEventToReactNative("URL_CLICK_LINK", Uri.parse(urlLinkToClick).toString()) + } + + val contributorNameView = noticeView.findViewById(R.id.name) + contributorNameView.text = contributorName +// contributorNameView.typeface = typeBold // TODO Should be defined in view, right ? + + val modifiedDateView = noticeView.findViewById(R.id.date) + modifiedDateView.text = modified // TODO should be formatted correctly +// modifiedDateView.typeface = typeBold // TODO Should be defined in view, right ? + + val deleteButton = noticeView.findViewById(R.id.closeNotice) + deleteButton.setOnClickListener { + // TODO What to do onClick + } + + if (contributorAvatarUrl != null) { + // TODO Do we need that ? + val policy = StrictMode.ThreadPolicy.Builder().permitAll().build() + StrictMode.setThreadPolicy(policy) + + val avatarView = noticeView.findViewById(R.id.contributorProfile) + val avatarBitmap = + BitmapFactory.decodeStream(contributorAvatarUrl.openConnection().getInputStream()) + avatarView.setImageBitmap(avatarBitmap) + } + } + + liveNotice.observeForever(noticeObserver) // TODO we should link to a LifecycleOwner + + noticeView + } + + override fun onViewAdded(child: View?) { + super.onViewAdded(child) + + val carouselView = findViewById(R.id.carouselView) + carouselView.setViewListener(carouselListener) + } override fun onAttachedToWindow() { - super.onAttachedToWindow() + super.onAttachedToWindow() playAnimation() } + /** + * Searches for all URLSpans in current text replaces them with our own ClickableSpans + * forwards clicks to provided function. + */ + private fun TextView.handleUrlClicks(onClicked: ((String) -> Unit)? = null) { + // create span builder and replaces current text with it + text = SpannableStringBuilder.valueOf(text).apply { + // search for all URL spans and replace all spans with our own clickable spans + this.getSpans(0, length, URLSpan::class.java).forEach { + // add new clickable span at the same position + setSpan( + object : ClickableSpan() { + override fun onClick(widget: View) { + onClicked?.invoke(it.url) + } + }, + getSpanStart(it), + getSpanEnd(it), + Spanned.SPAN_INCLUSIVE_EXCLUSIVE + ) + // remove old URLSpan + removeSpan(it) + } + } + // make sure movement method is set + movementMethod = LinkMovementMethod.getInstance() + } + + private fun playAnimation() { val animator = ObjectAnimator.ofFloat(this, View.TRANSLATION_Y, 600f, 0f) animator.start() } + + fun String.toSpanned(): Spanned { + return Html.fromHtml(this, Html.FROM_HTML_MODE_LEGACY) + } } diff --git a/android/docs/Architecture.md b/android/docs/Architecture.md new file mode 100644 index 0000000..68fedb5 --- /dev/null +++ b/android/docs/Architecture.md @@ -0,0 +1,75 @@ +# Architecture + +## Page Content Analysis + +Origin objective was to use accessibility service to extract current DOM from Chrome, to be able to apply XPath rule on live content. + +After a lot of trials and reading, we failed to meet this main goal. + +What we managed to do is to deep analyze the DOM, but exposed as a tree of AccessibilityNodeInfo. Tag names and classes are not exposed (every tag is exposed as an Android component, View and so on …), but we seem to have the full hierarchy with ids and text contents. + +As an example, whenever a product page is loaded in amazon (or when app becomes visible), irrespective of the URL, we can find the product title : + +```kotlin +val webview = findWebview(root) +if (webview != null) { + val titleExpanderContent = findById(webview, "titleExpanderContent") + if (titleExpanderContent != null) { + val titleView = findHeading(titleExpanderContent) + val title = titleView?.text ?: titleView?.contentDescription + Log.d(TAG, "Found Amazon page title : $title") + } +} + +``` + +gives + +```shell +D/Accessibility: Event : TYPE_WINDOW_CONTENT_CHANGED, Package: com.google.android.apps.nexuslauncher, Source: null +D/Accessibility: Active window packageName : com.android.chrome, className: android.widget.FrameLayout +D/Accessibility: Found Amazon page title : 6S Casque Bluetooth sans Fil, écouteurs stéréo sans Fil stéréo Pliables Hi-FI Écouteurs avec Microphone intégré, Micro SD/TF, FM pour iPhone/Samsung/iPad/PC (Or Noir) +``` + +### How the application worked before + +Simplified: + +- We use accessibility services, Kotlin side, to detect URL, and we send all URLs to React Native HeadlessTask +- In RN HeadlessTask we first fetch all matching contexts, and if current url matches and we have an xpath condition, we send it back to a Kotlin XPath module +- In the Kotlin XPath module, we fetch current url and try on apply xpath on fetched html, and then we send the response back to RN HeadlessTask <- Here we don’t have access to accessiblity informations anymore +- In RN HeadlessTask, we have some last checks (notice hasn’t been deleted, …) and then we call a Kotlin UI service that manages all the floating UI, to display found notices + +### Target flow + +The main work I did here (in 8ac3716 in particular) was to be able to analyze page content given a list of matching contexts to check. + +The thing is, because current content analysis (XPath) is done later and only given an URL, I could not change the strategy in a drop-in manner … + +I had to bring back most of the context matching process together, in the accessibility service, so that we can push screen content analysis further when needed + +So, now, the BackgroundService (that deserves a better name) does the following : + +- Fetch all matching contexts (only once, will be happen regularly) +- Every time screen change (well, with a 500 ms debounce), detect application, and if supported, current URL +- Find all corresponding matching contexts +- <-- If any has content condition, we’re ready to analyze here +- Then we send all Ids to the Floating UI service I adapted for this + +I do not use RN Headless task anymore, but it’s still there because I did not port all the functionality yet. Same for some other files, as FloatingModule, FloatingCoordinator, XPathModule … + + +Used documentation and references : +- https://stuff.mit.edu/afs/sipb/project/android/docs/guide/topics/ui/accessibility/services.html +- https://medium.com/nerd-for-tech/track-web-browser-usage-in-android-using-accessibility-service-800bfa2745d2 +- https://stackoverflow.com/questions/33318083/how-to-get-webview-from-accessibilitynodeinfo +- https://groups.google.com/a/chromium.org/g/chromium-dev/c/2VC16XswAaI +- https://stackoverflow.com/questions/7282789/is-there-any-way-to-get-access-to-dom-structure-in-androids-webview +- https://stackoverflow.com/questions/40522043/how-to-access-html-content-of-accessibilitynodeinfo-of-a-webview-element-using-a +- https://stackoverflow.com/questions/65326148/why-accessibilityservice-failed-to-retrieve-content-of-a-webview-but-works-prop +- https://github.com/google/talkback +- https://github.com/chromium/chromium/blob/master/content/public/android/java/src/org/chromium/content/browser/accessibility/WebContentsAccessibilityImpl.java +- https://www.py4u.net/discuss/630533 +- https://stackoverflow.com/questions/10634908/accessibility-and-android-webview +- https://stackoverflow.com/questions/36793154/accessibilityservice-not-returning-view-ids +