add wipe on inactive

This commit is contained in:
lucky 2022-01-10 17:46:32 +03:00
parent 3d472f1550
commit 22c060e054
14 changed files with 316 additions and 15 deletions

View File

@ -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:

View File

@ -3,6 +3,9 @@
xmlns:tools="http://schemas.android.com/tools"
package="me.lucky.wasted">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
@ -90,5 +93,28 @@
android:value="true" />
</service>
<service
android:name=".WipeJobService"
android:exported="true"
android:description="@string/wipe_job_service_description"
android:permission="android.permission.BIND_JOB_SERVICE">
</service>
<service
android:name=".UnlockService"
android:exported="false"
android:description="@string/unlock_service_description">
</service>
<receiver
android:name=".RestartReceiver"
android:enabled="false"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@ -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,
)
)
}
}

View File

@ -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
}
}

View File

@ -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) }
}

View File

@ -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))
}
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -86,18 +86,30 @@
android:valueTo="10"
android:stepSize="1.0" />
<Space
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="2dp" />
<TextView
android:id="@+id/description2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="center"
android:text="@string/max_failed_password_attempts_description" />
<Space
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="8dp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/wipeOnInactiveSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="16sp"
android:text="@string/wipe_on_inactive_switch" />
<TextView
android:id="@+id/description3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/wipe_on_inactive_description" />
</LinearLayout>
</ScrollView>

View File

@ -15,4 +15,13 @@
<string name="shortcut_label">Паниковать</string>
<string name="max_failed_password_attempts_description">Максимальное количество неудачных попыток ввода пароля.</string>
<string name="max_failed_password_attempts_content_description">Количество</string>
<string name="wipe_on_inactive_switch">Стереть при неактивности</string>
<string name="wipe_on_inactive_description">Стереть данные когда устройство не разблокируется N дней.</string>
<string name="wipe_on_inactive_days">Дней</string>
<string name="notification_channel_default_name">Дефолт</string>
<string name="ok">OK</string>
<string name="wipe_job_service_description">Стереть данные при условии</string>
<string name="wipe_job_service_start_failed_popup">Не удалось запустить службу стирания данных</string>
<string name="unlock_service_description">Получать события разблокировки</string>
<string name="unlock_service_notification_title">Служба Разблокировки</string>
</resources>

View File

@ -15,4 +15,13 @@
<string name="shortcut_label">Panic</string>
<string name="max_failed_password_attempts_description">Maximum number of failed password attempts.</string>
<string name="max_failed_password_attempts_content_description">Count</string>
<string name="wipe_on_inactive_switch">Wipe on inactive</string>
<string name="wipe_on_inactive_description">Wipe device when it was not unlocked for N days.</string>
<string name="wipe_on_inactive_days">Days</string>
<string name="notification_channel_default_name">Default</string>
<string name="ok">OK</string>
<string name="wipe_job_service_description">Wipe device on condition</string>
<string name="wipe_job_service_start_failed_popup">Failed to start wipe service</string>
<string name="unlock_service_description">Receive unlock events</string>
<string name="unlock_service_notification_title">Unlock Service</string>
</resources>

View File

@ -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.

View File

@ -4,8 +4,9 @@
сообщение с кодом аутентификации. При получении тревожного сигнала, используя API Администратора
Устройства, программа заблокирует устройство и опционально запустит стирание данных.
Также Вы можете установить лимит на максимальное количество неудачных попыток ввода пароля. После
исчерпания лимита данные на устройстве будут стёрты.
Также Вы можете:
* установить лимит на максимальное количество неудачных попыток ввода пароля
* стереть данные когда устройство не разблокировалось N дней
Приложение также работает с Рабочим Профилем. Вы можете использовать Shelter для установки
рискованных приложений и Потрачено в него. Затем Вы можете стереть данные этого профиля одним