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 [Device Administration API](https://developer.android.com/guide/topics/admin/device-admin), it
locks device and optionally runs wipe. locks device and optionally runs wipe.
Also you can limit the maximum number of failed password attempts. After the limit exceeded device Also you can:
will be wiped. - 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 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 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. 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 ## Example
Broadcast message: Broadcast message:

View File

@ -3,6 +3,9 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
package="me.lucky.wasted"> package="me.lucky.wasted">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application <application
android:allowBackup="false" android:allowBackup="false"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@ -90,5 +93,28 @@
android:value="true" /> android:value="true" />
</service> </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> </application>
</manifest> </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 package me.lucky.wasted
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity 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 com.google.android.material.snackbar.Snackbar
import java.util.* import java.util.*
@ -19,6 +21,7 @@ open class MainActivity : AppCompatActivity() {
private val prefs by lazy { Preferences(this) } private val prefs by lazy { Preferences(this) }
private val admin by lazy { DeviceAdminManager(this) } private val admin by lazy { DeviceAdminManager(this) }
private val shortcut by lazy { ShortcutManager(this) } private val shortcut by lazy { ShortcutManager(this) }
private val job by lazy { WipeJobManager(this) }
private val requestAdminPolicy = private val requestAdminPolicy =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
@ -51,6 +54,7 @@ open class MainActivity : AppCompatActivity() {
} }
private fun init() { private fun init() {
AppNotificationManager(this).createNotificationChannels()
if (prefs.code == "") prefs.code = makeCode() if (prefs.code == "") prefs.code = makeCode()
updateCodeColorState() updateCodeColorState()
binding.apply { binding.apply {
@ -60,6 +64,7 @@ open class MainActivity : AppCompatActivity() {
wipeESIM.isChecked = prefs.isWipeESIM wipeESIM.isChecked = prefs.isWipeESIM
wipeESIM.isEnabled = wipeData.isChecked wipeESIM.isEnabled = wipeData.isChecked
maxFailedPasswordAttempts.value = prefs.maxFailedPasswordAttempts.toFloat() maxFailedPasswordAttempts.value = prefs.maxFailedPasswordAttempts.toFloat()
wipeOnInactiveSwitch.isChecked = prefs.isWipeOnInactive
toggle.isChecked = prefs.isServiceEnabled toggle.isChecked = prefs.isServiceEnabled
} }
} }
@ -86,6 +91,19 @@ open class MainActivity : AppCompatActivity() {
maxFailedPasswordAttempts.addOnChangeListener { _, value, _ -> maxFailedPasswordAttempts.addOnChangeListener { _, value, _ ->
prefs.maxFailedPasswordAttempts = value.toInt() 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 -> toggle.setOnCheckedChangeListener { _, isChecked ->
when (isChecked) { when (isChecked) {
true -> if (!admin.isActive()) requestAdmin() else setOn() 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() { private fun updateCodeColorState() {
binding.code.setBackgroundColor(getColor( binding.code.setBackgroundColor(getColor(
if (prefs.isCodeEnabled) R.color.code_receiver_on else R.color.code_receiver_off 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() { private fun setOn() {
if (!setWipeOnInactiveComponentsState(prefs.isWipeOnInactive)) {
binding.toggle.isChecked = false
showWipeJobServiceStartFailedPopup()
return
}
prefs.isServiceEnabled = true prefs.isServiceEnabled = true
setCodeReceiverState(prefs.isCodeEnabled) setCodeReceiverState(prefs.isCodeEnabled)
shortcut.push() 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() { private fun setOff() {
prefs.isServiceEnabled = false prefs.isServiceEnabled = false
setCodeReceiverState(false) setCodeReceiverState(false)
setWipeOnInactiveComponentsState(false)
shortcut.remove() shortcut.remove()
admin.remove() admin.remove()
} }
private fun requestAdmin() = requestAdminPolicy.launch(admin.makeRequestIntent()) private fun requestAdmin() = requestAdminPolicy.launch(admin.makeRequestIntent())
private fun makeCode(): String = UUID.randomUUID().toString() 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( packageManager.setComponentEnabledSetting(
ComponentName(this, CodeReceiver::class.java), ComponentName(this, cls),
if (value) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else if (value) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else
PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
PackageManager.DONT_KILL_APP, 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) { class Preferences(ctx: Context) {
companion object { companion object {
const val DEFAULT_WIPE_ON_INACTIVE_DAYS = 7
private const val SERVICE_ENABLED = "service_enabled" private const val SERVICE_ENABLED = "service_enabled"
private const val CODE = "code" private const val CODE = "code"
private const val CODE_ENABLED = "code_enabled" private const val CODE_ENABLED = "code_enabled"
private const val WIPE_DATA = "wipe_data" private const val WIPE_DATA = "wipe_data"
private const val WIPE_ESIM = "wipe_esim" private const val WIPE_ESIM = "wipe_esim"
private const val MAX_FAILED_PASSWORD_ATTEMPTS = "max_failed_password_attempts" 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" private const val FILE_NAME = "sec_shared_prefs"
// migration // migration
@ -51,4 +55,12 @@ class Preferences(ctx: Context) {
var maxFailedPasswordAttempts: Int var maxFailedPasswordAttempts: Int
get() = prefs.getInt(MAX_FAILED_PASSWORD_ATTEMPTS, 0) get() = prefs.getInt(MAX_FAILED_PASSWORD_ATTEMPTS, 0)
set(value) = prefs.edit { putInt(MAX_FAILED_PASSWORD_ATTEMPTS, value) } 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:valueTo="10"
android:stepSize="1.0" /> android:stepSize="1.0" />
<Space
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="2dp" />
<TextView <TextView
android:id="@+id/description2" android:id="@+id/description2"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAlignment="center"
android:text="@string/max_failed_password_attempts_description" /> 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> </LinearLayout>
</ScrollView> </ScrollView>

View File

@ -15,4 +15,13 @@
<string name="shortcut_label">Паниковать</string> <string name="shortcut_label">Паниковать</string>
<string name="max_failed_password_attempts_description">Максимальное количество неудачных попыток ввода пароля.</string> <string name="max_failed_password_attempts_description">Максимальное количество неудачных попыток ввода пароля.</string>
<string name="max_failed_password_attempts_content_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> </resources>

View File

@ -15,4 +15,13 @@
<string name="shortcut_label">Panic</string> <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_description">Maximum number of failed password attempts.</string>
<string name="max_failed_password_attempts_content_description">Count</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> </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 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. 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 Also you can:
will be wiped. * 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 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. you can wipe this profile data with one click without wiping the whole device.

View File

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