diff --git a/.gitignore b/.gitignore
index aa724b7..705e648 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,4 @@
.externalNativeBuild
.cxx
local.properties
+.idea/*
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..ee21ecf
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,173 @@
+# Developer & Agent Guide: Fadocx Optimization
+
+## Workflow Orchestration
+
+### 1. Plan Node Default
+- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions)
+- If something goes sideways, STOP and re-plan immediately - don't keep pushing
+- Use plan mode for verification steps, not just building
+- Write detailed specs upfront to reduce ambiguity
+
+### 2. Subagent Strategy
+- Use subagents liberally to keep main context window clean
+- Offload research, exploration, and parallel analysis to subagents
+- For complex problems, throw more compute at it via subagents
+- One tack per subagent for focused execution
+
+### 3. Self-Improvement Loop
+- After ANY correction from the user: update `tasks/lessons.md` with the pattern
+-Write rules for yourself that prevent the same mistake
+- Ruthlessly iterate on these lessons until mistake rate drops
+- Review lessons at session start for relevant project
+
+### 4. Verification Before Done
+- Never mark a task complete without proving it works
+- Diff behavior between main and your changes when relevant
+- Ask yourself: "Would a staff engineer approve this?"
+- Run tests, check logs, demonstrate correctness
+
+### 5. Demand Elegance (Balanced)
+- For non-trivial changes: pause and ask "is there a more elegant way?"
+- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution"
+- Skip this for simple, obvious fixes - don't over-engineer
+- Challenge your own work before presenting it
+
+#### 6. Autonomous Bug Fixing
+- When given a bug report: just fix it. Don't ask for hand-holding
+- Point at logs, errors, failing tests - then resolve them
+- Zero context switching required from the user
+- Go fix failing CI tests without being told how
+
+## Task Management
+1. **Plan First**: Write plan to `tasks/todo.md` with checkable items
+2. **Verify Plan**: Check in before starting implementation
+3. **Track Progress**: Mark items complete as you go
+4. **Explain Changes**: High-level summary at each step
+5. **Document Results**: Add review section to `tasks/todo.md`
+6. **Capture Lessons**: Update `tasks/lessons.md` after corrections
+
+## Core Principles
+- **Simplicity First**: Make every change as simple as possible. Impact minimal code.
+- **No Laziness**: Find root causes. No temporary fixes. Senior developer standards.
+- **Minimal Impact**: Changes should only touch what's necessary. Avoid introducing bugs.
+
+---
+
+## Wasted App 2026 Revival - Mandatory Rules
+
+### Backward Compatibility Guarantee
+- **NEVER remove features**. Deprecated ≠ broken.
+- Device Admin API stays. If fixing Android 13+ issues, ADD permissions, don't replace.
+- minSdk 23 (Android 6) support MUST be maintained.
+- Old devices (Android 6-12): Device Admin only, no P2P.
+- New devices (Android 13+): Device Admin + P2P both available.
+
+### Execution Discipline
+- **Phase order is SACRED**. Do not parallelize or skip.
+ - Phase 1: API update + permissions (build foundation first)
+ - Phase 2: P2P networking (core system)
+ - Phase 3: UI + control (user-facing)
+ - Phase 4: Testing (integration + compat)
+- Each phase must PASS testing before next begins.
+- If Phase N breaks Phase N-1, STOP and re-plan.
+
+### Code Quality Standards
+- All networking code: Use TLS 1.2+ (OkHttp 4.11+)
+- All device communication: Encrypt with Tink (Google recommended)
+- All device data: EncryptedSharedPreferences + Room with encryption
+- No HTTP for control commands (TLS only).
+- No plain text device secrets.
+
+### Library Selection Principle
+- **Support maximum span**: Pick libs that work Android 6-16.
+- Prefer: OkHttp (API 21+), Gson (pure Java), Room (AndroidX), Tink (API 19+).
+- Avoid: Jetpack Compose (API 21+), experimental/alpha libs.
+- If adding a lib, verify minSdk support in official docs.
+
+### Testing Before Commit
+- Phase 1: Build + ForegroundService notification check.
+- Phase 2: Two real devices discover + pairing works.
+- Phase 3: Settings sync in < 500ms.
+- Phase 4: Full matrix tested (Android 6, 13, 15 minimum).
+- No feature commit without passing test on target API.
+
+### Documentation Requirements
+- Keep ROADMAP_2026_REVIVAL.md updated with actual progress.
+- If changing timeline, update roadmap with reason.
+- Lessons learned → /memories/session/lessons.md (for next session).
+- No silent pivots; document deviations.
+
+### Deprecation Handling (Critical)
+- Device Admin deprecated in Android 9 (2018) → Still use it, it works.
+- Respect the original code intent: lock device, wipe data, USB detection, inactivity timeout.
+- Treat P2P as ADDITION, not replacement.
+- When Device Admin is replaced (future), it's done AFTER P2P is stable.
+
+### Logging Standards (Extensive Tracking)
+
+**Log Levels:**
+- `VERBOSE`: Entry/exit of methods, variable state
+- `DEBUG`: Flow checkpoints, decision branches, API calls
+- `INFO`: User actions (lock triggered, wipe started, device paired)
+- `WARN`: Recoverable errors (retry pending, fallback activated)
+- `ERROR`: Failures requiring intervention (TLS failure, peer timeout)
+
+**Key Flows to Log:**
+
+1. **Device Admin Flow:**
+ ```
+ DEBUG: "lockNow() called"
+ DEBUG: "DevicePolicyManager.lockNow() invoked"
+ INFO: "Device lock triggered"
+ ERROR: "Device Admin not active" (if applicable)
+ ```
+
+2. **P2P Discovery Flow:**
+ ```
+ DEBUG: "mDNS discovery started"
+ DEBUG: "Searching for _wasted._tcp.local"
+ DEBUG: "Peer found: [device-name]"
+ INFO: "Pairing dialog shown"
+ DEBUG: "PIN validation: [status]"
+ INFO: "Pairing successful"
+ ```
+
+3. **Settings Sync Flow:**
+ ```
+ DEBUG: "Settings change detected: [key]=[value]"
+ DEBUG: "Broadcasting to [N] peers via TLS"
+ DEBUG: "ACK received from [peer-name]"
+ INFO: "Settings synced (latency: [ms])"
+ ERROR: "Sync failed for peer [name]"
+ ```
+
+4. **Reset Flow:**
+ ```
+ DEBUG: "Reset button clicked (local/remote)"
+ INFO: "Reset confirmation shown"
+ DEBUG: "User confirmed reset"
+ INFO: "wipeData() called" OR "Reset command sent to [peer]"
+ VERBOSE: "wipeData flags: [flags]"
+ ```
+
+**Log Tags:**
+- Use TAG constants: `DeviceAdminManager`, `P2PNetwork`, `SettingsSync`, `PairingManager`, `MessageQueue`, `SecurityManager`
+- One tag per class for easy filtering: `adb logcat DeviceAdminManager:* | grep -v VERBOSE`
+
+**Filtering on Device:**
+```bash
+# Watch all Wasted logs
+adb logcat | grep "Wasted"
+
+# Watch P2P discovery only
+adb logcat | grep "P2PNetwork"
+
+# Watch errors
+adb logcat *:E | grep -i wasted
+```
+
+**Testing Verification:**
+- Screenshot or save logcat when verifying flows
+- Document expected vs actual log sequences in test reports
+- Use logs to prove timing (latency <500ms for settings sync)
+- Archive logs per device for cross-device comparison
diff --git a/app/build.gradle b/app/build.gradle
index a73f367..37597dc 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -1,37 +1,40 @@
plugins {
id 'com.android.application'
id 'kotlin-android'
+ id 'com.google.devtools.ksp'
}
android {
- compileSdk 32
+ namespace = "me.lucky.wasted"
+ compileSdk = 36
defaultConfig {
- applicationId "me.lucky.wasted"
- minSdk 23
- targetSdk 32
- versionCode 39
- versionName "1.5.10"
+ applicationId = "me.lucky.wasted"
+ minSdk = 23
+ targetSdk = 36
+ versionCode = 41
+ versionName = "2.0.0"
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
- minifyEnabled false
+ minifyEnabled = false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
+ sourceCompatibility JavaVersion.VERSION_11
+ targetCompatibility JavaVersion.VERSION_11
}
kotlinOptions {
- jvmTarget = '1.8'
+ jvmTarget = '11'
}
buildFeatures {
- viewBinding true
+ viewBinding = true
+ aidl = true
}
lint {
disable 'MissingTranslation'
@@ -39,21 +42,39 @@ android {
}
dependencies {
- implementation 'androidx.core:core-ktx:1.8.0'
- implementation 'androidx.appcompat:appcompat:1.5.0'
- implementation 'com.google.android.material:material:1.6.1'
- implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
+ implementation 'androidx.core:core-ktx:1.18.0'
+ implementation 'androidx.appcompat:appcompat:1.7.1'
+ implementation 'com.google.android.material:material:1.13.0'
+ implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
testImplementation 'junit:junit:4.13.2'
- androidTestImplementation 'androidx.test.ext:junit:1.1.3'
- androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
+ androidTestImplementation 'androidx.test.ext:junit:1.3.0'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
- implementation 'androidx.security:security-crypto:1.0.0'
+ // Security & Encryption
+ implementation 'com.google.crypto.tink:tink-android:1.21.0'
+
+ // Network (open source, Apache 2.0)
+ implementation 'com.squareup.okhttp3:okhttp:5.3.2'
+ implementation 'com.squareup.okhttp3:okhttp-tls:5.3.2'
+ implementation 'com.google.zxing:core:3.5.4'
+ implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
+ implementation 'com.google.code.gson:gson:2.14.0'
+
+ // Local Storage
+ implementation 'androidx.room:room-runtime:2.8.4'
+ implementation 'androidx.room:room-ktx:2.8.4'
+ ksp 'androidx.room:room-compiler:2.8.4'
+
// https://issuetracker.google.com/issues/238425626
- implementation('androidx.preference:preference-ktx:1.2.0') {
+ implementation('androidx.preference:preference-ktx:1.2.1') {
exclude group: 'androidx.lifecycle', module:'lifecycle-viewmodel'
exclude group: 'androidx.lifecycle', module:'lifecycle-viewmodel-ktx'
}
implementation 'androidx.biometric:biometric:1.1.0'
- implementation 'androidx.drawerlayout:drawerlayout:1.1.1'
+ implementation 'androidx.drawerlayout:drawerlayout:1.2.0'
implementation 'info.guardianproject.panic:panic:1.0'
+
+ // Shizuku — ADB-level shell without root (API 23+)
+ implementation 'dev.rikka.shizuku:api:13.1.5'
+ implementation 'dev.rikka.shizuku:provider:13.1.5'
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 42c42de..4b53601 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,14 +1,32 @@
+ xmlns:tools="http://schemas.android.com/tools">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:exported="false"
+ android:foregroundServiceType="connectedDevice">
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/aidl/me/lucky/wasted/IRemoteShell.aidl b/app/src/main/aidl/me/lucky/wasted/IRemoteShell.aidl
new file mode 100644
index 0000000..b5d92e8
--- /dev/null
+++ b/app/src/main/aidl/me/lucky/wasted/IRemoteShell.aidl
@@ -0,0 +1,7 @@
+// Interface exposed by ShizukuShell running in the Shizuku (ADB-level) process.
+// Kept minimal: one method to execute a shell command and return its output.
+package me.lucky.wasted;
+
+interface IRemoteShell {
+ String executeNow(String command);
+}
diff --git a/app/src/main/java/me/lucky/wasted/Application.kt b/app/src/main/java/me/lucky/wasted/Application.kt
index eb01f5e..d3e1a32 100644
--- a/app/src/main/java/me/lucky/wasted/Application.kt
+++ b/app/src/main/java/me/lucky/wasted/Application.kt
@@ -3,9 +3,23 @@ package me.lucky.wasted
import android.app.Application
import com.google.android.material.color.DynamicColors
+import me.lucky.wasted.shizuku.ShizukuManager
+
class Application : Application() {
+
+ companion object {
+ /**
+ * App-wide ShizukuManager singleton. Initialized in onCreate() before any component starts.
+ * Access safely: `(context.applicationContext as? Application)?.shizuku`
+ */
+ lateinit var shizuku: ShizukuManager
+ private set
+ }
+
override fun onCreate() {
super.onCreate()
DynamicColors.applyToActivitiesIfAvailable(this)
+ shizuku = ShizukuManager(this)
+ shizuku.init() // registers Shizuku listeners; binds shell if already running
}
}
\ No newline at end of file
diff --git a/app/src/main/java/me/lucky/wasted/MainActivity.kt b/app/src/main/java/me/lucky/wasted/MainActivity.kt
index 5759d28..7e380fc 100644
--- a/app/src/main/java/me/lucky/wasted/MainActivity.kt
+++ b/app/src/main/java/me/lucky/wasted/MainActivity.kt
@@ -1,9 +1,12 @@
package me.lucky.wasted
+import android.Manifest
import android.content.ClipData
import android.content.ClipboardManager
import android.content.SharedPreferences
+import android.os.Build
import android.os.Bundle
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricManager
@@ -17,6 +20,7 @@ import com.google.android.material.textfield.TextInputLayout
import me.lucky.wasted.databinding.ActivityMainBinding
import me.lucky.wasted.fragment.*
+import me.lucky.wasted.p2p.P2PNetworkFragment
import me.lucky.wasted.trigger.shared.NotificationManager
open class MainActivity : AppCompatActivity() {
@@ -29,6 +33,16 @@ open class MainActivity : AppCompatActivity() {
prefs.copyTo(prefsdb, key)
}
+ private val requestNotificationPermissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { isGranted: Boolean ->
+ if (isGranted) {
+ android.util.Log.d("PostNotifications", "POST_NOTIFICATIONS permission granted")
+ } else {
+ android.util.Log.d("PostNotifications", "POST_NOTIFICATIONS permission denied")
+ }
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
@@ -36,6 +50,7 @@ open class MainActivity : AppCompatActivity() {
init1()
if (initBiometric()) return
init2()
+ requestNotificationPermissionIfNeeded()
setup()
}
@@ -45,6 +60,19 @@ open class MainActivity : AppCompatActivity() {
prefs.copyTo(prefsdb)
}
+ private fun requestNotificationPermissionIfNeeded() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ val permission = Manifest.permission.POST_NOTIFICATIONS
+ val permissionStatus = ContextCompat.checkSelfPermission(this, permission)
+ if (permissionStatus != android.content.pm.PackageManager.PERMISSION_GRANTED) {
+ android.util.Log.d("PostNotifications", "Requesting POST_NOTIFICATIONS permission")
+ requestNotificationPermissionLauncher.launch(permission)
+ } else {
+ android.util.Log.d("PostNotifications", "POST_NOTIFICATIONS permission already granted")
+ }
+ }
+ }
+
private fun init2() {
NotificationManager(this).createNotificationChannels()
replaceFragment(MainFragment())
@@ -74,6 +102,7 @@ open class MainActivity : AppCompatActivity() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
init2()
+ requestNotificationPermissionIfNeeded()
setup()
}
})
@@ -145,6 +174,7 @@ open class MainActivity : AppCompatActivity() {
R.id.nav_trigger_lock -> LockFragment()
R.id.nav_trigger_application -> ApplicationFragment()
R.id.nav_recast -> RecastFragment()
+ R.id.nav_p2p -> P2PNetworkFragment()
else -> MainFragment()
}
diff --git a/app/src/main/java/me/lucky/wasted/Preferences.kt b/app/src/main/java/me/lucky/wasted/Preferences.kt
index ee54a22..ade5bb6 100644
--- a/app/src/main/java/me/lucky/wasted/Preferences.kt
+++ b/app/src/main/java/me/lucky/wasted/Preferences.kt
@@ -6,8 +6,8 @@ import android.os.Build
import android.os.UserManager
import androidx.core.content.edit
import androidx.preference.PreferenceManager
-import androidx.security.crypto.EncryptedSharedPreferences
-import androidx.security.crypto.MasterKeys
+import me.lucky.wasted.security.LegacyEncryptedPreferencesReader
+import me.lucky.wasted.security.TinkEncryptedSharedPreferences
class Preferences(ctx: Context, encrypted: Boolean = true) {
companion object {
@@ -24,6 +24,9 @@ class Preferences(ctx: Context, encrypted: Boolean = true) {
private const val RECAST_RECEIVER = "recast_receiver"
private const val RECAST_EXTRA_KEY = "recast_extra_key"
private const val RECAST_EXTRA_VALUE = "recast_extra_value"
+ private const val REMOTE_RESET_CONFIRMATION = "remote_reset_confirmation"
+
+ private const val P2P_ACTIVE = "p2p_enabled"
private const val TRIGGERS = "triggers"
private const val TRIGGER_LOCK_COUNT = "trigger_lock_count"
@@ -44,13 +47,12 @@ class Preferences(ctx: Context, encrypted: Boolean = true) {
}
private val prefs: SharedPreferences = if (encrypted) {
- val mk = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
- EncryptedSharedPreferences.create(
- FILE_NAME,
- mk,
+ TinkEncryptedSharedPreferences.create(
ctx,
- EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
- EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
+ FILE_NAME,
+ legacyEntriesProvider = {
+ LegacyEncryptedPreferencesReader.readEntries(ctx, FILE_NAME)
+ },
)
} else {
val context = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
@@ -116,6 +118,14 @@ class Preferences(ctx: Context, encrypted: Boolean = true) {
get() = prefs.getString(RECAST_EXTRA_VALUE, "") ?: ""
set(value) = prefs.edit { putString(RECAST_EXTRA_VALUE, value) }
+ var p2pEnabled: Boolean
+ get() = prefs.getBoolean(P2P_ACTIVE, false)
+ set(value) = prefs.edit { putBoolean(P2P_ACTIVE, value) }
+
+ var remoteResetConfirmationEnabled: Boolean
+ get() = prefs.getBoolean(REMOTE_RESET_CONFIRMATION, false)
+ set(value) = prefs.edit { putBoolean(REMOTE_RESET_CONFIRMATION, value) }
+
fun registerListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) =
prefs.registerOnSharedPreferenceChangeListener(listener)
diff --git a/app/src/main/java/me/lucky/wasted/Utils.kt b/app/src/main/java/me/lucky/wasted/Utils.kt
index 0bf27f7..a2514af 100644
--- a/app/src/main/java/me/lucky/wasted/Utils.kt
+++ b/app/src/main/java/me/lucky/wasted/Utils.kt
@@ -90,10 +90,15 @@ class Utils(private val ctx: Context) {
val enabled = prefs.isEnabled
val triggers = prefs.triggers
val isUSB = triggers.and(Trigger.USB.value) != 0
- val foregroundEnabled = enabled && (triggers.and(Trigger.LOCK.value) != 0 || isUSB)
+ val p2pEnabled = prefs.p2pEnabled
+ val foregroundEnabled = (enabled && (triggers.and(Trigger.LOCK.value) != 0 || isUSB)) || p2pEnabled
setForegroundEnabled(foregroundEnabled)
setComponentEnabled(RestartReceiver::class.java, foregroundEnabled)
setComponentEnabled(UsbReceiver::class.java, enabled && isUSB)
+ // Stop P2P network when user has disabled P2P
+ if (!p2pEnabled) {
+ me.lucky.wasted.p2p.P2PController.instanceOrNull()?.stop()
+ }
}
private fun setForegroundEnabled(enabled: Boolean) =
diff --git a/app/src/main/java/me/lucky/wasted/admin/AdminProvisioningActivity.kt b/app/src/main/java/me/lucky/wasted/admin/AdminProvisioningActivity.kt
new file mode 100644
index 0000000..933a3bb
--- /dev/null
+++ b/app/src/main/java/me/lucky/wasted/admin/AdminProvisioningActivity.kt
@@ -0,0 +1,70 @@
+package me.lucky.wasted.admin
+
+import android.app.Activity
+import android.app.admin.DevicePolicyManager
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import androidx.appcompat.app.AppCompatActivity
+import me.lucky.wasted.MainActivity
+
+class AdminProvisioningActivity : AppCompatActivity() {
+ companion object {
+ private const val TAG = "ProvisioningActivity"
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ when (intent.action) {
+ DevicePolicyManager.ACTION_GET_PROVISIONING_MODE -> handleGetProvisioningMode()
+ DevicePolicyManager.ACTION_ADMIN_POLICY_COMPLIANCE -> handleAdminPolicyCompliance()
+ DevicePolicyManager.ACTION_PROVISIONING_SUCCESSFUL -> handleProvisioningSuccessful()
+ else -> {
+ Log.w(TAG, "Unsupported provisioning action: ${intent.action}")
+ finish()
+ }
+ }
+ }
+
+ private fun handleGetProvisioningMode() {
+ val requestedModes = intent.getIntegerArrayListExtra(
+ DevicePolicyManager.EXTRA_PROVISIONING_ALLOWED_PROVISIONING_MODES,
+ )
+ val selectedMode = when {
+ requestedModes.isNullOrEmpty() -> DevicePolicyManager.PROVISIONING_MODE_FULLY_MANAGED_DEVICE
+ requestedModes.contains(DevicePolicyManager.PROVISIONING_MODE_FULLY_MANAGED_DEVICE) -> {
+ DevicePolicyManager.PROVISIONING_MODE_FULLY_MANAGED_DEVICE
+ }
+ requestedModes.contains(DevicePolicyManager.PROVISIONING_MODE_MANAGED_PROFILE) -> {
+ DevicePolicyManager.PROVISIONING_MODE_MANAGED_PROFILE
+ }
+ else -> requestedModes.first()
+ }
+
+ Log.i(TAG, "Returning provisioning mode=$selectedMode")
+ val resultData = Intent()
+ .putExtra(DevicePolicyManager.EXTRA_PROVISIONING_MODE, selectedMode)
+ .putExtra(DevicePolicyManager.EXTRA_PROVISIONING_SKIP_EDUCATION_SCREENS, false)
+
+ setResult(Activity.RESULT_OK, resultData)
+ finish()
+ }
+
+ private fun handleAdminPolicyCompliance() {
+ Log.i(TAG, "Admin policy compliance acknowledged")
+ DevicePolicyBootstrap.configureActiveAdmin(this, "admin-policy-compliance")
+ setResult(Activity.RESULT_OK)
+ finish()
+ }
+
+ private fun handleProvisioningSuccessful() {
+ Log.i(TAG, "Provisioning successful activity launched")
+ DevicePolicyBootstrap.configureActiveAdmin(this, "provisioning-successful")
+ startActivity(
+ Intent(this, MainActivity::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK),
+ )
+ finish()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/me/lucky/wasted/admin/DeviceAdminManager.kt b/app/src/main/java/me/lucky/wasted/admin/DeviceAdminManager.kt
index 35a8403..238dd32 100644
--- a/app/src/main/java/me/lucky/wasted/admin/DeviceAdminManager.kt
+++ b/app/src/main/java/me/lucky/wasted/admin/DeviceAdminManager.kt
@@ -5,20 +5,94 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Build
+import android.os.Environment
+import android.os.UserManager
+import android.provider.MediaStore
import java.lang.Exception
+import android.util.Log
+import me.lucky.wasted.Application as WastedApp
import me.lucky.wasted.Preferences
class DeviceAdminManager(private val ctx: Context) {
+ data class ResetSupport(
+ val isSupported: Boolean,
+ val userMessage: String,
+ )
+
private val dpm = ctx.getSystemService(DevicePolicyManager::class.java)
+ private val userManager = ctx.getSystemService(UserManager::class.java)
private val deviceAdmin by lazy { ComponentName(ctx, DeviceAdminReceiver::class.java) }
private val prefs by lazy { Preferences.new(ctx) }
+ private val adminComponentName by lazy { deviceAdmin.flattenToShortString() }
fun remove() = dpm?.removeActiveAdmin(deviceAdmin)
fun isActive() = dpm?.isAdminActive(deviceAdmin) ?: false
+ fun isDeviceOwner() = dpm?.isDeviceOwnerApp(ctx.packageName) == true
+ fun isProfileOwner() = dpm?.isProfileOwnerApp(ctx.packageName) == true
+
+ fun getManagementSummary(): String {
+ return when {
+ isDeviceOwner() -> "Wasted is enrolled as Device Owner on this phone"
+ isOrgOwnedProfileOwner() -> "Wasted manages this phone as an organization-owned profile owner"
+ isProfileOwner() -> "Wasted is enrolled as a profile owner on this phone"
+ isActive() -> "Device Admin is active on this phone"
+ else -> "Wasted is not enrolled on this phone"
+ }
+ }
fun lockNow() { if (!lockPrivilegedNow()) dpm?.lockNow() }
+ fun getResetSupport(): ResetSupport {
+ if (!isActive()) return ResetSupport(
+ isSupported = false,
+ userMessage = "Device Admin is not active on this phone.",
+ )
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return ResetSupport(
+ isSupported = true,
+ userMessage = "Full factory reset available (Android 13 or earlier).",
+ )
+
+ if (canUseFullDeviceWipeApi()) return ResetSupport(
+ isSupported = true,
+ userMessage = "Full factory reset armed — Device Owner mode active.",
+ )
+
+ // Android 14+, not Device Owner — tiered best-effort wipe
+ return ResetSupport(
+ isSupported = true,
+ userMessage = when (getProtectionTier()) {
+ 2 -> "Strong wipe armed: TRIM + app data clear + file deletion on trigger."
+ 3 -> "Partial wipe armed: photos and files deleted. Enable Shizuku for stronger protection."
+ else -> "Minimal wipe: only Wasted data cleared. Grant All Files Access and enable Shizuku."
+ },
+ )
+ }
+
+ /**
+ * Returns the current wipe tier:
+ * 1 = Device Owner → full factory reset
+ * 2 = Shizuku connected → TRIM + pm clear + file wipe
+ * 3 = MANAGE_EXTERNAL_STORAGE → file wipe only
+ * 4 = nothing extra → own data only
+ *
+ * Only meaningful on Android 14+ when not Device Owner.
+ * On <14, wipeData() is always a full factory reset regardless of tier.
+ */
+ fun getProtectionTier(): Int = when {
+ canUseFullDeviceWipeApi() -> 1
+ isShizukuConnected() -> 2
+ hasManageExternalStoragePermission() -> 3
+ else -> 4
+ }
+
+ private fun isShizukuConnected(): Boolean {
+ return try {
+ WastedApp.shizuku.isConnected()
+ } catch (_: UninitializedPropertyAccessException) { false }
+ }
+
private fun lockPrivilegedNow(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false
var ok = true
@@ -33,15 +107,158 @@ class DeviceAdminManager(private val ctx: Context) {
}
fun wipeData() {
- var flags = 0
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
- flags = flags.or(DevicePolicyManager.WIPE_SILENTLY)
- if (prefs.isWipeEmbeddedSim && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
- flags = flags.or(DevicePolicyManager.WIPE_EUICC)
- dpm?.wipeData(flags)
+ val resetSupport = getResetSupport()
+ if (!resetSupport.isSupported) {
+ throw IllegalStateException(resetSupport.userMessage)
+ }
+
+ if (canUseFullDeviceWipeApi()) {
+ // Tier 1: Device Owner → factory reset (reformats the partition, TRIM not needed)
+ Log.i(TAG, "wipeData: Tier 1 — hardReset via Device Owner")
+ hardReset()
+ return
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ // Tier 2+3: Android 14+, not Device Owner — best-effort chain
+ Log.i(TAG, "wipeData: Android 14+, not Device Owner — best-effort wipe")
+ // Tier 2: Shizuku (TRIM + pm clear) — makes deleted data unrecoverable
+ if (isShizukuConnected()) {
+ Log.i(TAG, "wipeData: Tier 2 — running Shizuku wipe commands")
+ try {
+ WastedApp.shizuku.runWipeCommands()
+ } catch (e: Exception) {
+ Log.e(TAG, "Shizuku wipe commands failed: ${e.message}")
+ }
+ }
+ // Tier 3: delete user files if MANAGE_EXTERNAL_STORAGE granted
+ deepManualWipe()
+ return
+ }
+
+ // Android <14: wipeData() = full factory reset — do NOT replace with file deletion
+ Log.i(TAG, "wipeData: Android <14 — calling dpm.wipeData()")
+ dpm?.wipeData(buildLegacyWipeFlags())
+ }
+
+ private fun hardReset() {
+ // Source: https://stackoverflow.com/a/78489105 (CC BY-SA 4.0)
+ // Use appropriate wipeDevice/wipeData API based on Android version
+ try {
+ when {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> {
+ dpm?.wipeDevice(buildWipeDeviceFlags())
+ }
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> {
+ dpm?.wipeData(buildLegacyWipeFlags().or(DevicePolicyManager.WIPE_RESET_PROTECTION_DATA))
+ }
+ else -> {
+ dpm?.wipeData(0)
+ }
+ }
+ } catch (e: SecurityException) {
+ throw IllegalStateException("Device Owner wipe failed: ${e.message}", e)
+ }
+ }
+
+ private fun deepManualWipe() {
+ // For device admin on Android 14+: best-effort cleanup.
+ // Requires MANAGE_EXTERNAL_STORAGE to be granted by user (one-time in Settings).
+ // Cannot silently uninstall apps without Device Owner — skipped.
+ // Cannot touch system apps or /data/data — those require Device Owner.
+
+ // 1. Delete our own app data (no permissions required)
+ try {
+ ctx.dataDir.deleteRecursively()
+ } catch (_: Exception) {}
+ try {
+ ctx.cacheDir.deleteRecursively()
+ } catch (_: Exception) {}
+
+ // 2. Delete user files from external storage if MANAGE_EXTERNAL_STORAGE is granted
+ if (hasManageExternalStoragePermission()) {
+ deleteUserFiles()
+ }
+ }
+
+ fun hasManageExternalStoragePermission(): Boolean {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ Environment.isExternalStorageManager()
+ } else {
+ ctx.checkSelfPermission(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) ==
+ android.content.pm.PackageManager.PERMISSION_GRANTED
+ }
+ }
+
+ private fun deleteUserFiles() {
+ val dirs = listOf(
+ Environment.DIRECTORY_DOWNLOADS,
+ Environment.DIRECTORY_DCIM,
+ Environment.DIRECTORY_DOCUMENTS,
+ Environment.DIRECTORY_PICTURES,
+ Environment.DIRECTORY_MOVIES,
+ Environment.DIRECTORY_MUSIC,
+ Environment.DIRECTORY_ALARMS,
+ Environment.DIRECTORY_NOTIFICATIONS,
+ Environment.DIRECTORY_PODCASTS,
+ Environment.DIRECTORY_RINGTONES,
+ Environment.DIRECTORY_SCREENSHOTS,
+ )
+ for (dirName in dirs) {
+ try {
+ Environment.getExternalStoragePublicDirectory(dirName)
+ ?.takeIf { it.exists() }
+ ?.deleteRecursively()
+ } catch (_: Exception) {}
+ }
+ // Also wipe the root of external storage (catches app-created directories)
+ try {
+ Environment.getExternalStorageDirectory()?.listFiles()?.forEach { child ->
+ if (child.isDirectory && dirs.none { child.name.equals(it, ignoreCase = true) }) {
+ child.deleteRecursively()
+ }
+ }
+ } catch (_: Exception) {}
}
fun makeRequestIntent() =
Intent(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN)
.putExtra(DevicePolicyManager.EXTRA_DEVICE_ADMIN, deviceAdmin)
+
+ fun canUseFullDeviceWipeApi(): Boolean {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ return false
+ }
+
+ val isDeviceOwner = isDeviceOwner()
+ val isOrgOwnedProfileOwner = isOrgOwnedProfileOwner()
+
+ return isDeviceOwner || isOrgOwnedProfileOwner
+ }
+
+ fun isOrgOwnedProfileOwner(): Boolean {
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
+ isProfileOwner() &&
+ dpm?.isOrganizationOwnedDeviceWithManagedProfile == true
+ }
+
+ private fun buildLegacyWipeFlags(): Int {
+ var flags = 0
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ flags = flags.or(DevicePolicyManager.WIPE_SILENTLY)
+ }
+ return flags
+ }
+
+ private fun buildWipeDeviceFlags(): Int {
+ var flags = buildLegacyWipeFlags()
+ if (prefs.isWipeEmbeddedSim && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ flags = flags.or(DevicePolicyManager.WIPE_EUICC)
+ }
+ return flags
+ }
+
+ companion object {
+ private const val TAG = "DeviceAdminManager"
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/me/lucky/wasted/admin/DeviceAdminReceiver.kt b/app/src/main/java/me/lucky/wasted/admin/DeviceAdminReceiver.kt
index 2e11a9a..7c812a3 100644
--- a/app/src/main/java/me/lucky/wasted/admin/DeviceAdminReceiver.kt
+++ b/app/src/main/java/me/lucky/wasted/admin/DeviceAdminReceiver.kt
@@ -1,5 +1,35 @@
package me.lucky.wasted.admin
import android.app.admin.DeviceAdminReceiver
+import android.content.Context
+import android.content.Intent
+import android.os.PersistableBundle
+import android.util.Log
-class DeviceAdminReceiver : DeviceAdminReceiver()
\ No newline at end of file
+class DeviceAdminReceiver : DeviceAdminReceiver() {
+ companion object {
+ private const val TAG = "DeviceAdminReceiver"
+ }
+
+ override fun onEnabled(context: Context, intent: Intent) {
+ super.onEnabled(context, intent)
+ Log.i(TAG, "Device admin enabled")
+ DevicePolicyBootstrap.configureActiveAdmin(context, "device-admin-enabled")
+ }
+
+ override fun onProfileProvisioningComplete(context: Context, intent: Intent) {
+ super.onProfileProvisioningComplete(context, intent)
+ Log.i(TAG, "Provisioning complete broadcast received")
+ DevicePolicyBootstrap.configureActiveAdmin(context, "profile-provisioning-complete")
+ }
+
+ override fun onTransferOwnershipComplete(context: Context, bundle: PersistableBundle?) {
+ super.onTransferOwnershipComplete(context, bundle)
+ Log.i(TAG, "Ownership transfer complete")
+ DevicePolicyBootstrap.configureActiveAdmin(context, "ownership-transfer-complete")
+ }
+
+ override fun onDisableRequested(context: Context, intent: Intent): CharSequence {
+ return "Disabling Wasted removes the lock and reset privileges that protect this phone."
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/me/lucky/wasted/admin/DevicePolicyBootstrap.kt b/app/src/main/java/me/lucky/wasted/admin/DevicePolicyBootstrap.kt
new file mode 100644
index 0000000..7107710
--- /dev/null
+++ b/app/src/main/java/me/lucky/wasted/admin/DevicePolicyBootstrap.kt
@@ -0,0 +1,74 @@
+package me.lucky.wasted.admin
+
+import android.app.admin.DevicePolicyManager
+import android.content.ComponentName
+import android.content.Context
+import android.os.Build
+import android.util.Log
+
+object DevicePolicyBootstrap {
+ private const val TAG = "DeviceAdminBootstrap"
+
+ fun configureActiveAdmin(context: Context, source: String) {
+ val manager = context.getSystemService(DevicePolicyManager::class.java)
+ if (manager == null) {
+ Log.e(TAG, "DevicePolicyManager unavailable while configuring admin from $source")
+ return
+ }
+
+ val admin = ComponentName(context, DeviceAdminReceiver::class.java)
+ if (!manager.isAdminActive(admin)) {
+ Log.w(TAG, "Admin not active while configuring from $source")
+ return
+ }
+
+ val isDeviceOwner = manager.isDeviceOwnerApp(context.packageName)
+ val isProfileOwner = manager.isProfileOwnerApp(context.packageName)
+ Log.i(
+ TAG,
+ "configureActiveAdmin source=$source deviceOwner=$isDeviceOwner profileOwner=$isProfileOwner",
+ )
+
+ runCatching {
+ manager.setShortSupportMessage(
+ admin,
+ "Wasted controls lock and reset behavior for this managed phone.",
+ )
+ }.onFailure {
+ Log.w(TAG, "Unable to set short support message", it)
+ }
+
+ runCatching {
+ manager.setLongSupportMessage(
+ admin,
+ "Wasted is configured as the device management app for this phone. Remote lock and remote reset depend on this management role remaining active.",
+ )
+ }.onFailure {
+ Log.w(TAG, "Unable to set long support message", it)
+ }
+
+ if (isProfileOwner) {
+ runCatching {
+ manager.setProfileName(admin, "Wasted")
+ }.onFailure {
+ Log.w(TAG, "Unable to set managed profile name", it)
+ }
+
+ runCatching {
+ manager.setProfileEnabled(admin)
+ }.onFailure {
+ Log.w(TAG, "Unable to enable managed profile", it)
+ }
+ }
+
+ if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isDeviceOwner) ||
+ (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isProfileOwner)
+ ) {
+ runCatching {
+ manager.setOrganizationName(admin, "Wasted")
+ }.onFailure {
+ Log.w(TAG, "Unable to set organization name", it)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/me/lucky/wasted/fragment/MainFragment.kt b/app/src/main/java/me/lucky/wasted/fragment/MainFragment.kt
index 0847584..d0ce899 100644
--- a/app/src/main/java/me/lucky/wasted/fragment/MainFragment.kt
+++ b/app/src/main/java/me/lucky/wasted/fragment/MainFragment.kt
@@ -2,20 +2,27 @@ package me.lucky.wasted.fragment
import android.app.Activity
import android.content.Context
+import android.content.Intent
import android.content.SharedPreferences
+import android.net.Uri
+import android.os.Build
import android.os.Bundle
+import android.provider.Settings
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import java.util.*
+import me.lucky.wasted.Application as WastedApp
import me.lucky.wasted.Preferences
import me.lucky.wasted.R
import me.lucky.wasted.Utils
import me.lucky.wasted.admin.DeviceAdminManager
import me.lucky.wasted.databinding.FragmentMainBinding
+import me.lucky.wasted.shizuku.ShizukuManager
class MainFragment : Fragment() {
private lateinit var binding: FragmentMainBinding
@@ -44,6 +51,13 @@ class MainFragment : Fragment() {
prefs.registerListener(prefsListener)
}
+ override fun onResume() {
+ super.onResume()
+ // Refresh whenever we return to screen — covers returning from:
+ // Shizuku app (after starting/granting permission), Settings (after removing accounts/granting files access)
+ refreshProtectionUI()
+ }
+
override fun onStop() {
super.onStop()
prefs.unregisterListener(prefsListener)
@@ -70,6 +84,7 @@ class MainFragment : Fragment() {
wipeData.setOnCheckedChangeListener { _, isChecked ->
prefs.isWipeData = isChecked
wipeEmbeddedSim.isEnabled = isChecked
+ refreshProtectionUI()
}
wipeEmbeddedSim.setOnCheckedChangeListener { _, isChecked ->
prefs.isWipeEmbeddedSim = isChecked
@@ -83,6 +98,12 @@ class MainFragment : Fragment() {
prefs.isEnabled = true
Utils(ctx).setEnabled(true)
binding.toggle.isChecked = true
+ // Prompt for All Files Access right after enabling — needed for Tier 3 (file deletion)
+ if (!admin.hasManageExternalStoragePermission() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ requestFilesAccess()
+ } else {
+ refreshProtectionUI()
+ }
}
private fun setOff() {
@@ -90,6 +111,7 @@ class MainFragment : Fragment() {
Utils(ctx).setEnabled(false)
try { admin.remove() } catch (exc: SecurityException) {}
binding.toggle.isChecked = false
+ refreshProtectionUI()
}
private val registerForDeviceAdmin =
@@ -98,5 +120,263 @@ class MainFragment : Fragment() {
}
private fun requestAdmin() = registerForDeviceAdmin.launch(admin.makeRequestIntent())
+
+ // ─── All Files Access (MANAGE_EXTERNAL_STORAGE for Tier 3 wipe) ──────────
+
+ private val filesAccessLauncher =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ refreshProtectionUI() // re-check permission state after user returns from Settings
+ }
+
+ private fun requestFilesAccess() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ val intent = Intent(
+ Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
+ Uri.parse("package:${ctx.packageName}")
+ )
+ filesAccessLauncher.launch(intent)
+ }
+ // API < 30: WRITE_EXTERNAL_STORAGE is declared in manifest and granted at install
+ }
+
+ // ─── Protection level + Setup cards ──────────────────────────────────────
+
+ /**
+ * Refresh both cards. Cards only show when wipe is enabled and Device Admin is active.
+ */
+ private fun refreshProtectionUI() {
+ val showCards = prefs.isWipeData && admin.isActive()
+ if (!showCards) {
+ binding.protectionCard.visibility = View.GONE
+ binding.setupCard.visibility = View.GONE
+ return
+ }
+ binding.protectionCard.visibility = View.VISIBLE
+ binding.protectionStatus.text = when (admin.getProtectionTier()) {
+ 1 -> getString(R.string.protection_tier_1)
+ 2 -> getString(R.string.protection_tier_2)
+ 3 -> getString(R.string.protection_tier_3)
+ else -> getString(R.string.protection_tier_4)
+ }
+ updateSetupCard()
+ }
+
+ /**
+ * Show the correct setup step based on Shizuku state and Device Owner state.
+ *
+ * States (in order):
+ * DO active → success banner, nothing more to do
+ * Shizuku not installed → Step 1: install from Play Store
+ * Shizuku installed, off → Step 2: start via Wireless Debugging
+ * Running, no permission → Step 3: grant permission
+ * Running, shell pending → "connecting…" placeholder
+ * Shell ready → optional Device Owner button
+ */
+ private fun updateSetupCard() {
+ val shizuku = shizuku() ?: run {
+ binding.setupCard.visibility = View.GONE
+ return
+ }
+ binding.setupCard.visibility = View.VISIBLE
+
+ when {
+ admin.isDeviceOwner() || admin.isOrgOwnedProfileOwner() -> {
+ binding.setupTitle.text = "✓ Device Owner Active"
+ binding.setupBody.text =
+ "Wasted is enrolled as Device Owner.\n" +
+ "A full factory reset will fire on trigger."
+ binding.setupAction.visibility = View.GONE
+ binding.filesAccessDivider.visibility = View.GONE
+ binding.filesAccessRow.visibility = View.GONE
+ }
+
+ !shizuku.isInstalled() -> {
+ binding.setupTitle.text = "Step 1 — Install Shizuku"
+ binding.setupBody.text =
+ "Shizuku lets Wasted clear all app data and TRIM flash storage " +
+ "before wiping — making deleted data forensically unrecoverable.\n\n" +
+ "Download Shizuku v13.6.0 from GitHub and install it.\n" +
+ "After installing, open it and follow its setup instructions."
+ binding.setupAction.apply {
+ text = "Download Shizuku"
+ visibility = View.VISIBLE
+ setOnClickListener { openShizukuGitHub() }
+ }
+ showFilesAccessRowIfNeeded()
+ }
+
+ !shizuku.isRunning() -> {
+ binding.setupTitle.text = "Step 2 — Start Shizuku"
+ binding.setupBody.text =
+ "Shizuku is installed but not running.\n\n" +
+ "1. Enable Developer Options (if not already):\n" +
+ " Settings → About Phone → tap Build Number 7 times\n\n" +
+ "2. Enable Wireless Debugging:\n" +
+ " Settings → Developer Options → Wireless Debugging → On\n\n" +
+ "3. Open Shizuku → tap \"Start via Wireless Debugging\"\n" +
+ " → follow the on-screen pairing steps\n\n" +
+ "No PC needed — a second Android phone running Termux works too."
+ binding.setupAction.apply {
+ text = "Open Shizuku"
+ visibility = View.VISIBLE
+ setOnClickListener { launchShizuku() }
+ }
+ showFilesAccessRowIfNeeded()
+ }
+
+ !shizuku.hasPermission() -> {
+ binding.setupTitle.text = "Step 3 — Grant Shizuku Permission"
+ binding.setupBody.text =
+ "Shizuku is running! Wasted needs permission to use it.\n\n" +
+ "Tap Grant Permission — a dialog from Shizuku will appear.\n" +
+ "Tap Allow."
+ binding.setupAction.apply {
+ text = "Grant Permission"
+ visibility = View.VISIBLE
+ setOnClickListener { shizuku.requestPermission() }
+ }
+ showFilesAccessRowIfNeeded()
+ }
+
+ !shizuku.isConnected() -> {
+ binding.setupTitle.text = "Shizuku — Connecting…"
+ binding.setupBody.text =
+ "Permission granted. The shell is connecting — usually takes 1–2 seconds.\n\n" +
+ "If this persists: open Shizuku, force-stop it, and restart it."
+ binding.setupAction.visibility = View.GONE
+ showFilesAccessRowIfNeeded()
+ }
+
+ else -> {
+ binding.setupTitle.text = "🔒 Upgrade to Device Owner"
+ binding.setupBody.text =
+ "Shizuku is active — TRIM + app data clear is armed.\n\n" +
+ "For FULL factory-reset capability, make Wasted the Device Owner:\n\n" +
+ "✓ Full data destruction guaranteed\n" +
+ "✓ Most reliable wipe on Android 14+\n\n" +
+ "Prerequisites — BEFORE tapping the button:\n" +
+ "1. Settings → Accounts → remove every account\n" +
+ "2. Settings → Apps → open Gmail, Samsung Account, Google → remove accounts\n" +
+ "3. Reboot the phone\n" +
+ "4. Come back and tap Set Device Owner"
+ binding.setupAction.apply {
+ text = "Set Device Owner"
+ visibility = View.VISIBLE
+ setOnClickListener { showDeviceOwnerConfirmDialog() }
+ }
+ showFilesAccessRowIfNeeded()
+ }
+ }
+ }
+
+ private fun showFilesAccessRowIfNeeded() {
+ val granted = admin.hasManageExternalStoragePermission()
+ binding.filesAccessDivider.visibility = if (!granted) View.VISIBLE else View.GONE
+ binding.filesAccessRow.visibility = if (!granted) View.VISIBLE else View.GONE
+ if (!granted) {
+ binding.filesAccessAction.setOnClickListener { requestFilesAccess() }
+ }
+ }
+
+ // ─── Device Owner command flow ────────────────────────────────────────────
+
+ private fun showDeviceOwnerConfirmDialog() {
+ AlertDialog.Builder(ctx)
+ .setTitle("Set Device Owner — Full Factory Reset")
+ .setMessage(
+ "✓ This enables FULL data destruction capability.\n" +
+ "✓ Wasted will format the device on trigger.\n\n" +
+ "Prerequisites (MUST be done first):\n\n" +
+ "• Settings → Accounts → remove every account\n" +
+ "• Settings → Apps → open Gmail, Samsung Account, Google\n" +
+ " → Account & sync → remove all accounts\n" +
+ "• Reboot the phone\n\n" +
+ "Not sure if accounts are hidden? Use 'Check Accounts' to verify.\n\n" +
+ "Then Wasted will execute:\n" +
+ " dpm set-device-owner me.lucky.wasted/.admin.DeviceAdminReceiver\n\n" +
+ "To undo later: disable Device Admin in Wasted settings."
+ )
+ .setNeutralButton("Check Accounts") { _, _ -> showCheckAccountsDialog() }
+ .setPositiveButton("Set Device Owner") { _, _ -> doBecomeDeviceOwner() }
+ .setNegativeButton(R.string.cancel, null)
+ .show()
+ }
+
+ private fun showCheckAccountsDialog() {
+ val s = shizuku() ?: run {
+ AlertDialog.Builder(ctx)
+ .setTitle("Shizuku Not Ready")
+ .setMessage("Shizuku shell is not connected yet. Wait a moment and try again.")
+ .setPositiveButton("OK", null)
+ .show()
+ return
+ }
+
+ binding.setupBody.text = "Checking for hidden accounts…"
+ Thread {
+ val result = s.checkHiddenAccounts()
+ val act = activity ?: return@Thread
+ act.runOnUiThread {
+ if (!isAdded) return@runOnUiThread
+ AlertDialog.Builder(ctx)
+ .setTitle("Account Check Result")
+ .setMessage(result)
+ .setPositiveButton("OK") { _, _ ->
+ if (result.contains("✓ No accounts")) {
+ // If no accounts, show Device Owner dialog again
+ showDeviceOwnerConfirmDialog()
+ }
+ }
+ .show()
+ refreshProtectionUI()
+ }
+ }.start()
+ }
+
+ private fun doBecomeDeviceOwner() {
+ binding.setupAction.isEnabled = false
+ binding.setupBody.text = "Running command via Shizuku shell…"
+
+ Thread {
+ val (success, message) = try {
+ val out = shizuku()!!.setDeviceOwner()
+ Pair(true, out.ifBlank { "Wasted is now Device Owner.\nFull factory reset is armed." })
+ } catch (e: Exception) {
+ Pair(false, e.message ?: "Unknown error.")
+ }
+
+ val act = activity ?: return@Thread
+ act.runOnUiThread {
+ if (!isAdded) return@runOnUiThread
+ binding.setupAction.isEnabled = true
+ AlertDialog.Builder(ctx)
+ .setTitle(if (success) "✓ Device Owner Set" else "Failed")
+ .setMessage(message)
+ .setPositiveButton("OK") { _, _ -> if (success) refreshProtectionUI() }
+ .show()
+ if (!success) updateSetupCard() // restore card text on failure
+ }
+ }.start()
+ }
+
+ // ─── Shizuku launch helpers ───────────────────────────────────────────────
+
+ private fun openShizukuGitHub() {
+ val url = "https://github.com/RikkaApps/Shizuku/releases/tag/v13.6.0"
+ startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
+ }
+
+ private fun launchShizuku() {
+ val intent = ctx.packageManager.getLaunchIntentForPackage(ShizukuManager.SHIZUKU_PACKAGE)
+ if (intent != null) startActivity(intent) else openShizukuGitHub()
+ }
+
+ // ─── Helpers ─────────────────────────────────────────────────────────────
+
+ /** Safely retrieve the app-wide ShizukuManager. Null if Application not initialized. */
+ private fun shizuku(): ShizukuManager? = try {
+ WastedApp.shizuku
+ } catch (_: UninitializedPropertyAccessException) { null }
+
private fun makeSecret() = UUID.randomUUID().toString()
}
\ No newline at end of file
diff --git a/app/src/main/java/me/lucky/wasted/p2p/P2PController.kt b/app/src/main/java/me/lucky/wasted/p2p/P2PController.kt
new file mode 100644
index 0000000..c92b19a
--- /dev/null
+++ b/app/src/main/java/me/lucky/wasted/p2p/P2PController.kt
@@ -0,0 +1,379 @@
+package me.lucky.wasted.p2p
+
+import android.content.Context
+import android.provider.Settings
+import android.util.Log
+import com.google.gson.Gson
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import me.lucky.wasted.p2p.database.WastedP2PDatabase
+import me.lucky.wasted.p2p.models.DeviceSettingsSnapshot
+import me.lucky.wasted.p2p.models.Message
+import me.lucky.wasted.p2p.models.MessageType
+import me.lucky.wasted.p2p.models.PairingState
+import me.lucky.wasted.p2p.models.Peer
+import me.lucky.wasted.p2p.network.P2PNetwork
+import me.lucky.wasted.p2p.pairing.PairingManager
+import me.lucky.wasted.p2p.protocol.RemoteControlManager
+import me.lucky.wasted.p2p.protocol.SettingsSyncManager
+
+class P2PController private constructor(context: Context) {
+
+ data class ActionResult(
+ val ok: Boolean,
+ val message: String,
+ )
+
+ data class PairingQrPayload(
+ val deviceId: String,
+ val deviceName: String,
+ val pin: String,
+ )
+
+ companion object {
+ private const val TAG = "P2PController"
+
+ @Volatile
+ private var instance: P2PController? = null
+
+ fun getInstance(context: Context): P2PController {
+ return instance ?: synchronized(this) {
+ instance ?: P2PController(context.applicationContext).also { instance = it }
+ }
+ }
+
+ /** Returns existing singleton without creating a new one. */
+ fun instanceOrNull(): P2PController? = instance
+ }
+
+ private val appContext = context.applicationContext
+ // Default dispatcher — network/DB ops inside launched coroutines run off the main thread
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ private val gson = Gson()
+ private val peerDao = WastedP2PDatabase.getInstance(appContext).peerDao()
+
+ val network = P2PNetwork(appContext, peerDao)
+ val pairingManager = PairingManager(appContext, peerDao)
+ val settingsSyncManager = SettingsSyncManager(appContext, network, scope)
+ val remoteControlManager = RemoteControlManager(appContext, network, scope)
+
+ val connectedPeers: StateFlow> = network.connectedPeers
+ val allPeers: Flow> = peerDao.getAllPeersFlow()
+ val pairingState: StateFlow = pairingManager.pairingState
+ val currentPin: StateFlow = pairingManager.currentPin
+ val pairingError: StateFlow = pairingManager.pairingError
+ val localSettings: StateFlow = settingsSyncManager.localSettings
+ val peerSettings: StateFlow