diff --git a/README.md b/README.md index 62f03d0..5d68d1e 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,14 @@ Lock device and wipe data on panic trigger. -You can use [PanicKit](https://guardianproject.info/code/panickit/), Tile or send broadcast message -with authentication code. On trigger, using +You can use [PanicKit](https://guardianproject.info/code/panickit/), tile, shortcut or send +broadcast message with authentication code. On trigger, using [Device Administration API](https://developer.android.com/guide/topics/admin/device-admin), it locks device and optionally runs wipe. +Also you can limit the maximum number of failed password attempts. After the limit exceeded +device will be wiped. + ## Example Broadcast message: diff --git a/SECURITY.md b/SECURITY.md index 1dc2545..d8e03f2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,8 @@ | Version | Supported | | ------- | ------------------ | -| 1.1.x | :white_check_mark: | -| < 1.1 | :x: | +| 1.2.x | :white_check_mark: | +| < 1.2 | :x: | ## Reporting a Vulnerability diff --git a/app/build.gradle b/app/build.gradle index 78f20c4..697a948 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,8 +10,8 @@ android { applicationId "me.lucky.wasted" minSdk 23 targetSdk 31 - versionCode 10 - versionName "1.1.3" + versionCode 12 + versionName "1.2.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e745f7d..352730d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -65,6 +65,17 @@ + + + + + + + = Build.VERSION_CODES.Q) - DevicePolicyManager.WIPE_SILENTLY else 0) - } -} diff --git a/app/src/main/java/me/lucky/wasted/DeviceAdminManager.kt b/app/src/main/java/me/lucky/wasted/DeviceAdminManager.kt new file mode 100644 index 0000000..fa1385f --- /dev/null +++ b/app/src/main/java/me/lucky/wasted/DeviceAdminManager.kt @@ -0,0 +1,40 @@ +package me.lucky.wasted + +import android.app.admin.DevicePolicyManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.os.Build + +class DeviceAdminManager(private val ctx: Context) { + private val dpm by lazy { + ctx.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + } + private val deviceAdmin by lazy { ComponentName(ctx, DeviceAdminReceiver::class.java) } + private val prefs by lazy { Preferences(ctx) } + + fun remove() = dpm.removeActiveAdmin(deviceAdmin) + fun isActive(): Boolean = dpm.isAdminActive(deviceAdmin) + fun getCurrentFailedPasswordAttempts(): Int = dpm.currentFailedPasswordAttempts + fun lockNow() = dpm.lockNow() + + fun wipeData() { + var flags = 0 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + flags = flags.or(DevicePolicyManager.WIPE_SILENTLY) + } + if (prefs.isWipeESIM && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + flags = flags.or(DevicePolicyManager.WIPE_EUICC) + } + dpm.wipeData(flags) + } + + fun makeRequestIntent(): Intent { + return Intent(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN) + .putExtra(DevicePolicyManager.EXTRA_DEVICE_ADMIN, deviceAdmin) + .putExtra( + DevicePolicyManager.EXTRA_ADD_EXPLANATION, + ctx.getString(R.string.device_admin_description), + ) + } +} diff --git a/app/src/main/java/me/lucky/wasted/DeviceAdminReceiver.kt b/app/src/main/java/me/lucky/wasted/DeviceAdminReceiver.kt index b8068c4..993c10f 100644 --- a/app/src/main/java/me/lucky/wasted/DeviceAdminReceiver.kt +++ b/app/src/main/java/me/lucky/wasted/DeviceAdminReceiver.kt @@ -1,5 +1,30 @@ package me.lucky.wasted import android.app.admin.DeviceAdminReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.UserHandle +import androidx.annotation.RequiresApi -class DeviceAdminReceiver : DeviceAdminReceiver() +class DeviceAdminReceiver : DeviceAdminReceiver() { + + @RequiresApi(Build.VERSION_CODES.O) + override fun onPasswordFailed(context: Context, intent: Intent, user: UserHandle) { + super.onPasswordFailed(context, intent, user) + onPasswordFailedInternal(context) + } + + override fun onPasswordFailed(context: Context, intent: Intent) { + super.onPasswordFailed(context, intent) + onPasswordFailedInternal(context) + } + + private fun onPasswordFailedInternal(ctx: Context) { + val prefs = Preferences(ctx) + if (!prefs.isServiceEnabled || prefs.maxFailedPasswordAttempts == 0) return + val admin = DeviceAdminManager(ctx) + if (admin.getCurrentFailedPasswordAttempts() >= prefs.maxFailedPasswordAttempts) + admin.wipeData() + } +} diff --git a/app/src/main/java/me/lucky/wasted/MainActivity.kt b/app/src/main/java/me/lucky/wasted/MainActivity.kt index 23b621e..29a6c07 100644 --- a/app/src/main/java/me/lucky/wasted/MainActivity.kt +++ b/app/src/main/java/me/lucky/wasted/MainActivity.kt @@ -1,11 +1,12 @@ package me.lucky.wasted -import android.app.admin.DevicePolicyManager import android.content.ComponentName import android.content.Context -import android.content.Intent import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle +import android.view.View +import android.widget.SeekBar import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity @@ -17,7 +18,8 @@ open class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private val prefs by lazy { Preferences(this) } - private val admin by lazy { DeviceAdmin(this) } + private val admin by lazy { DeviceAdminManager(this) } + private val shortcut by lazy { ShortcutManager(this) } private val requestAdminPolicy = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> @@ -51,23 +53,54 @@ open class MainActivity : AppCompatActivity() { private fun init() { if (prefs.code == "") prefs.code = makeCode() + updateCodeColorState() binding.apply { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) wipeESIM.visibility = View.GONE code.text = prefs.code - wipeDataCheckBox.isChecked = prefs.doWipe + wipeData.isChecked = prefs.isWipeData + wipeESIM.isChecked = prefs.isWipeESIM + wipeESIM.isEnabled = wipeData.isChecked + maxFailedPasswordAttempts.progress = prefs.maxFailedPasswordAttempts toggle.isChecked = prefs.isServiceEnabled } } private fun setup() { binding.apply { + code.setOnClickListener { + prefs.isCodeEnabled = !prefs.isCodeEnabled + updateCodeColorState() + setCodeReceiverState( + this@MainActivity, + prefs.isServiceEnabled && prefs.isCodeEnabled, + ) + } code.setOnLongClickListener { prefs.code = makeCode() code.text = prefs.code true } - wipeDataCheckBox.setOnCheckedChangeListener { _, isChecked -> - prefs.doWipe = isChecked + wipeData.setOnCheckedChangeListener { _, isChecked -> + prefs.isWipeData = isChecked + wipeESIM.isEnabled = isChecked } + wipeESIM.setOnCheckedChangeListener { _, isChecked -> + prefs.isWipeESIM = isChecked + } + maxFailedPasswordAttempts.setOnSeekBarChangeListener( + object : SeekBar.OnSeekBarChangeListener { + + override fun onProgressChanged( + seekBar: SeekBar?, + progress: Int, + fromUser: Boolean, + ) { + prefs.maxFailedPasswordAttempts = progress + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) {} + override fun onStopTrackingTouch(seekBar: SeekBar?) {} + }) toggle.setOnCheckedChangeListener { _, isChecked -> when (isChecked) { true -> if (!admin.isActive()) requestAdmin() else setOn() @@ -77,31 +110,29 @@ open class MainActivity : AppCompatActivity() { } } + private fun updateCodeColorState() { + binding.code.setBackgroundColor(getColor( + if (prefs.isCodeEnabled) R.color.code_receiver_on else R.color.code_receiver_off + )) + } + private fun setOn() { prefs.isServiceEnabled = true - setControlReceiverState(this, true) + setCodeReceiverState(this, prefs.isCodeEnabled) + shortcut.push() } private fun setOff() { admin.remove() - setControlReceiverState(this, false) + setCodeReceiverState(this, false) + shortcut.remove() prefs.isServiceEnabled = false } - private fun requestAdmin() { - val intent = Intent(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN).apply { - putExtra(DevicePolicyManager.EXTRA_DEVICE_ADMIN, admin.deviceAdmin) - putExtra( - DevicePolicyManager.EXTRA_ADD_EXPLANATION, - getString(R.string.device_admin_description), - ) - } - requestAdminPolicy.launch(intent) - } - + private fun requestAdmin() = requestAdminPolicy.launch(admin.makeRequestIntent()) private fun makeCode(): String = UUID.randomUUID().toString() - private fun setControlReceiverState(ctx: Context, value: Boolean) { + private fun setCodeReceiverState(ctx: Context, value: Boolean) { ctx.packageManager.setComponentEnabledSetting( ComponentName(ctx, CodeReceiver::class.java), if (value) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else diff --git a/app/src/main/java/me/lucky/wasted/PanicConnectionActivity.kt b/app/src/main/java/me/lucky/wasted/PanicConnectionActivity.kt index 045c73e..eaabf83 100644 --- a/app/src/main/java/me/lucky/wasted/PanicConnectionActivity.kt +++ b/app/src/main/java/me/lucky/wasted/PanicConnectionActivity.kt @@ -31,18 +31,17 @@ class PanicConnectionActivity : MainActivity() { } catch (exc: PackageManager.NameNotFoundException) {} } - AlertDialog.Builder(this).apply { - setTitle(getString(R.string.panic_app_dialog_title)) - setMessage(String.format(getString(R.string.panic_app_dialog_message), app)) - setNegativeButton(R.string.allow) { _, _ -> + AlertDialog.Builder(this) + .setTitle(getString(R.string.panic_app_dialog_title)) + .setMessage(String.format(getString(R.string.panic_app_dialog_message), app)) + .setNegativeButton(R.string.allow) { _, _ -> PanicResponder.setTriggerPackageName(this@PanicConnectionActivity) setResult(RESULT_OK) } - setPositiveButton(R.string.cancel) { _, _ -> + .setPositiveButton(R.string.cancel) { _, _ -> setResult(RESULT_CANCELED) finish() } - show() - } + .show() } } diff --git a/app/src/main/java/me/lucky/wasted/PanicResponderActivity.kt b/app/src/main/java/me/lucky/wasted/PanicResponderActivity.kt index 7950436..830ee62 100644 --- a/app/src/main/java/me/lucky/wasted/PanicResponderActivity.kt +++ b/app/src/main/java/me/lucky/wasted/PanicResponderActivity.kt @@ -8,19 +8,19 @@ import info.guardianproject.panic.PanicResponder class PanicResponderActivity : AppCompatActivity() { private val prefs by lazy { Preferences(this) } - private val admin by lazy { DeviceAdmin(this) } + private val admin by lazy { DeviceAdminManager(this) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (!Panic.isTriggerIntent(intent) || !prefs.isServiceEnabled) { - finish() + finishAndRemoveTask() return } try { - admin.dpm.lockNow() + admin.lockNow() if (PanicResponder.receivedTriggerFromConnectedApp(this) && - prefs.doWipe) admin.wipeData() + prefs.isWipeData) admin.wipeData() } catch (exc: SecurityException) {} - finish() + finishAndRemoveTask() } } diff --git a/app/src/main/java/me/lucky/wasted/Preferences.kt b/app/src/main/java/me/lucky/wasted/Preferences.kt index 7749f70..a7d878a 100644 --- a/app/src/main/java/me/lucky/wasted/Preferences.kt +++ b/app/src/main/java/me/lucky/wasted/Preferences.kt @@ -9,7 +9,10 @@ class Preferences(ctx: Context) { companion object { private const val SERVICE_ENABLED = "service_enabled" private const val CODE = "code" - private const val DO_WIPE = "do_wipe" + private const val CODE_ENABLED = "code_enabled" + private const val WIPE_DATA = "wipe_data" + private const val WIPE_ESIM = "wipe_esim" + private const val MAX_FAILED_PASSWORD_ATTEMPTS = "max_failed_password_attempts" } private val mk = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) @@ -29,7 +32,19 @@ class Preferences(ctx: Context) { get() = prefs.getString(CODE, "") set(value) = prefs.edit { putString(CODE, value) } - var doWipe: Boolean - get() = prefs.getBoolean(DO_WIPE, false) - set(value) = prefs.edit { putBoolean(DO_WIPE, value) } + var isCodeEnabled: Boolean + get() = prefs.getBoolean(CODE_ENABLED, false) + set(value) = prefs.edit { putBoolean(CODE_ENABLED, value) } + + var isWipeData: Boolean + get() = prefs.getBoolean(WIPE_DATA, false) + set(value) = prefs.edit { putBoolean(WIPE_DATA, value) } + + var isWipeESIM: Boolean + get() = prefs.getBoolean(WIPE_ESIM, false) + set(value) = prefs.edit { putBoolean(WIPE_ESIM, value) } + + var maxFailedPasswordAttempts: Int + get() = prefs.getInt(MAX_FAILED_PASSWORD_ATTEMPTS, 0) + set(value) = prefs.edit { putInt(MAX_FAILED_PASSWORD_ATTEMPTS, value) } } diff --git a/app/src/main/java/me/lucky/wasted/QSTileService.kt b/app/src/main/java/me/lucky/wasted/QSTileService.kt index e792ad5..9e901fc 100644 --- a/app/src/main/java/me/lucky/wasted/QSTileService.kt +++ b/app/src/main/java/me/lucky/wasted/QSTileService.kt @@ -11,7 +11,7 @@ import kotlin.concurrent.timerTask @RequiresApi(Build.VERSION_CODES.N) class QSTileService : TileService() { private val prefs by lazy { Preferences(this) } - private val admin by lazy { DeviceAdmin(this) } + private val admin by lazy { DeviceAdminManager(this) } private val counter = AtomicInteger(0) private var timer: Timer? = null @@ -26,9 +26,9 @@ class QSTileService : TileService() { override fun onClick() { super.onClick() if (!prefs.isServiceEnabled) return - if (!prefs.doWipe) { + if (!prefs.isWipeData) { try { - admin.dpm.lockNow() + admin.lockNow() } catch (exc: SecurityException) {} return } @@ -39,7 +39,7 @@ class QSTileService : TileService() { timer = Timer() timer?.schedule(timerTask { try { - admin.dpm.lockNow() + admin.lockNow() admin.wipeData() } catch (exc: SecurityException) {} }, 2000) @@ -53,9 +53,7 @@ class QSTileService : TileService() { } private fun update(tileState: Int) { - qsTile.apply { - state = tileState - updateTile() - } + qsTile.state = tileState + qsTile.updateTile() } } diff --git a/app/src/main/java/me/lucky/wasted/ShortcutActivity.kt b/app/src/main/java/me/lucky/wasted/ShortcutActivity.kt new file mode 100644 index 0000000..1f663e8 --- /dev/null +++ b/app/src/main/java/me/lucky/wasted/ShortcutActivity.kt @@ -0,0 +1,12 @@ +package me.lucky.wasted + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity + +class ShortcutActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + CodeReceiver.panic(this, intent) + finishAndRemoveTask() + } +} diff --git a/app/src/main/java/me/lucky/wasted/ShortcutManager.kt b/app/src/main/java/me/lucky/wasted/ShortcutManager.kt new file mode 100644 index 0000000..94d1362 --- /dev/null +++ b/app/src/main/java/me/lucky/wasted/ShortcutManager.kt @@ -0,0 +1,32 @@ +package me.lucky.wasted + +import android.content.Context +import android.content.Intent +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat + +class ShortcutManager(private val ctx: Context) { + companion object { + private const val SHORTCUT_ID = "panic" + } + + private val prefs by lazy { Preferences(ctx) } + + fun push() { + ShortcutManagerCompat.pushDynamicShortcut( + ctx, + ShortcutInfoCompat.Builder(ctx, SHORTCUT_ID) + .setShortLabel(ctx.getString(R.string.shortcut_label)) + .setIcon(IconCompat.createWithResource(ctx, android.R.drawable.ic_delete)) + .setIntent( + Intent(CodeReceiver.ACTION) + .setClass(ctx, ShortcutActivity::class.java) + .putExtra(CodeReceiver.KEY, prefs.code) + ) + .build(), + ) + } + + fun remove() = ShortcutManagerCompat.removeDynamicShortcuts(ctx, arrayListOf(SHORTCUT_ID)) +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index ccafcf0..0d95510 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -16,20 +16,75 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> - + app:layout_constraintTop_toBottomOf="@+id/description"> + + + + + + + + + + + + + + + + - - \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f8c6127..ded1e85 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -7,4 +7,6 @@ #FF018786 #FF000000 #FFFFFFFF + #FFD600 + #E13741 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3b5e521..d101e22 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,14 +1,17 @@ Wasted - Turn on Wasted to lock device on panic trigger. You can use PanicKit, Tile or send broadcast message with this authentication code. + Turn on Wasted to lock device on panic trigger. You can use PanicKit, tile, shortcut or send broadcast message with this authentication code. Wasted Allow Wasted to lock device and optionally wipe data on panic trigger Admin service unavailable Wipe data - Confirm Panic App + Wipe eSIM + Confirm panic app Are you sure that you want to allow %1$s to trigger destructive panic actions\? an unknown app Allow Cancel Unlock device + Panic + Also you can limit the maximum number of failed password attempts. After the limit exceeded device will be wiped. diff --git a/app/src/main/res/xml/device_admin.xml b/app/src/main/res/xml/device_admin.xml index 37b37b2..91b3993 100644 --- a/app/src/main/res/xml/device_admin.xml +++ b/app/src/main/res/xml/device_admin.xml @@ -2,6 +2,7 @@ + diff --git a/fastlane/metadata/android/en-US/changelogs/12.txt b/fastlane/metadata/android/en-US/changelogs/12.txt new file mode 100644 index 0000000..775dd09 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/12.txt @@ -0,0 +1,4 @@ +add option to wipe eSIM +add option to limit the maximum number of failed password attempts +add option to disable code receiver +add app panic shortcut diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index b42677a..0875e7b 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -1,4 +1,7 @@ Lock device and wipe data on panic trigger. -You can use PanicKit, Tile or send broadcast message with authentication code. +You can use PanicKit, tile, shortcut or send broadcast message with authentication code. On trigger, using Device Administration API, it locks device and optionally runs wipe. + +Also you can limit the maximum number of failed password attempts. +After the limit exceeded device will be wiped. diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png index e335dea..bdcb99f 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