Skip to content

Commit

Permalink
Replaced UP swipe with obscuring view for Suggested Posts.
Browse files Browse the repository at this point in the history
Also:
- Reduced APK size from 8.8MB to 55KB by dropping auto-added but
  never-used build-time dependencies.
- Separated event throttling by event type to avoid the BACK (from
  Search/Explore/Reels) from preventing the Suggested Posts detection
  from working.
- Increased min & target SDK API levels to 34 because the
  obscuring-view work went through iterations that required API
  34 (e.g. attachAccessibilityOverlayToWindow) though it didn't
  work out because of inability to acquire a hostToken for
  SurfaceControlViewHost's constructor.
- Removed restriction of the accessibility service's configuration to
  just Google Maps & Instagram because with that in place, didn't seem
  possible to get notified when a different app/activity was
  started/resumed to hide the obscuring view :/
  • Loading branch information
fischman committed Nov 27, 2023
1 parent bbe731b commit bceae46
Show file tree
Hide file tree
Showing 8 changed files with 108 additions and 86 deletions.
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ manual action anymore.
## What it does
When the `Reels` or `Explore and search` icons in the app's home view are
tapped, a fake "back" swipe will be performed to go back to the home
view. When scrolling the main feed and reaching `Suggested Posts` a fake
"up" swipe will be performed, refreshing the feed and jumping to its
top.
view. When scrolling the main feed and reaching `Suggested Posts` an
obscuring view will hide the rest of the feed below that header.

