diff --git a/README.md b/README.md index c831d72..5b4a9b0 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,9 @@ 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. +Also you can: +- limit the maximum number of failed password attempts +- wipe device when it was not unlocked for N days The app works in `Work Profile` too. You can use [Shelter](https://github.com/PeterCxy/Shelter) to install risky apps and `Wasted` in it. Then you can wipe this profile data with one click without @@ -26,6 +27,16 @@ wiping the whole device. Only encrypted device may guarantee that the data will not be recoverable. +## Permissions + +`FOREGROUND_SERVICE` + +[Wipe on inactive] receive unlock events + +`RECEIVE_BOOT_COMPLETED` + +[Wipe on inactive] persist wipe job across reboots + ## Example Broadcast message: diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 352730d..063e0d8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,9 @@ xmlns:tools="http://schemas.android.com/tools" package="me.lucky.wasted"> + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/me/lucky/wasted/AppNotificationManager.kt b/app/src/main/java/me/lucky/wasted/AppNotificationManager.kt new file mode 100644 index 0000000..f14c15c --- /dev/null +++ b/app/src/main/java/me/lucky/wasted/AppNotificationManager.kt @@ -0,0 +1,25 @@ +package me.lucky.wasted + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build + +class AppNotificationManager(private val ctx: Context) { + companion object { + const val CHANNEL_DEFAULT_ID = "default" + } + + fun createNotificationChannels() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val notificationManager = + ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel( + NotificationChannel( + CHANNEL_DEFAULT_ID, + ctx.getString(R.string.notification_channel_default_name), + NotificationManager.IMPORTANCE_LOW, + ) + ) + } +} \ 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 0ca79b5..e990a06 100644 --- a/app/src/main/java/me/lucky/wasted/MainActivity.kt +++ b/app/src/main/java/me/lucky/wasted/MainActivity.kt @@ -1,13 +1,15 @@ package me.lucky.wasted 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 androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import java.util.* @@ -19,6 +21,7 @@ open class MainActivity : AppCompatActivity() { private val prefs by lazy { Preferences(this) } private val admin by lazy { DeviceAdminManager(this) } private val shortcut by lazy { ShortcutManager(this) } + private val job by lazy { WipeJobManager(this) } private val requestAdminPolicy = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> @@ -51,6 +54,7 @@ open class MainActivity : AppCompatActivity() { } private fun init() { + AppNotificationManager(this).createNotificationChannels() if (prefs.code == "") prefs.code = makeCode() updateCodeColorState() binding.apply { @@ -60,6 +64,7 @@ open class MainActivity : AppCompatActivity() { wipeESIM.isChecked = prefs.isWipeESIM wipeESIM.isEnabled = wipeData.isChecked maxFailedPasswordAttempts.value = prefs.maxFailedPasswordAttempts.toFloat() + wipeOnInactiveSwitch.isChecked = prefs.isWipeOnInactive toggle.isChecked = prefs.isServiceEnabled } } @@ -86,6 +91,19 @@ open class MainActivity : AppCompatActivity() { maxFailedPasswordAttempts.addOnChangeListener { _, value, _ -> prefs.maxFailedPasswordAttempts = value.toInt() } + wipeOnInactiveSwitch.setOnCheckedChangeListener { _, isChecked -> + if (!setWipeOnInactiveComponentsState(prefs.isServiceEnabled && isChecked)) { + wipeOnInactiveSwitch.isChecked = false + showWipeJobServiceStartFailedPopup() + return@setOnCheckedChangeListener + } + prefs.isWipeOnInactive = isChecked + + } + wipeOnInactiveSwitch.setOnLongClickListener { + showWipeOnInactiveSettings() + true + } toggle.setOnCheckedChangeListener { _, isChecked -> when (isChecked) { true -> if (!admin.isActive()) requestAdmin() else setOn() @@ -95,6 +113,23 @@ open class MainActivity : AppCompatActivity() { } } + private fun showWipeOnInactiveSettings() { + val items = arrayOf("1", "2", "3", "5", "7", "10", "15", "30") + var days = prefs.wipeOnInactiveDays + var checked = items.indexOf(days.toString()) + if (checked == -1) checked = items + .indexOf(Preferences.DEFAULT_WIPE_ON_INACTIVE_DAYS.toString()) + MaterialAlertDialogBuilder(this) + .setTitle(R.string.wipe_on_inactive_days) + .setSingleChoiceItems(items, checked) { _, which -> + days = items[which].toInt() + } + .setPositiveButton(R.string.ok) { _, _ -> + prefs.wipeOnInactiveDays = days + } + .show() + } + private fun updateCodeColorState() { binding.code.setBackgroundColor(getColor( if (prefs.isCodeEnabled) R.color.code_receiver_on else R.color.code_receiver_off @@ -102,27 +137,60 @@ open class MainActivity : AppCompatActivity() { } private fun setOn() { + if (!setWipeOnInactiveComponentsState(prefs.isWipeOnInactive)) { + binding.toggle.isChecked = false + showWipeJobServiceStartFailedPopup() + return + } prefs.isServiceEnabled = true setCodeReceiverState(prefs.isCodeEnabled) shortcut.push() } + private fun showWipeJobServiceStartFailedPopup() { + Snackbar.make( + findViewById(R.id.toggle), + R.string.wipe_job_service_start_failed_popup, + Snackbar.LENGTH_LONG, + ).show() + } + private fun setOff() { prefs.isServiceEnabled = false setCodeReceiverState(false) + setWipeOnInactiveComponentsState(false) shortcut.remove() admin.remove() } private fun requestAdmin() = requestAdminPolicy.launch(admin.makeRequestIntent()) private fun makeCode(): String = UUID.randomUUID().toString() + private fun setCodeReceiverState(value: Boolean) = + setReceiverState(CodeReceiver::class.java, value) + private fun setRestartReceiverState(value: Boolean) = + setReceiverState(RestartReceiver::class.java, value) - private fun setCodeReceiverState(value: Boolean) { + private fun setReceiverState(cls: Class<*>, value: Boolean) { packageManager.setComponentEnabledSetting( - ComponentName(this, CodeReceiver::class.java), + ComponentName(this, cls), if (value) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP, ) } + + private fun setUnlockServiceState(value: Boolean) { + Intent(this, UnlockService::class.java).also { + if (value) ContextCompat.startForegroundService(this, it) else stopService(it) + } + } + + private fun setWipeOnInactiveComponentsState(value: Boolean): Boolean { + val result = job.setState(value) + if (result) { + setUnlockServiceState(value) + setRestartReceiverState(value) + } + return result + } } diff --git a/app/src/main/java/me/lucky/wasted/Preferences.kt b/app/src/main/java/me/lucky/wasted/Preferences.kt index 9c6d66c..b766302 100644 --- a/app/src/main/java/me/lucky/wasted/Preferences.kt +++ b/app/src/main/java/me/lucky/wasted/Preferences.kt @@ -7,12 +7,16 @@ import androidx.security.crypto.MasterKeys class Preferences(ctx: Context) { companion object { + const val DEFAULT_WIPE_ON_INACTIVE_DAYS = 7 + private const val SERVICE_ENABLED = "service_enabled" private const val CODE = "code" 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 const val WIPE_ON_INACTIVE = "wipe_on_inactive" + private const val WIPE_ON_INACTIVE_DAYS = "wipe_on_inactive_days" private const val FILE_NAME = "sec_shared_prefs" // migration @@ -51,4 +55,12 @@ class Preferences(ctx: Context) { var maxFailedPasswordAttempts: Int get() = prefs.getInt(MAX_FAILED_PASSWORD_ATTEMPTS, 0) set(value) = prefs.edit { putInt(MAX_FAILED_PASSWORD_ATTEMPTS, value) } + + var isWipeOnInactive: Boolean + get() = prefs.getBoolean(WIPE_ON_INACTIVE, false) + set(value) = prefs.edit { putBoolean(WIPE_ON_INACTIVE, value) } + + var wipeOnInactiveDays: Int + get() = prefs.getInt(WIPE_ON_INACTIVE_DAYS, DEFAULT_WIPE_ON_INACTIVE_DAYS) + set(value) = prefs.edit { putInt(WIPE_ON_INACTIVE_DAYS, value) } } diff --git a/app/src/main/java/me/lucky/wasted/RestartReceiver.kt b/app/src/main/java/me/lucky/wasted/RestartReceiver.kt new file mode 100644 index 0000000..02505da --- /dev/null +++ b/app/src/main/java/me/lucky/wasted/RestartReceiver.kt @@ -0,0 +1,16 @@ +package me.lucky.wasted + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.content.ContextCompat + +class RestartReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_BOOT_COMPLETED && + intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return + val prefs = Preferences(context) + if (!prefs.isServiceEnabled || !prefs.isWipeOnInactive) return + ContextCompat.startForegroundService(context, Intent(context, UnlockService::class.java)) + } +} diff --git a/app/src/main/java/me/lucky/wasted/UnlockService.kt b/app/src/main/java/me/lucky/wasted/UnlockService.kt new file mode 100644 index 0000000..a8c191d --- /dev/null +++ b/app/src/main/java/me/lucky/wasted/UnlockService.kt @@ -0,0 +1,58 @@ +package me.lucky.wasted + +import android.app.KeyguardManager +import android.app.Service +import android.app.job.JobScheduler +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.IBinder +import android.os.SystemClock +import androidx.core.app.NotificationCompat + +class UnlockService : Service() { + companion object { + private const val NOTIFICATION_ID = 1000 + } + + private lateinit var receiver: BroadcastReceiver + + private class UnlockReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val keyguardManager = context + .getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + if (!keyguardManager.isDeviceSecure) return + while (WipeJobManager(context).schedule() == JobScheduler.RESULT_FAILURE) + SystemClock.sleep(1000) + } + } + + override fun onCreate() { + super.onCreate() + receiver = UnlockReceiver() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + startForeground( + NOTIFICATION_ID, + NotificationCompat.Builder(this, AppNotificationManager.CHANNEL_DEFAULT_ID) + .setContentTitle(getString(R.string.unlock_service_notification_title)) + .setSmallIcon(android.R.drawable.ic_delete) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + ) + registerReceiver(receiver, IntentFilter(Intent.ACTION_USER_PRESENT)) + return START_STICKY + } + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + override fun onDestroy() { + super.onDestroy() + unregisterReceiver(receiver) + } +} diff --git a/app/src/main/java/me/lucky/wasted/WipeJobManager.kt b/app/src/main/java/me/lucky/wasted/WipeJobManager.kt new file mode 100644 index 0000000..04431db --- /dev/null +++ b/app/src/main/java/me/lucky/wasted/WipeJobManager.kt @@ -0,0 +1,34 @@ +package me.lucky.wasted + +import android.app.job.JobInfo +import android.app.job.JobScheduler +import android.content.ComponentName +import android.content.Context +import java.util.concurrent.TimeUnit + +class WipeJobManager(private val ctx: Context) { + companion object { + private const val JOB_ID = 1000 + } + private val prefs by lazy { Preferences(ctx) } + private val jobScheduler by lazy { + ctx.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler + } + + fun schedule(): Int { + return jobScheduler.schedule( + JobInfo.Builder(JOB_ID, ComponentName(ctx, WipeJobService::class.java)) + .setMinimumLatency(TimeUnit.DAYS.toMillis(prefs.wipeOnInactiveDays.toLong())) + .setBackoffCriteria(0, JobInfo.BACKOFF_POLICY_LINEAR) + .setPersisted(true) + .build() + ) + } + + fun setState(value: Boolean): Boolean { + if (value) { + if (schedule() == JobScheduler.RESULT_FAILURE) return false + } else { jobScheduler.cancel(JOB_ID) } + return true + } +} diff --git a/app/src/main/java/me/lucky/wasted/WipeJobService.kt b/app/src/main/java/me/lucky/wasted/WipeJobService.kt new file mode 100644 index 0000000..4908178 --- /dev/null +++ b/app/src/main/java/me/lucky/wasted/WipeJobService.kt @@ -0,0 +1,19 @@ +package me.lucky.wasted + +import android.app.job.JobParameters +import android.app.job.JobService + +class WipeJobService : JobService() { + override fun onStartJob(params: JobParameters?): Boolean { + val prefs = Preferences(this) + if (!prefs.isServiceEnabled || !prefs.isWipeOnInactive) return false + try { + DeviceAdminManager(this).wipeData() + } catch (exc: SecurityException) {} + return false + } + + override fun onStopJob(params: JobParameters?): Boolean { + return true + } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index c9558ec..c147ff0 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -86,18 +86,30 @@ android:valueTo="10" android:stepSize="1.0" /> - - + + + + + + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 2c19df7..c311e1c 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -15,4 +15,13 @@ Паниковать Максимальное количество неудачных попыток ввода пароля. Количество + Стереть при неактивности + Стереть данные когда устройство не разблокируется N дней. + Дней + Дефолт + OK + Стереть данные при условии + Не удалось запустить службу стирания данных + Получать события разблокировки + Служба Разблокировки diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0618674..243f0ce 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -15,4 +15,13 @@ Panic Maximum number of failed password attempts. Count + Wipe on inactive + Wipe device when it was not unlocked for N days. + Days + Default + OK + Wipe device on condition + Failed to start wipe service + Receive unlock events + Unlock Service diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 1454dd6..16ca7ab 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -3,8 +3,9 @@ Lock device and wipe data on panic trigger. 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. +Also you can: +* limit the maximum number of failed password attempts +* wipe device when it was not unlocked for N days The app works in Work Profile too. You can use Shelter to install risky apps and Wasted in it. Then you can wipe this profile data with one click without wiping the whole device. diff --git a/fastlane/metadata/android/ru-RU/full_description.txt b/fastlane/metadata/android/ru-RU/full_description.txt index 589290c..ff12bf5 100644 --- a/fastlane/metadata/android/ru-RU/full_description.txt +++ b/fastlane/metadata/android/ru-RU/full_description.txt @@ -4,8 +4,9 @@ сообщение с кодом аутентификации. При получении тревожного сигнала, используя API Администратора Устройства, программа заблокирует устройство и опционально запустит стирание данных. -Также Вы можете установить лимит на максимальное количество неудачных попыток ввода пароля. После -исчерпания лимита данные на устройстве будут стёрты. +Также Вы можете: +* установить лимит на максимальное количество неудачных попыток ввода пароля +* стереть данные когда устройство не разблокировалось N дней Приложение также работает с Рабочим Профилем. Вы можете использовать Shelter для установки рискованных приложений и Потрачено в него. Затем Вы можете стереть данные этого профиля одним