Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive

- uses: actions/setup-java@v4
with:
Expand All @@ -30,9 +28,6 @@ jobs:
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
EOF

- name: Build libxposed to mavenLocal
run: ./gradlew buildLibxposed

- name: Decode keystore
env:
KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE }}
Expand Down
4 changes: 0 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive

- name: Set up JDK 21
uses: actions/setup-java@v4
Expand All @@ -35,9 +34,6 @@ jobs:
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
EOF

- name: Build libxposed to mavenLocal
run: ./gradlew buildLibxposed

- name: Decode keystore
env:
KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE }}
Expand Down
8 changes: 0 additions & 8 deletions .gitmodules

This file was deleted.

8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ CleanShare is an Xposed module that removes Direct Share's suggested contact/con
from Android's Share Sheet.

![Android CI](https://github.com/hxreborn/cleanshare/actions/workflows/android.yml/badge.svg)
![Kotlin](https://img.shields.io/badge/Kotlin-2.1.21-7F52FF?style=flat&logo=kotlin&logoColor=white)
![Kotlin](https://img.shields.io/badge/Kotlin-2.3.10-7F52FF?style=flat&logo=kotlin&logoColor=white)
![Android](https://img.shields.io/badge/API-30%2B-3DDC84?logo=android&logoColor=white)
![Xposed Repo](https://img.shields.io/github/downloads/Xposed-Modules-Repo/eu.hxreborn.cleanshare/total?label=Xposed%20Repo&logo=android&logoColor=white)

<div align="center">
<img src=".github/assets/direct-share-targets.png" alt="Direct Share targets row removed" width="320" />
Expand Down Expand Up @@ -37,7 +38,7 @@ it also blocks backend shortcut queries to prevent share target profiling.
## Requirements

- Android 11 (API 30) or higher
- [LSPosed](https://github.com/JingMatrix/LSPosed) (JingMatrix fork recommended)
- [LSPosed](https://github.com/JingMatrix/LSPosed) 2.0.0+ (JingMatrix fork recommended)
- Pixel or AOSP-based ROM (Other OEMs are untested)

## Installation
Expand Down Expand Up @@ -128,9 +129,8 @@ app's [storage sandbox](https://developer.android.com/training/data-storage#scop
## Build

```bash
git clone --recurse-submodules https://github.com/hxreborn/cleanshare.git
git clone https://github.com/hxreborn/cleanshare.git
cd cleanshare
./gradlew buildLibxposed
./gradlew assembleRelease
```

Expand Down
36 changes: 27 additions & 9 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
plugins {
alias(libs.plugins.agp.app)
alias(libs.plugins.kotlin)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.serialization)
alias(libs.plugins.aboutlibraries)
alias(libs.plugins.ktlint)
}

android {
Expand All @@ -15,8 +13,8 @@ android {
applicationId = "eu.hxreborn.cleanshare"
minSdk = 30
targetSdk = 36
versionCode = 210
versionName = "2.1.0"
versionCode = 300
versionName = "3.0.0"
}

signingConfigs {
Expand Down Expand Up @@ -71,7 +69,7 @@ android {

packaging {
resources {
pickFirsts += "META-INF/xposed/*"
merges += "META-INF/xposed/*"
}
}

Expand All @@ -90,10 +88,30 @@ kotlin {
jvmToolchain(21)
}

ktlint {
version.set("1.8.0")
android.set(true)
ignoreFailures.set(false)
val copyAboutLibraries by tasks.registering(Copy::class) {
dependsOn("exportLibraryDefinitions")
from("build/generated/aboutLibraries/aboutlibraries.json")
into("build/generated/aboutLibrariesRes/raw")
}
android.sourceSets["main"].res.directories.add("build/generated/aboutLibrariesRes")
tasks.named("preBuild").configure { dependsOn(copyAboutLibraries) }

val ktlintCli = "com.pinterest.ktlint:ktlint-cli:1.8.0"

val ktlintCheck by tasks.registering(JavaExec::class) {
group = "verification"
description = "Check Kotlin code style"
classpath = configurations.detachedConfiguration(dependencies.create(ktlintCli))
mainClass.set("com.pinterest.ktlint.Main")
args("src/**/*.kt")
}

val ktlintFormat by tasks.registering(JavaExec::class) {
group = "verification"
description = "Fix Kotlin code style"
classpath = configurations.detachedConfiguration(dependencies.create(ktlintCli))
mainClass.set("com.pinterest.ktlint.Main")
args("-F", "src/**/*.kt")
}

dependencies {
Expand Down
17 changes: 4 additions & 13 deletions app/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -1,27 +1,18 @@
# LSPosed module entry point
-keep class eu.hxreborn.cleanshare.CleanShareModule { *; }

# Keep all @XposedHooker annotated classes
-keep @io.github.libxposed.api.annotations.XposedHooker class * { *; }

# Keep annotation classes
-keep class io.github.libxposed.api.annotations.** { *; }
# Prevent R8 from merging hook classes into app process code (compileOnly API)
-keep,allowobfuscation class eu.hxreborn.cleanshare.hook.** { *; }

# Preserve annotations
-keepattributes *Annotation*
-keepattributes RuntimeVisibleAnnotations

# Keep hook methods
-keepclassmembers class * {
@io.github.libxposed.api.annotations.BeforeInvocation <methods>;
@io.github.libxposed.api.annotations.AfterInvocation <methods>;
}

# Keep XposedModule subclasses
-adaptresourcefilecontents META-INF/xposed/java_init.list
-keep,allowobfuscation,allowoptimization public class * extends io.github.libxposed.api.XposedModule {
public <init>(...);
public <init>();
public void onPackageLoaded(...);
public void onPackageReady(...);
}

# ContentProvider registered in manifest must survive shrinking
Expand Down
134 changes: 73 additions & 61 deletions app/src/main/kotlin/eu/hxreborn/cleanshare/CleanShareModule.kt
Original file line number Diff line number Diff line change
@@ -1,56 +1,56 @@
package eu.hxreborn.cleanshare

import android.app.Activity
import android.app.ActivityManager
import android.content.IntentFilter
import android.content.pm.ResolveInfo
import android.content.pm.ShortcutManager
import android.os.Build
import android.os.Bundle
import android.util.Log
import eu.hxreborn.cleanshare.hook.deletion.CheckboxHook
import eu.hxreborn.cleanshare.hook.deletion.DeletionHook
import eu.hxreborn.cleanshare.hook.directshare.LowRamHooker
import eu.hxreborn.cleanshare.hook.directshare.ShareTargetsHooker
import eu.hxreborn.cleanshare.hook.quickshare.QuickShareFilterHooker
import eu.hxreborn.cleanshare.util.PREFS_FILE_NAME
import eu.hxreborn.cleanshare.util.PREF_KEY_HIDE_DIRECT_SHARE
import eu.hxreborn.cleanshare.util.PREF_KEY_HIDE_QUICK_SHARE
import eu.hxreborn.cleanshare.util.QUICK_SHARE_ACTIVITY
import eu.hxreborn.cleanshare.util.debugLog
import eu.hxreborn.cleanshare.util.findClass
import eu.hxreborn.cleanshare.util.findMethodByName
import io.github.libxposed.api.XposedInterface
import io.github.libxposed.api.XposedModule
import io.github.libxposed.api.XposedModuleInterface.ModuleLoadedParam
import io.github.libxposed.api.XposedModuleInterface.PackageLoadedParam
import io.github.libxposed.api.XposedModuleInterface.PackageReadyParam

private const val ANDROID_FRAMEWORK_PKG = "android"
private const val INTENT_RESOLVER_PKG = "com.android.intentresolver"
private const val AIAI_PKG = "com.google.android.as"

// ChooserActivity location differs by API level
private val CHOOSER_CLASS_NAMES =
listOf(
"com.android.intentresolver.ChooserActivity", // API 33+
"com.android.internal.app.ChooserActivity", // API 30-32
"com.android.intentresolver.ChooserActivity",
"com.android.internal.app.ChooserActivity",
)

// Share sheet lives in framework on 11–12, IntentResolver from 13+
private val SHARE_SHEET_PKG: String =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
INTENT_RESOLVER_PKG
} else {
ANDROID_FRAMEWORK_PKG
}

class CleanShareModule(
base: XposedInterface,
param: ModuleLoadedParam,
) : XposedModule(base, param) {
class CleanShareModule : XposedModule() {
companion object {
const val TAG = "CleanShare"
var instance: CleanShareModule? = null
private set
}

init {
override fun onModuleLoaded(param: ModuleLoadedParam) {
instance = this
log("CleanShare v${BuildConfig.VERSION_NAME} loaded in ${param.processName}")
log(Log.INFO, TAG, "v${BuildConfig.VERSION_NAME} loaded in ${param.processName}")
}

override fun onPackageLoaded(param: PackageLoadedParam) {
override fun onPackageReady(param: PackageReadyParam) {
when (param.packageName) {
SHARE_SHEET_PKG -> {
hookLowRam()
Expand All @@ -64,91 +64,103 @@ class CleanShareModule(
}
}

// Spoof low-RAM so the share sheet skips creating ShortcutLoader
private fun hookLowRam() {
runCatching {
val method = ActivityManager::class.java.getDeclaredMethod("isLowRamDeviceStatic")
method.isAccessible = true
hook(method, LowRamHooker::class.java)
log("Hooked ActivityManager.isLowRamDeviceStatic")
}.onFailure {
log("LowRam hook failed: ${it.message}")
}
hook(method).intercept { chain ->
val prefs = getRemotePreferences(PREFS_FILE_NAME)
val enabled = prefs?.getBoolean(PREF_KEY_HIDE_DIRECT_SHARE, true) ?: true
debugLog { "[DirectShare] isLowRamDeviceStatic called, enabled=$enabled" }
if (enabled) return@intercept true
chain.proceed()
}
log(Log.INFO, TAG, "Hooked ActivityManager.isLowRamDeviceStatic")
}.onFailure { log(Log.WARN, TAG, "LowRam hook failed: ${it.message}") }
}

// Insert "Delete after sharing" checkbox for screenshot shares
private fun hookScreenshotDelete(classLoader: ClassLoader) {
val chooserClass = findClass(classLoader, CHOOSER_CLASS_NAMES)
if (chooserClass == null) {
log("ChooserActivity not found, skipping deletion hooks")
return
}
val chooserClass =
findClass(classLoader, CHOOSER_CLASS_NAMES) ?: run {
log(Log.WARN, TAG, "ChooserActivity not found, skipping deletion hooks")
return
}

// Hook onCreate for checkbox insertion
runCatching {
val method = chooserClass.getDeclaredMethod("onCreate", Bundle::class.java)
method.isAccessible = true
hook(method, CheckboxHook::class.java)
log("Hooked ${chooserClass.simpleName}.onCreate")
}.onFailure {
log("Checkbox hook failed: ${it.message}")
}
hook(method).intercept { chain ->
chain.proceed()
CheckboxHook.handleOnCreate(chain.thisObject as? Activity ?: return@intercept null)
null
}
log(Log.INFO, TAG, "Hooked ${chooserClass.simpleName}.onCreate")
}.onFailure { log(Log.WARN, TAG, "Checkbox hook failed: ${it.message}") }

// Hook startSelected for deletion trigger
val startSelected = findMethodByName(chooserClass, "startSelected")
if (startSelected == null) {
log("startSelected not found on ${chooserClass.name}")
return
}
val startSelected =
findMethodByName(chooserClass, "startSelected") ?: run {
log(Log.WARN, TAG, "startSelected not found on ${chooserClass.name}")
return
}

runCatching {
startSelected.isAccessible = true
hook(startSelected, DeletionHook::class.java)
log("Hooked ${chooserClass.simpleName}.startSelected")
}.onFailure {
log("Deletion hook failed: ${it.message}")
}
hook(startSelected).intercept { chain ->
chain.proceed()
DeletionHook.handleStartSelected()
null
}
log(Log.INFO, TAG, "Hooked ${chooserClass.simpleName}.startSelected")
}.onFailure { log(Log.WARN, TAG, "Deletion hook failed: ${it.message}") }
}

// Block AiAi shortcut queries to prevent share target profiling
private fun hookShareTargets() {
runCatching {
val method =
ShortcutManager::class.java.getDeclaredMethod(
"getShareTargets",
IntentFilter::class.java,
)
ShortcutManager::class.java
.getDeclaredMethod("getShareTargets", IntentFilter::class.java)
method.isAccessible = true
hook(method, ShareTargetsHooker::class.java)
log("Hooked ShortcutManager.getShareTargets")
}.onFailure {
log("ShareTargets hook failed: ${it.message}")
}
hook(method).intercept { chain ->
val prefs = getRemotePreferences(PREFS_FILE_NAME)
val enabled = prefs?.getBoolean(PREF_KEY_HIDE_DIRECT_SHARE, true) ?: true
debugLog { "[DirectShare] getShareTargets called, enabled=$enabled" }
if (enabled) return@intercept emptyList<Any>()
chain.proceed()
}
log(Log.INFO, TAG, "Hooked ShortcutManager.getShareTargets")
}.onFailure { log(Log.WARN, TAG, "ShareTargets hook failed: ${it.message}") }
}

// Filter Quick Share from share targets by hooking queryIntentActivitiesAsUser
// Works on both framework (A11-12) and IntentResolver (A13+)
@Suppress("UNCHECKED_CAST")
private fun hookQuickShareFilter(classLoader: ClassLoader) {
val pmClass =
runCatching {
classLoader.loadClass("android.app.ApplicationPackageManager")
}.getOrNull() ?: run {
log("Quick Share hook: ApplicationPackageManager not found")
log(Log.WARN, TAG, "Quick Share hook: ApplicationPackageManager not found")
return
}

val methods = pmClass.declaredMethods.filter { it.name == "queryIntentActivitiesAsUser" }
if (methods.isEmpty()) {
log("Quick Share hook: no queryIntentActivitiesAsUser methods found")
log(Log.WARN, TAG, "Quick Share hook: no queryIntentActivitiesAsUser methods found")
return
}

methods.forEach { method ->
runCatching {
method.isAccessible = true
hook(method, QuickShareFilterHooker::class.java)
hook(method).intercept { chain ->
val result = chain.proceed()
val prefs = getRemotePreferences(PREFS_FILE_NAME)
val enabled = prefs?.getBoolean(PREF_KEY_HIDE_QUICK_SHARE, false) ?: false
if (!enabled) return@intercept result
val list = result as? MutableList<ResolveInfo> ?: return@intercept result
list.removeAll { it.activityInfo?.name == QUICK_SHARE_ACTIVITY }
result
}
}
}
log("Hooked queryIntentActivitiesAsUser (${methods.size} overloads)")
log(Log.INFO, TAG, "Hooked queryIntentActivitiesAsUser (${methods.size} overloads)")
}
}
Loading
Loading