## How it works
This "app" registers an [Android Accessibility Service](https://developer.android.com/guide/topics/ui/accessibility/service) that watches
Expand Down
16 changes: 8 additions & 8 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ plugins {

android {
namespace 'org.fischman.noexplore'
compileSdk 33
compileSdk 34

defaultConfig {
applicationId "org.fischman.noexplore"
minSdk 28
targetSdk 33
minSdk 34
targetSdk 34
versionCode 1
versionName "1.0"

Expand All @@ -30,9 +30,9 @@ android {

dependencies {

implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.8.0'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
// implementation 'androidx.appcompat:appcompat:1.6.1'
// implementation 'com.google.android.material:material:1.8.0'
// testImplementation 'junit:junit:4.13.2'
// androidTestImplementation 'androidx.test.ext:junit:1.1.5'
// androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
}
3 changes: 1 addition & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.NoExplore"
tools:targetApi="31">
tools:targetApi="34">
<service
android:name=".NoExploreService"
android:enabled="true"
Expand Down
127 changes: 97 additions & 30 deletions app/src/main/java/org/fischman/noexplore/NoExploreService.java
Original file line number Diff line number Diff line change
@@ -1,27 +1,39 @@
package org.fischman.noexplore;

import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;

import android.accessibilityservice.AccessibilityService;
import android.accessibilityservice.AccessibilityServiceInfo;
import android.accessibilityservice.GestureDescription;
import android.graphics.Color;
import android.graphics.Path;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.hardware.display.DisplayManager;
import android.util.Log;
import android.view.Gravity;
import android.view.View;
import android.view.WindowManager;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.LinearLayout;
import android.widget.TextView;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class NoExploreService extends AccessibilityService {
private final boolean DEBUG = false;

private void emit(String msg) {
Log.e("AMI", msg);
}
private void emit(String msg) { if (DEBUG) Log.e("AMI", msg); }

// Time (epoch millis) of the last event that was acted on.
// Time (epoch millis) of the last event that was acted on for each type.
// Used to throttle actions, in preference to a longer android:notificationTimeout value because
// that also slows down the first action instead of only subsequent ones.
private long lastEventTime = 0;
private Map<Integer, Long> lastEventTime = new HashMap<Integer, Long>();

private TextView obscuringView;

public NoExploreService() {}

Expand All @@ -34,7 +46,18 @@ public void onServiceConnected() {
return;
}
info.flags |= AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS;
info.flags |= AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS;
setServiceInfo(info);

TextView tv = new TextView(this);
tv.setLayoutParams(new LinearLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT, 1F));
tv.setGravity(Gravity.CENTER);
tv.setTextAlignment(View.TEXT_ALIGNMENT_CENTER);
tv.setText("\"Suggested posts\" being obscured by " + getString(R.string.app_name));
tv.setTextColor(Color.YELLOW);
tv.setBackgroundColor(Color.BLACK);
tv.setTextSize(35F);
obscuringView = tv;
}

private static String emptyIfNull(CharSequence s) { return s != null ? s.toString() : ""; }
Expand All @@ -54,24 +77,33 @@ private void dumpRecursively(String prefix, AccessibilityNodeInfo node) {

@Override
public void onAccessibilityEvent(AccessibilityEvent event) {
if (event.getPackageName() == null) {
if (event.getEventType() == AccessibilityEvent.TYPE_WINDOWS_CHANGED &&
(event.getWindowChanges() & AccessibilityEvent.WINDOWS_CHANGE_ACTIVE) != 0) {
emit("hideObscuringOverlay because TYPE_WINDOWS_CHANGED from null package");
hideObscuringOverlay();
}
return;
}

long eventTime = event.getEventTime();
if (eventTime - lastEventTime < 500) return;
if (eventTime - lastEventTime.getOrDefault(event.getEventType(), 0L) < 500) return;
if (DEBUG) emit("event is " + event);

switch (emptyIfNull(event.getPackageName())) {
switch (event.getPackageName().toString()) {
case "com.instagram.android":
onInstagramEvent(event);
return;
case "com.google.android.apps.maps":
onMapsEvent(event);
return;
default:
// Should be excluded by accessibility_service_config.xml configuration!
throw new RuntimeException("Unexpected event package name: " + event);
return;
}
}

private AccessibilityNodeInfo firstDescendant(AccessibilityNodeInfo source, String viewID) {
if (source == null) return null;
List<AccessibilityNodeInfo> candidates = source.findAccessibilityNodeInfosByViewId(viewID);
if (candidates.isEmpty()) return null;
if (candidates.size() > 1 && DEBUG)
Expand All @@ -85,18 +117,16 @@ private void onMapsEvent(AccessibilityEvent event) {
long eventTime = event.getEventTime();
int eventType = event.getEventType();
if (eventType != AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) return;
AccessibilityNodeInfo source = event.getSource();
if (source == null) { return; }
AccessibilityNodeInfo found = firstDescendant(source, "com.google.android.apps.maps:id/explore_tab_home_title_card");
AccessibilityNodeInfo found = firstDescendant(event.getSource(), "com.google.android.apps.maps:id/explore_tab_home_title_card");
if (found == null) return;
Rect r = new Rect();
found.getBoundsInScreen(r);
found.getBoundsInWindow(r);
// Don't process the view until it's popped up enough for drag-down to be meaningful.
if (r.top > 2000) return;
// Don't process the view if its bounds don't make sense (e.g. overlapping notification bar).
if (r.top < 10) return;

lastEventTime = eventTime;
lastEventTime.put(eventType, eventTime);
Path path = new Path();
path.moveTo((r.left+r.right)/2, r.top+5);
path.lineTo((r.left+r.right)/2, r.top+200);
Expand All @@ -111,11 +141,24 @@ private void onMapsEvent(AccessibilityEvent event) {
}, null);
emit("Dispatch gesture for maps: " + dispatched);
if (DEBUG) {
emit("Gesture because saw maps \"Latest in\" in source, which follows after source: " + source);
dumpRecursively(source);
emit("Gesture because saw maps \"Latest in\" in source, which follows after source: " + event.getSource());
dumpRecursively(event.getSource());
}
}

private int profileTabTop = -1;
private int profileTabTop(AccessibilityNodeInfo source) {
if (profileTabTop > 0) { return profileTabTop; }
AccessibilityNodeInfo profileTab = firstDescendant(source, "com.instagram.android:id/profile_tab");
if (profileTab == null) return 0;
Rect rect = new Rect();
profileTab.getBoundsInWindow(rect);
emit("profileTab: " + rect);
profileTabTop = rect.top;
return profileTabTop;
}


private void onInstagramEvent(AccessibilityEvent event) {
long eventTime = event.getEventTime();
int eventType = event.getEventType();
Expand All @@ -126,29 +169,53 @@ private void onInstagramEvent(AccessibilityEvent event) {
AccessibilityNodeInfo search = firstDescendant(event.getSource(), "com.instagram.android:id/search_tab");
AccessibilityNodeInfo reels = firstDescendant(event.getSource(), "com.instagram.android:id/clips_tab");
if (search != null || reels != null) {
lastEventTime = eventTime;
lastEventTime.put(eventType, eventTime);
if (DEBUG) emit("BACK because search or reels is not null: " + search + ", " + reels);
performGlobalAction(GLOBAL_ACTION_BACK);
}
return;
}

if (eventType != AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) return;
AccessibilityNodeInfo source = event.getSource();
if (source == null) { return; }
AccessibilityNodeInfo found = firstDescendant(source, "com.instagram.android:id/end_of_feed_demarcator_container");
if (found == null) return;
AccessibilityNodeInfo found = firstDescendant(event.getSource(), "com.instagram.android:id/end_of_feed_demarcator_container");
if (found == null) { hideObscuringOverlay(); return; }
Rect r = new Rect();
found.getBoundsInScreen(r);
// Arbitrary choice of "1000" below, but useful to prevent the app being unusable if there
// are no new posts so "Suggested Posts" shows up at the top of the feed.
if (r.top > 1000) {
lastEventTime = eventTime;
if (DEBUG) {
emit("UP because saw Suggested Posts in source at " + r + ", which follows after source: " + source);
dumpRecursively(source);
}
performGlobalAction(GESTURE_SWIPE_UP);
lastEventTime.put(eventType, eventTime);
emit("Obscuring view because end_of_feed_demarcator is at " + r);
showObscuringOverlay(r.bottom, profileTabTop(event.getSource()));
}

private WindowManager getWindowManager() { return (WindowManager) getSystemService(WINDOW_SERVICE); }
private DisplayManager getDisplayManager() { return (DisplayManager) getSystemService(DISPLAY_SERVICE); }
private void hideObscuringOverlay() {
emit("hideObscuringOverlay");
if (obscuringView.getParent() == null) {
emit("hideObscuringOverlay no-op'd");
return;
}
getWindowManager().removeView(obscuringView);
}

private void showObscuringOverlay(int top, int bottom) {
emit("showObscuringOverlay: " + top +" - " + bottom);
// Force always being able to see a strip at the top for scrolling stories back into view.
if (top < 400) top = 400;
WindowManager wm = getWindowManager();
WindowManager.LayoutParams lp = new WindowManager.LayoutParams();
lp.type = WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY;
lp.format = PixelFormat.OPAQUE;
lp.flags |= WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
lp.width = MATCH_PARENT;
lp.height = bottom - top; // wm.getMaximumWindowMetrics().getBounds().height() - top - bottom;
lp.gravity = Gravity.BOTTOM;
lp.y = wm.getMaximumWindowMetrics().getBounds().height() - bottom;
if (obscuringView.getParent() == null) {
emit(" showObscuringOverlay - addView");
wm.addView(obscuringView, lp);
} else {
emit(" showObscuringOverlay - updateView");
wm.updateViewLayout(obscuringView, lp);
}
}

Expand Down
16 changes: 0 additions & 16 deletions app/src/main/res/values-night/themes.xml

This file was deleted.

10 changes: 0 additions & 10 deletions app/src/main/res/values/colors.xml

This file was deleted.

16 changes: 0 additions & 16 deletions app/src/main/res/values/themes.xml

This file was deleted.

1 change: 0 additions & 1 deletion app/src/main/res/xml/accessibility_service_config.xml
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/accessibility_service_description"
android:packageNames="com.instagram.android, com.google.android.apps.maps"
android:accessibilityEventTypes="typeAllMask"
android:accessibilityFlags="flagDefault"
android:accessibilityFeedbackType="feedbackGeneric"
Expand Down

0 comments on commit bceae46

Please sign in to comment.