From 2afda57f6411f1905a5a8a01bb0db20565cd07ab Mon Sep 17 00:00:00 2001 From: AdiyogX <214392644+AdiyogX@users.noreply.github.com> Date: Sun, 31 May 2026 15:20:29 +0530 Subject: [PATCH] feat: add v1.1.0 advanced privacy and security suite Implements the v1.1.0 feature set on top of the existing architecture (no package or DI rewrite): - Hidden vault: KeyStore-backed AES-256-GCM streaming encryption with import/export/secure-delete - Scheduled locking with an overnight-aware evaluator and exact-alarm compatibility - Intruder detection: silent front-camera capture, encrypted on-device only (no off-device upload) - Location and trusted Wi-Fi rules; notification privacy via a listener service - Usage statistics with a Compose chart; lock profiles and child (whitelist) mode - Encrypted backup/restore (PBKDF2 + AES-256-GCM); Quick Settings tile, widget, shake-to-lock - Security hardening: brute-force cooldown, scrambled keypad, wrong-PIN shake, configurable PIN length Adds unit tests (brute-force tiers, schedule evaluator, geofence, profile logic, backup round-trip) and updates README, CHANGELOG, RELEASE_NOTES, and the website changelog/updates/features pages. Note: marked Upcoming; not yet build-verified in CI, and the schedule/location/profile engines are not yet wired into the live lock decision. --- CHANGELOG.md | 21 +++- README.md | 13 +++ RELEASE_NOTES.md | 18 +++ app/src/main/AndroidManifest.xml | 40 +++++++ .../lockify/core/location/GeoFence.kt | 74 ++++++++++++ .../lockify/core/navigation/AppNavigator.kt | 8 ++ .../lockify/core/navigation/Screen.kt | 2 + .../lockify/core/schedule/LockSchedule.kt | 73 ++++++++++++ .../lockify/core/schedule/ScheduleAlarm.kt | 19 +++ .../lockify/core/security/BruteForceGuard.kt | 48 ++++++++ .../lockify/core/security/VaultCipher.kt | 60 ++++++++++ .../data/repository/AppLockRepository.kt | 17 +++ .../data/repository/PreferencesRepository.kt | 33 ++++++ .../lockify/features/backup/BackupCrypto.kt | 51 ++++++++ .../lockify/features/backup/BackupManager.kt | 41 +++++++ .../features/intruder/IntruderCapture.kt | 95 +++++++++++++++ .../features/location/TrustedEnvironment.kt | 62 ++++++++++ .../lockscreen/ui/PasswordOverlayScreen.kt | 109 ++++++++++++++---- .../LockifyNotificationService.kt | 49 ++++++++ .../features/profiles/ProfileRepository.kt | 78 +++++++++++++ .../features/quicktile/LockTileService.kt | 26 +++++ .../features/settings/ui/SettingsScreen.kt | 78 +++++++++++++ .../lockify/features/shake/ShakeDetector.kt | 45 ++++++++ .../features/stats/UsageStatsProvider.kt | 22 ++++ .../lockify/features/stats/ui/StatsScreen.kt | 91 +++++++++++++++ .../lockify/features/vault/VaultRepository.kt | 66 +++++++++++ .../lockify/features/vault/ui/VaultScreen.kt | 107 +++++++++++++++++ .../features/widget/LockWidgetProvider.kt | 41 +++++++ .../services/ExperimentalAppLockService.kt | 9 ++ app/src/main/res/layout/widget_lock.xml | 13 +++ app/src/main/res/values/strings.xml | 14 +++ app/src/main/res/xml/lock_widget_info.xml | 8 ++ .../lockify/core/location/GeoFenceTest.kt | 21 ++++ .../core/schedule/ScheduleEvaluatorTest.kt | 32 +++++ .../core/security/BruteForceGuardTest.kt | 22 ++++ .../features/backup/BackupCryptoTest.kt | 22 ++++ .../features/profiles/LockProfileTest.kt | 21 ++++ web-app/changelog.html | 56 +++++++++ web-app/features.html | 66 +++++++++++ web-app/updates.html | 12 ++ 40 files changed, 1659 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/com/itisuniqueofficial/lockify/core/location/GeoFence.kt create mode 100644 app/src/main/java/com/itisuniqueofficial/lockify/core/schedule/LockSchedule.kt create mode 100644 app/src/main/java/com/itisuniqueofficial/lockify/core/schedule/ScheduleAlarm.kt create mode 100644 app/src/main/java/com/itisuniqueofficial/lockify/core/security/BruteForceGuard.kt create mode 100644 app/src/main/java/com/itisuniqueofficial/lockify/core/security/VaultCipher.kt create mode 100644 app/src/main/java/com/itisuniqueofficial/lockify/features/backup/BackupCrypto.kt create mode 100644 app/src/main/java/com/itisuniqueofficial/lockify/features/backup/BackupManager.kt create mode 100644 app/src/main/java/com/itisuniqueofficial/lockify/features/intruder/IntruderCapture.kt create mode 100644 app/src/main/java/com/itisuniqueofficial/lockify/features/location/TrustedEnvironment.kt create mode 100644 app/src/main/java/com/itisuniqueofficial/lockify/features/notifications/LockifyNotificationService.kt create mode 100644 app/src/main/java/com/itisuniqueofficial/lockify/features/profiles/ProfileRepository.kt create mode 100644 app/src/main/java/com/itisuniqueofficial/lockify/features/quicktile/LockTileService.kt create mode 100644 app/src/main/java/com/itisuniqueofficial/lockify/features/shake/ShakeDetector.kt create mode 100644 app/src/main/java/com/itisuniqueofficial/lockify/features/stats/UsageStatsProvider.kt create mode 100644 app/src/main/java/com/itisuniqueofficial/lockify/features/stats/ui/StatsScreen.kt create mode 100644 app/src/main/java/com/itisuniqueofficial/lockify/features/vault/VaultRepository.kt create mode 100644 app/src/main/java/com/itisuniqueofficial/lockify/features/vault/ui/VaultScreen.kt create mode 100644 app/src/main/java/com/itisuniqueofficial/lockify/features/widget/LockWidgetProvider.kt create mode 100644 app/src/main/res/layout/widget_lock.xml create mode 100644 app/src/main/res/xml/lock_widget_info.xml create mode 100644 app/src/test/java/com/itisuniqueofficial/lockify/core/location/GeoFenceTest.kt create mode 100644 app/src/test/java/com/itisuniqueofficial/lockify/core/schedule/ScheduleEvaluatorTest.kt create mode 100644 app/src/test/java/com/itisuniqueofficial/lockify/core/security/BruteForceGuardTest.kt create mode 100644 app/src/test/java/com/itisuniqueofficial/lockify/features/backup/BackupCryptoTest.kt create mode 100644 app/src/test/java/com/itisuniqueofficial/lockify/features/profiles/LockProfileTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index ff7f97f..66e0cf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,25 @@ # Changelog -## Unreleased +## 1.1.0 — Unreleased +Added +- Hidden vault: encrypt photos, videos, and files with AES-256-GCM keyed from the Android KeyStore (streaming, large-file safe). +- Scheduled locking: time and day-of-week windows with exact-alarm support. +- Intruder detection: silent front-camera capture after repeated failed unlocks, stored encrypted on-device only (no off-device upload). +- Location and Wi-Fi rules: relax locking on trusted networks or inside trusted geofences. +- Notification privacy: redact locked-app notification content via a notification listener. +- Usage statistics: 7-day per-app usage insights with charts. +- Lock profiles and child (whitelist) mode, each with its own PIN. +- Encrypted backup and restore (password-based PBKDF2 + AES-256-GCM). +- Quick Settings tile, home-screen widget, and shake-to-lock. + +Security +- Brute-force protection: escalating cooldown after repeated failed attempts (3 → 30s, 5 → 2m, 10 → 10m). +- Scrambled PIN keypad option to resist shoulder-surfing. + +Improved +- Wrong-PIN shake animation and configurable minimum PIN length. + +## Earlier Unreleased - Hardened PIN/pattern storage with salted PBKDF2 hashes and legacy migration. - Made lock overlay close/back return to Home instead of revealing protected content. - Added unit tests for credential hashing. diff --git a/README.md b/README.md index 32f5804..9e4ddfd 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,19 @@ the core experience lightweight and on-device. - Real-time background protection - Lightweight and fast +### New in v1.1.0 (upcoming) + +- Hidden vault — encrypt photos, videos, and files (AES-256, hardware-backed key) +- Scheduled locking by time and day of week +- Intruder detection — silent front-camera capture, encrypted on-device only +- Location and trusted Wi-Fi rules +- Notification privacy for locked apps +- Usage statistics with on-device charts +- Lock profiles and child (whitelist) mode +- Encrypted backup and restore +- Brute-force cooldown and scrambled PIN keypad +- Quick Settings tile, home-screen widget, and shake-to-lock +
## Play Store diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7809d26..711e25f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,21 @@ +# Lockify v1.1.0 Release Notes (Upcoming) + +## What's New in v1.1.0 + +- Hidden Vault — encrypt photos, videos, and files with AES-256 (hardware-backed key) +- Scheduled Locking — auto-lock selected apps by time and day of week +- Intruder Detection — silently capture a front-camera photo after repeated failed unlocks (stored encrypted on your device only) +- Location & Wi-Fi Rules — relax locking on trusted networks or at trusted places +- Notification Privacy — hide notification content for locked apps +- Usage Statistics — 7-day app usage insights with charts +- Lock Profiles & Child Mode — multiple profiles, each with its own PIN +- Encrypted Backup & Restore — password-protected local backups +- Quick Settings Tile, Home-Screen Widget & Shake-to-Lock +- Brute-Force Protection — escalating cooldown after repeated wrong attempts +- Scrambled PIN Keypad, wrong-PIN shake animation, and configurable PIN length + +--- + # Lockify v1.0.3 Release Notes ## What's New in v1.0.3 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e02ed10..062146e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,13 @@ tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" /> + + + + + @@ -117,6 +124,16 @@ android:value="accessibility" /> + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/itisuniqueofficial/lockify/core/location/GeoFence.kt b/app/src/main/java/com/itisuniqueofficial/lockify/core/location/GeoFence.kt new file mode 100644 index 0000000..3687740 --- /dev/null +++ b/app/src/main/java/com/itisuniqueofficial/lockify/core/location/GeoFence.kt @@ -0,0 +1,74 @@ +package com.itisuniqueofficial.lockify.core.location + +import android.content.Context +import androidx.core.content.edit +import org.json.JSONArray +import org.json.JSONObject +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +/** A circular geofence. [action] UNLOCK_WITHIN relaxes locking inside the radius. */ +data class LocationRule( + val id: Long, + val name: String, + val latitude: Double, + val longitude: Double, + val radiusMeters: Float, + val unlockWithin: Boolean = true, + val enabled: Boolean = true +) + +object GeoFence { + private const val EARTH_RADIUS_M = 6_371_000.0 + + /** Great-circle distance between two coordinates, in metres. */ + fun distanceMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val dLat = Math.toRadians(lat2 - lat1) + val dLon = Math.toRadians(lon2 - lon1) + val a = sin(dLat / 2) * sin(dLat / 2) + + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * + sin(dLon / 2) * sin(dLon / 2) + return EARTH_RADIUS_M * 2 * atan2(sqrt(a), sqrt(1 - a)) + } + + fun isWithin(rule: LocationRule, lat: Double, lon: Double): Boolean = + distanceMeters(rule.latitude, rule.longitude, lat, lon) <= rule.radiusMeters +} + +/** Persists location rules as JSON in SharedPreferences. */ +class LocationRuleRepository(context: Context) { + private val prefs = context.applicationContext + .getSharedPreferences("location_rules", Context.MODE_PRIVATE) + + fun getAll(): List { + val arr = JSONArray(prefs.getString(KEY, "[]")) + return (0 until arr.length()).map { i -> + val o = arr.getJSONObject(i) + LocationRule( + o.getLong("id"), o.getString("name"), o.getDouble("lat"), o.getDouble("lon"), + o.getDouble("radius").toFloat(), o.getBoolean("unlock"), o.getBoolean("enabled") + ) + } + } + + fun save(rule: LocationRule) = persist(getAll().filter { it.id != rule.id } + rule) + fun delete(id: Long) = persist(getAll().filter { it.id != id }) + + private fun persist(list: List) { + val arr = JSONArray() + list.forEach { + arr.put( + JSONObject().put("id", it.id).put("name", it.name).put("lat", it.latitude) + .put("lon", it.longitude).put("radius", it.radiusMeters.toDouble()) + .put("unlock", it.unlockWithin).put("enabled", it.enabled) + ) + } + prefs.edit { putString(KEY, arr.toString()) } + } + + private companion object { + const val KEY = "rules" + } +} diff --git a/app/src/main/java/com/itisuniqueofficial/lockify/core/navigation/AppNavigator.kt b/app/src/main/java/com/itisuniqueofficial/lockify/core/navigation/AppNavigator.kt index 0b01420..d410c95 100644 --- a/app/src/main/java/com/itisuniqueofficial/lockify/core/navigation/AppNavigator.kt +++ b/app/src/main/java/com/itisuniqueofficial/lockify/core/navigation/AppNavigator.kt @@ -147,6 +147,14 @@ fun AppNavHost(navController: NavHostController, startDestination: String) { composable(Screen.AntiUninstall.route) { AntiUninstallScreen(navController) } + + composable(Screen.Vault.route) { + com.itisuniqueofficial.lockify.features.vault.ui.VaultScreen(navController) + } + + composable(Screen.Stats.route) { + com.itisuniqueofficial.lockify.features.stats.ui.StatsScreen(navController) + } } } diff --git a/app/src/main/java/com/itisuniqueofficial/lockify/core/navigation/Screen.kt b/app/src/main/java/com/itisuniqueofficial/lockify/core/navigation/Screen.kt index 739a3a8..966d40f 100644 --- a/app/src/main/java/com/itisuniqueofficial/lockify/core/navigation/Screen.kt +++ b/app/src/main/java/com/itisuniqueofficial/lockify/core/navigation/Screen.kt @@ -11,5 +11,7 @@ sealed class Screen(val route: String) { object Settings : Screen("settings") object TriggerExclusions : Screen("trigger_exclusions") object AntiUninstall: Screen("anti_uninstall") + object Vault : Screen("vault") + object Stats : Screen("stats") } diff --git a/app/src/main/java/com/itisuniqueofficial/lockify/core/schedule/LockSchedule.kt b/app/src/main/java/com/itisuniqueofficial/lockify/core/schedule/LockSchedule.kt new file mode 100644 index 0000000..941b1c0 --- /dev/null +++ b/app/src/main/java/com/itisuniqueofficial/lockify/core/schedule/LockSchedule.kt @@ -0,0 +1,73 @@ +package com.itisuniqueofficial.lockify.core.schedule + +import android.content.Context +import androidx.core.content.edit +import org.json.JSONArray +import org.json.JSONObject + +/** + * A lock schedule. [daysOfWeek] is a bitmask with Mon=1, Tue=2, ... Sun=64. + * Times are minutes-since-midnight. A window where start > end wraps past midnight. + */ +data class LockSchedule( + val id: Long, + val name: String, + val daysOfWeek: Int, + val startMinutes: Int, + val endMinutes: Int, + val enabled: Boolean = true +) + +/** Pure, unit-testable evaluation of whether a schedule is active at a given moment. */ +object ScheduleEvaluator { + /** [isoDayOfWeek] is 1=Mon..7=Sun; [minuteOfDay] is 0..1439. */ + fun isActive(s: LockSchedule, isoDayOfWeek: Int, minuteOfDay: Int): Boolean { + if (!s.enabled) return false + if (s.daysOfWeek and (1 shl (isoDayOfWeek - 1)) == 0) return false + return if (s.startMinutes <= s.endMinutes) + minuteOfDay in s.startMinutes until s.endMinutes + else + minuteOfDay >= s.startMinutes || minuteOfDay < s.endMinutes + } +} + +/** Persists schedules as JSON in SharedPreferences (no DB dependency). */ +class ScheduleRepository(context: Context) { + private val prefs = context.applicationContext + .getSharedPreferences("lock_schedules", Context.MODE_PRIVATE) + + fun getAll(): List { + val arr = JSONArray(prefs.getString(KEY, "[]")) + return (0 until arr.length()).map { i -> + val o = arr.getJSONObject(i) + LockSchedule( + o.getLong("id"), o.getString("name"), o.getInt("days"), + o.getInt("start"), o.getInt("end"), o.getBoolean("enabled") + ) + } + } + + fun save(schedule: LockSchedule) { + val updated = getAll().filter { it.id != schedule.id } + schedule + persist(updated) + } + + fun delete(id: Long) = persist(getAll().filter { it.id != id }) + + private fun persist(list: List) { + val arr = JSONArray() + list.forEach { + arr.put( + JSONObject() + .put("id", it.id).put("name", it.name).put("days", it.daysOfWeek) + .put("start", it.startMinutes).put("end", it.endMinutes) + .put("enabled", it.enabled) + ) + } + prefs.edit { putString(KEY, arr.toString()) } + } + + private companion object { + const val KEY = "schedules" + } +} diff --git a/app/src/main/java/com/itisuniqueofficial/lockify/core/schedule/ScheduleAlarm.kt b/app/src/main/java/com/itisuniqueofficial/lockify/core/schedule/ScheduleAlarm.kt new file mode 100644 index 0000000..cfc8f6d --- /dev/null +++ b/app/src/main/java/com/itisuniqueofficial/lockify/core/schedule/ScheduleAlarm.kt @@ -0,0 +1,19 @@ +package com.itisuniqueofficial.lockify.core.schedule + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.os.Build + +/** Schedules exact alarms with graceful fallback across API 26..35. */ +object ScheduleAlarm { + fun scheduleExact(context: Context, triggerAtMillis: Long, pendingIntent: PendingIntent) { + val am = context.getSystemService(AlarmManager::class.java) ?: return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !am.canScheduleExactAlarms()) { + // No exact-alarm permission: fall back to an inexact-but-idle-safe alarm. + am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent) + return + } + am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent) + } +} diff --git a/app/src/main/java/com/itisuniqueofficial/lockify/core/security/BruteForceGuard.kt b/app/src/main/java/com/itisuniqueofficial/lockify/core/security/BruteForceGuard.kt new file mode 100644 index 0000000..acb9ea3 --- /dev/null +++ b/app/src/main/java/com/itisuniqueofficial/lockify/core/security/BruteForceGuard.kt @@ -0,0 +1,48 @@ +package com.itisuniqueofficial.lockify.core.security + +import android.content.Context +import androidx.core.content.edit + +/** + * Brute-force protection: applies a tiered cooldown after repeated failed unlock + * attempts. State is persisted so a cooldown survives process death / app restart. + * + * Tiers (per spec §5.4): 3 fails -> 30s, 5 fails -> 2min, 10 fails -> 10min. + */ +class BruteForceGuard(context: Context) { + + private val prefs = context.applicationContext + .getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + /** Remaining lockout in milliseconds, or 0 if an unlock attempt is allowed now. */ + fun remainingLockoutMs(now: Long = System.currentTimeMillis()): Long = + (prefs.getLong(KEY_UNTIL, 0L) - now).coerceAtLeast(0L) + + /** Records a failed attempt and arms the next cooldown tier. Returns the new cooldown ms. */ + fun recordFailure(now: Long = System.currentTimeMillis()): Long { + val attempts = prefs.getInt(KEY_ATTEMPTS, 0) + 1 + val cooldown = cooldownMsForAttempts(attempts) + prefs.edit { + putInt(KEY_ATTEMPTS, attempts) + putLong(KEY_UNTIL, if (cooldown > 0) now + cooldown else 0L) + } + return cooldown + } + + /** Clears all failure state after a successful unlock. */ + fun recordSuccess() = prefs.edit { remove(KEY_ATTEMPTS); remove(KEY_UNTIL) } + + companion object { + private const val PREFS = "brute_force_guard" + private const val KEY_ATTEMPTS = "failed_attempts" + private const val KEY_UNTIL = "lockout_until" + + /** Pure, unit-testable cooldown tier mapping. */ + fun cooldownMsForAttempts(attempts: Int): Long = when { + attempts >= 10 -> 10 * 60_000L + attempts >= 5 -> 2 * 60_000L + attempts >= 3 -> 30_000L + else -> 0L + } + } +} diff --git a/app/src/main/java/com/itisuniqueofficial/lockify/core/security/VaultCipher.kt b/app/src/main/java/com/itisuniqueofficial/lockify/core/security/VaultCipher.kt new file mode 100644 index 0000000..a970358 --- /dev/null +++ b/app/src/main/java/com/itisuniqueofficial/lockify/core/security/VaultCipher.kt @@ -0,0 +1,60 @@ +package com.itisuniqueofficial.lockify.core.security + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.io.InputStream +import java.io.OutputStream +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.CipherInputStream +import javax.crypto.CipherOutputStream +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * AES-256-GCM streaming encryption for vault files. The master key is generated in and + * never leaves the Android KeyStore (hardware-backed where available). Files are streamed, + * so arbitrarily large media never has to be held in memory. + * + * On-disk layout per file: [12-byte IV][GCM ciphertext+tag]. + */ +object VaultCipher { + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val KEY_ALIAS = "lockify_vault_key_v1" + private const val TRANSFORM = "AES/GCM/NoPadding" + private const val IV_LEN = 12 + private const val TAG_BITS = 128 + + private fun key(): SecretKey { + val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + (ks.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey } + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE).apply { + init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build() + ) + }.generateKey() + } + + fun encrypt(input: InputStream, output: OutputStream) { + val cipher = Cipher.getInstance(TRANSFORM).apply { init(Cipher.ENCRYPT_MODE, key()) } + output.write(cipher.iv) + CipherOutputStream(output, cipher).use { input.copyTo(it) } + } + + fun decrypt(input: InputStream, output: OutputStream) { + val iv = ByteArray(IV_LEN) + require(input.read(iv) == IV_LEN) { "Corrupt vault file: missing IV" } + val cipher = Cipher.getInstance(TRANSFORM).apply { + init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(TAG_BITS, iv)) + } + CipherInputStream(input, cipher).use { it.copyTo(output) } + } +} diff --git a/app/src/main/java/com/itisuniqueofficial/lockify/data/repository/AppLockRepository.kt b/app/src/main/java/com/itisuniqueofficial/lockify/data/repository/AppLockRepository.kt index 7110711..96c3c49 100644 --- a/app/src/main/java/com/itisuniqueofficial/lockify/data/repository/AppLockRepository.kt +++ b/app/src/main/java/com/itisuniqueofficial/lockify/data/repository/AppLockRepository.kt @@ -38,6 +38,8 @@ class AppLockRepository(private val context: Context) { fun getPassword(): String? = preferencesRepository.getPassword() fun setPassword(password: String) = preferencesRepository.setPassword(password) + fun setRawPasswordHash(hash: String) = preferencesRepository.setRawPasswordHash(hash) + fun setRawPatternHash(hash: String) = preferencesRepository.setRawPatternHash(hash) fun validatePassword(inputPassword: String): Boolean = preferencesRepository.validatePassword(inputPassword) @@ -57,6 +59,21 @@ class AppLockRepository(private val context: Context) { fun shouldUseMaxBrightness(): Boolean = preferencesRepository.shouldUseMaxBrightness() fun setDisableHaptics(enabled: Boolean) = preferencesRepository.setDisableHaptics(enabled) fun shouldDisableHaptics(): Boolean = preferencesRepository.shouldDisableHaptics() + fun setScrambleKeypadEnabled(enabled: Boolean) = + preferencesRepository.setScrambleKeypadEnabled(enabled) + fun isScrambleKeypadEnabled(): Boolean = preferencesRepository.isScrambleKeypadEnabled() + fun setMinPinLength(length: Int) = preferencesRepository.setMinPinLength(length) + fun getMinPinLength(): Int = preferencesRepository.getMinPinLength() + fun setIntruderSelfieEnabled(enabled: Boolean) = + preferencesRepository.setIntruderSelfieEnabled(enabled) + fun isIntruderSelfieEnabled(): Boolean = preferencesRepository.isIntruderSelfieEnabled() + fun setHideLockedAppNotifications(enabled: Boolean) = + preferencesRepository.setHideLockedAppNotifications(enabled) + fun isHideLockedAppNotifications(): Boolean = + preferencesRepository.isHideLockedAppNotifications() + fun setShakeToLockEnabled(enabled: Boolean) = + preferencesRepository.setShakeToLockEnabled(enabled) + fun isShakeToLockEnabled(): Boolean = preferencesRepository.isShakeToLockEnabled() fun setShowSystemApps(enabled: Boolean) = preferencesRepository.setShowSystemApps(enabled) fun shouldShowSystemApps(): Boolean = preferencesRepository.shouldShowSystemApps() diff --git a/app/src/main/java/com/itisuniqueofficial/lockify/data/repository/PreferencesRepository.kt b/app/src/main/java/com/itisuniqueofficial/lockify/data/repository/PreferencesRepository.kt index 33ec5f2..cc75e1b 100644 --- a/app/src/main/java/com/itisuniqueofficial/lockify/data/repository/PreferencesRepository.kt +++ b/app/src/main/java/com/itisuniqueofficial/lockify/data/repository/PreferencesRepository.kt @@ -20,6 +20,8 @@ class PreferencesRepository(context: Context) { appLockPrefs.edit { putString(KEY_PASSWORD, CredentialHasher.hash(password)) } } fun getPassword(): String? = appLockPrefs.getString(KEY_PASSWORD, null) + /** Restores a previously-hashed credential verbatim (used by backup restore). */ + fun setRawPasswordHash(hash: String) = appLockPrefs.edit { putString(KEY_PASSWORD, hash) } fun validatePassword(inputPassword: String): Boolean { val stored = getPassword() if (CredentialHasher.verify(inputPassword, stored)) return true @@ -34,6 +36,7 @@ class PreferencesRepository(context: Context) { appLockPrefs.edit { putString(KEY_PATTERN, CredentialHasher.hash(pattern)) } } fun getPattern(): String? = appLockPrefs.getString(KEY_PATTERN, null) + fun setRawPatternHash(hash: String) = appLockPrefs.edit { putString(KEY_PATTERN, hash) } fun validatePattern(inputPattern: String): Boolean { val stored = getPattern() if (CredentialHasher.verify(inputPattern, stored)) return true @@ -64,6 +67,30 @@ class PreferencesRepository(context: Context) { } fun shouldDisableHaptics(): Boolean = settingsPrefs.getBoolean(KEY_DISABLE_HAPTICS, false) + fun setScrambleKeypadEnabled(enabled: Boolean) { + settingsPrefs.edit { putBoolean(KEY_SCRAMBLE_KEYPAD, enabled) } + } + fun isScrambleKeypadEnabled(): Boolean = settingsPrefs.getBoolean(KEY_SCRAMBLE_KEYPAD, false) + + fun setMinPinLength(length: Int) { settingsPrefs.edit { putInt(KEY_MIN_PIN_LENGTH, length) } } + fun getMinPinLength(): Int = settingsPrefs.getInt(KEY_MIN_PIN_LENGTH, DEFAULT_MIN_PIN_LENGTH) + + fun setIntruderSelfieEnabled(enabled: Boolean) { + settingsPrefs.edit { putBoolean(KEY_INTRUDER_SELFIE, enabled) } + } + fun isIntruderSelfieEnabled(): Boolean = settingsPrefs.getBoolean(KEY_INTRUDER_SELFIE, false) + + fun setHideLockedAppNotifications(enabled: Boolean) { + settingsPrefs.edit { putBoolean(KEY_HIDE_NOTIFICATIONS, enabled) } + } + fun isHideLockedAppNotifications(): Boolean = + settingsPrefs.getBoolean(KEY_HIDE_NOTIFICATIONS, false) + + fun setShakeToLockEnabled(enabled: Boolean) { + settingsPrefs.edit { putBoolean(KEY_SHAKE_TO_LOCK, enabled) } + } + fun isShakeToLockEnabled(): Boolean = settingsPrefs.getBoolean(KEY_SHAKE_TO_LOCK, false) + fun setShowSystemApps(enabled: Boolean) { settingsPrefs.edit { putBoolean(KEY_SHOW_SYSTEM_APPS, enabled) } } @@ -146,6 +173,12 @@ class PreferencesRepository(context: Context) { private const val KEY_PATTERN = "pattern" private const val KEY_BIOMETRIC_AUTH_ENABLED = "use_biometric_auth" private const val KEY_DISABLE_HAPTICS = "disable_haptics" + private const val KEY_SCRAMBLE_KEYPAD = "scramble_keypad" + private const val KEY_MIN_PIN_LENGTH = "min_pin_length" + private const val KEY_INTRUDER_SELFIE = "intruder_selfie" + private const val KEY_HIDE_NOTIFICATIONS = "hide_locked_app_notifications" + private const val KEY_SHAKE_TO_LOCK = "shake_to_lock" + private const val DEFAULT_MIN_PIN_LENGTH = 4 private const val KEY_USE_MAX_BRIGHTNESS = "use_max_brightness" private const val KEY_ANTI_UNINSTALL = "anti_uninstall" private const val KEY_UNLOCK_TIME_DURATION = "unlock_time_duration" diff --git a/app/src/main/java/com/itisuniqueofficial/lockify/features/backup/BackupCrypto.kt b/app/src/main/java/com/itisuniqueofficial/lockify/features/backup/BackupCrypto.kt new file mode 100644 index 0000000..0c72629 --- /dev/null +++ b/app/src/main/java/com/itisuniqueofficial/lockify/features/backup/BackupCrypto.kt @@ -0,0 +1,51 @@ +package com.itisuniqueofficial.lockify.features.backup + +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.SecretKeySpec + +/** + * Password-based encryption for local backups. A 256-bit key is derived from the user's + * backup password with PBKDF2-HMAC-SHA256, then AES-256-GCM protects the payload. + * + * Blob layout: [16-byte salt][12-byte IV][GCM ciphertext+tag]. Pure JVM, unit-testable. + */ +object BackupCrypto { + private const val ITERATIONS = 120_000 + private const val SALT_LEN = 16 + private const val IV_LEN = 12 + private const val TAG_BITS = 128 + + fun encrypt(plaintext: ByteArray, password: CharArray): ByteArray { + val salt = ByteArray(SALT_LEN).also { SecureRandom().nextBytes(it) } + val iv = ByteArray(IV_LEN).also { SecureRandom().nextBytes(it) } + val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply { + init(Cipher.ENCRYPT_MODE, deriveKey(password, salt), GCMParameterSpec(TAG_BITS, iv)) + } + return salt + iv + cipher.doFinal(plaintext) + } + + fun decrypt(blob: ByteArray, password: CharArray): ByteArray { + require(blob.size > SALT_LEN + IV_LEN) { "Backup file is truncated" } + val salt = blob.copyOfRange(0, SALT_LEN) + val iv = blob.copyOfRange(SALT_LEN, SALT_LEN + IV_LEN) + val ct = blob.copyOfRange(SALT_LEN + IV_LEN, blob.size) + val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply { + init(Cipher.DECRYPT_MODE, deriveKey(password, salt), GCMParameterSpec(TAG_BITS, iv)) + } + return cipher.doFinal(ct) + } + + private fun deriveKey(password: CharArray, salt: ByteArray): SecretKeySpec { + val spec = PBEKeySpec(password, salt, ITERATIONS, 256) + return try { + val bits = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded + SecretKeySpec(bits, "AES") + } finally { + spec.clearPassword() + } + } +} diff --git a/app/src/main/java/com/itisuniqueofficial/lockify/features/backup/BackupManager.kt b/app/src/main/java/com/itisuniqueofficial/lockify/features/backup/BackupManager.kt new file mode 100644 index 0000000..5757c3e --- /dev/null +++ b/app/src/main/java/com/itisuniqueofficial/lockify/features/backup/BackupManager.kt @@ -0,0 +1,41 @@ +package com.itisuniqueofficial.lockify.features.backup + +import android.content.Context +import android.net.Uri +import com.itisuniqueofficial.lockify.data.repository.AppLockRepository +import org.json.JSONArray +import org.json.JSONObject + +/** + * Exports/imports an encrypted backup of lock configuration: the (already-hashed) credentials, + * lock type and the locked-app list. Vault files themselves are intentionally not included. + */ +class BackupManager(private val context: Context) { + + private val repo = AppLockRepository(context) + + fun export(dest: Uri, password: CharArray) { + val json = JSONObject() + .put("version", 1) + .put("lockType", repo.getLockType()) + .put("lockedApps", JSONArray(repo.getLockedApps().toList())) + repo.getPassword()?.let { json.put("passwordHash", it) } + repo.getPattern()?.let { json.put("patternHash", it) } + val blob = BackupCrypto.encrypt(json.toString().toByteArray(Charsets.UTF_8), password) + context.contentResolver.openOutputStream(dest)?.use { it.write(blob) } + ?: error("Unable to open backup destination") + } + + /** Decrypts and applies a backup. Throws if the password is wrong (GCM tag mismatch). */ + fun restore(source: Uri, password: CharArray) { + val blob = context.contentResolver.openInputStream(source)?.use { it.readBytes() } + ?: error("Unable to open backup file") + val json = JSONObject(String(BackupCrypto.decrypt(blob, password), Charsets.UTF_8)) + + repo.setLockType(json.getString("lockType")) + if (json.has("passwordHash")) repo.setRawPasswordHash(json.getString("passwordHash")) + if (json.has("patternHash")) repo.setRawPatternHash(json.getString("patternHash")) + val apps = json.getJSONArray("lockedApps") + (0 until apps.length()).forEach { repo.addLockedApp(apps.getString(it)) } + } +} diff --git a/app/src/main/java/com/itisuniqueofficial/lockify/features/intruder/IntruderCapture.kt b/app/src/main/java/com/itisuniqueofficial/lockify/features/intruder/IntruderCapture.kt new file mode 100644 index 0000000..24606f8 --- /dev/null +++ b/app/src/main/java/com/itisuniqueofficial/lockify/features/intruder/IntruderCapture.kt @@ -0,0 +1,95 @@ +package com.itisuniqueofficial.lockify.features.intruder + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.ImageFormat +import android.hardware.camera2.CameraCaptureSession +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraDevice +import android.hardware.camera2.CameraManager +import android.media.ImageReader +import android.os.Handler +import android.os.HandlerThread +import android.util.Log +import androidx.core.content.ContextCompat +import com.itisuniqueofficial.lockify.core.security.VaultCipher +import java.io.ByteArrayInputStream +import java.io.File + +/** + * Silently captures a single front-camera still after repeated failed unlocks and stores it + * AES-256-GCM encrypted in app-private storage. No preview, no shutter sound, and — by design — + * no off-device transmission. Opt-in only. + */ +class IntruderCapture(private val context: Context) { + + @SuppressLint("MissingPermission") + fun capture() { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) + != PackageManager.PERMISSION_GRANTED + ) return + val cm = context.getSystemService(CameraManager::class.java) ?: return + val frontId = cm.cameraIdList.firstOrNull { + cm.getCameraCharacteristics(it) + .get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT + } ?: return + + val reader = ImageReader.newInstance(640, 480, ImageFormat.JPEG, 1) + val thread = HandlerThread("intruder-capture").apply { start() } + val handler = Handler(thread.looper) + + reader.setOnImageAvailableListener({ r -> + r.acquireLatestImage()?.use { image -> + val buffer = image.planes[0].buffer + val bytes = ByteArray(buffer.remaining()).also { buffer.get(it) } + save(bytes) + } + r.close() + thread.quitSafely() + }, handler) + + runCatching { + cm.openCamera(frontId, object : CameraDevice.StateCallback() { + override fun onOpened(camera: CameraDevice) { + val request = camera.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE) + .apply { addTarget(reader.surface) } + @Suppress("DEPRECATION") + camera.createCaptureSession( + listOf(reader.surface), + object : CameraCaptureSession.StateCallback() { + override fun onConfigured(session: CameraCaptureSession) { + session.capture(request.build(), object : + CameraCaptureSession.CaptureCallback() { + override fun onCaptureCompleted( + s: CameraCaptureSession, + req: android.hardware.camera2.CaptureRequest, + result: android.hardware.camera2.TotalCaptureResult + ) { + camera.close() + } + }, handler) + } + + override fun onConfigureFailed(session: CameraCaptureSession) = + camera.close() + }, + handler + ) + } + + override fun onDisconnected(camera: CameraDevice) = camera.close() + override fun onError(camera: CameraDevice, error: Int) = camera.close() + }, handler) + }.onFailure { Log.w("IntruderCapture", "capture failed", it) } + } + + private fun save(jpeg: ByteArray) { + val dir = File(context.filesDir, "intruders").apply { mkdirs() } + val target = File(dir, "${System.currentTimeMillis()}.enc") + ByteArrayInputStream(jpeg).use { input -> + target.outputStream().use { VaultCipher.encrypt(input, it) } + } + } +} diff --git a/app/src/main/java/com/itisuniqueofficial/lockify/features/location/TrustedEnvironment.kt b/app/src/main/java/com/itisuniqueofficial/lockify/features/location/TrustedEnvironment.kt new file mode 100644 index 0000000..77668fe --- /dev/null +++ b/app/src/main/java/com/itisuniqueofficial/lockify/features/location/TrustedEnvironment.kt @@ -0,0 +1,62 @@ +package com.itisuniqueofficial.lockify.features.location + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.location.LocationManager +import android.net.wifi.WifiManager +import androidx.core.content.ContextCompat +import androidx.core.content.edit +import com.itisuniqueofficial.lockify.core.location.GeoFence +import com.itisuniqueofficial.lockify.core.location.LocationRuleRepository + +/** + * Decides whether locking should be relaxed for the current environment: + * either connected to a trusted Wi-Fi SSID, or inside an "unlock-within" geofence. + */ +class TrustedEnvironment(private val context: Context) { + + private val prefs = context.applicationContext + .getSharedPreferences("trusted_env", Context.MODE_PRIVATE) + private val rules = LocationRuleRepository(context) + + fun trustedSsids(): Set = prefs.getStringSet(KEY_SSIDS, emptySet()) ?: emptySet() + fun setTrustedSsids(ssids: Set) = prefs.edit { putStringSet(KEY_SSIDS, ssids) } + + fun shouldRelaxLocking(): Boolean = onTrustedWifi() || insideUnlockGeofence() + + private fun onTrustedWifi(): Boolean { + val current = currentSsid() ?: return false + return current in trustedSsids() + } + + @Suppress("DEPRECATION") + private fun currentSsid(): String? { + val wifi = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager + ?: return null + // SSID is quoted by the framework, e.g. "MyNetwork"; strip the quotes. + return wifi.connectionInfo?.ssid?.trim('"')?.takeIf { it.isNotBlank() && it != "" } + } + + private fun insideUnlockGeofence(): Boolean { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) + != PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) + != PackageManager.PERMISSION_GRANTED + ) return false + + val lm = context.getSystemService(LocationManager::class.java) ?: return false + val loc = runCatching { + lm.getProviders(true).mapNotNull { lm.getLastKnownLocation(it) } + .maxByOrNull { it.time } + }.getOrNull() ?: return false + + return rules.getAll().any { + it.enabled && it.unlockWithin && GeoFence.isWithin(it, loc.latitude, loc.longitude) + } + } + + private companion object { + const val KEY_SSIDS = "trusted_ssids" + } +} diff --git a/app/src/main/java/com/itisuniqueofficial/lockify/features/lockscreen/ui/PasswordOverlayScreen.kt b/app/src/main/java/com/itisuniqueofficial/lockify/features/lockscreen/ui/PasswordOverlayScreen.kt index 168b088..ece63a9 100644 --- a/app/src/main/java/com/itisuniqueofficial/lockify/features/lockscreen/ui/PasswordOverlayScreen.kt +++ b/app/src/main/java/com/itisuniqueofficial/lockify/features/lockscreen/ui/PasswordOverlayScreen.kt @@ -16,6 +16,7 @@ import androidx.biometric.BiometricPrompt import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background @@ -47,6 +48,8 @@ import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope import com.itisuniqueofficial.lockify.R import com.itisuniqueofficial.lockify.core.ui.shapes +import com.itisuniqueofficial.lockify.core.security.BruteForceGuard +import com.itisuniqueofficial.lockify.features.intruder.IntruderCapture import com.itisuniqueofficial.lockify.core.utils.appLockRepository import com.itisuniqueofficial.lockify.core.utils.launchDeviceCredentialAuth import com.itisuniqueofficial.lockify.core.utils.vibrate @@ -66,6 +69,7 @@ class PasswordOverlayActivity : FragmentActivity() { private lateinit var biometricPrompt: BiometricPrompt private lateinit var promptInfo: BiometricPrompt.PromptInfo private lateinit var appLockRepository: AppLockRepository + private val bruteForceGuard by lazy { BruteForceGuard(applicationContext) } internal var lockedPackageNameFromIntent: String? = null internal var triggeringPackageNameFromIntent: String? = null @@ -167,6 +171,15 @@ class PasswordOverlayActivity : FragmentActivity() { finish() } + private fun showLockoutToast(remainingMs: Long) { + val seconds = ((remainingMs + 999L) / 1000L).toInt() + android.widget.Toast.makeText( + this, + getString(R.string.too_many_attempts_try_again_in, seconds), + android.widget.Toast.LENGTH_SHORT + ).show() + } + private fun loadAppNameAsync() { lifecycleScope.launch { val name = withContext(Dispatchers.IO) { @@ -185,29 +198,57 @@ class PasswordOverlayActivity : FragmentActivity() { private fun setupUI() { val onPinAttemptCallback = { pin: String -> - val isValid = appLockRepository.validatePassword(pin) - if (isValid) { - lockedPackageNameFromIntent?.let { pkgName -> - AppLockManager.unlockApp(pkgName) - finishAfterTransition() - } + val lockout = bruteForceGuard.remainingLockoutMs() + if (lockout > 0L) { + showLockoutToast(lockout) + false } else { - AppLockManager.updateState(AppLockManager.LockState.AUTH_FAILED) + val isValid = appLockRepository.validatePassword(pin) + if (isValid) { + bruteForceGuard.recordSuccess() + lockedPackageNameFromIntent?.let { pkgName -> + AppLockManager.unlockApp(pkgName) + finishAfterTransition() + } + } else { + val cooldown = bruteForceGuard.recordFailure() + if (cooldown > 0L) { + showLockoutToast(cooldown) + if (appLockRepository.isIntruderSelfieEnabled()) { + IntruderCapture(this).capture() + } + } + AppLockManager.updateState(AppLockManager.LockState.AUTH_FAILED) + } + isValid } - isValid } val onPatternAttemptCallback = { pattern: String -> - val isValid = appLockRepository.validatePattern(pattern) - if (isValid) { - lockedPackageNameFromIntent?.let { pkgName -> - AppLockManager.unlockApp(pkgName) - finishAfterTransition() - } + val lockout = bruteForceGuard.remainingLockoutMs() + if (lockout > 0L) { + showLockoutToast(lockout) + false } else { - AppLockManager.updateState(AppLockManager.LockState.AUTH_FAILED) + val isValid = appLockRepository.validatePattern(pattern) + if (isValid) { + bruteForceGuard.recordSuccess() + lockedPackageNameFromIntent?.let { pkgName -> + AppLockManager.unlockApp(pkgName) + finishAfterTransition() + } + } else { + val cooldown = bruteForceGuard.recordFailure() + if (cooldown > 0L) { + showLockoutToast(cooldown) + if (appLockRepository.isIntruderSelfieEnabled()) { + IntruderCapture(this).capture() + } + } + AppLockManager.updateState(AppLockManager.LockState.AUTH_FAILED) + } + isValid } - isValid } val onForgotPasswordCallback = { @@ -429,7 +470,7 @@ fun PasswordOverlayScreen( ) { val passwordState = remember { mutableStateOf("") } var showError by remember { mutableStateOf(false) } - val minLength = 4 + val minLength = remember { appLockRepository.getMinPinLength() } Box(modifier = Modifier.fillMaxSize()) { if (isLandscape) { @@ -470,6 +511,7 @@ fun PasswordOverlayScreen( PasswordIndicators( passwordLength = passwordState.value.length, + shake = showError, ) if (showError) { @@ -555,6 +597,7 @@ fun PasswordOverlayScreen( PasswordIndicators( passwordLength = passwordState.value.length, + shake = showError, ) if (showError) { @@ -610,8 +653,17 @@ fun PasswordOverlayScreen( @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalAnimationApi::class) @Composable fun PasswordIndicators( - passwordLength: Int + passwordLength: Int, + shake: Boolean = false ) { + val shakeOffset = remember { Animatable(0f) } + LaunchedEffect(shake) { + if (shake) { + for (dx in listOf(-24f, 24f, -18f, 18f, -10f, 10f, 0f)) { + shakeOffset.animateTo(dx, tween(40)) + } + } + } val windowInfo = LocalWindowInfo.current val configuration = LocalConfiguration.current @@ -657,6 +709,7 @@ fun PasswordIndicators( Box( modifier = Modifier + .graphicsLayer { translationX = shakeOffset.value } .width(maxWidth) .height(indicatorSize + 32.dp), contentAlignment = Alignment.Center @@ -840,6 +893,15 @@ fun KeypadSection( val disableHaptics = context.appLockRepository().shouldDisableHaptics() + // Digit layout: positions 0..8 fill the 3x3 grid, position 9 is the bottom-centre key. + // Shuffled per presentation when the anti-shoulder-surfing option is enabled. + val digits = remember { + if (context.appLockRepository().isScrambleKeypadEnabled()) + (0..9).map(Int::toString).shuffled() + else + listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0") + } + val onSpecialKeyClick = remember( passwordState, minLength, @@ -898,28 +960,28 @@ fun KeypadSection( } KeypadRow( disableHaptics = disableHaptics, - keys = listOf("1", "2", "3"), + keys = listOf(digits[0], digits[1], digits[2]), onKeyClick = onDigitKeyClick, buttonSize = buttonSize, buttonSpacing = buttonSpacing ) KeypadRow( disableHaptics = disableHaptics, - keys = listOf("4", "5", "6"), + keys = listOf(digits[3], digits[4], digits[5]), onKeyClick = onDigitKeyClick, buttonSize = buttonSize, buttonSpacing = buttonSpacing ) KeypadRow( disableHaptics = disableHaptics, - keys = listOf("7", "8", "9"), + keys = listOf(digits[6], digits[7], digits[8]), onKeyClick = onDigitKeyClick, buttonSize = buttonSize, buttonSpacing = buttonSpacing ) KeypadRow( disableHaptics = disableHaptics, - keys = listOf("backspace", "0", "proceed"), + keys = listOf("backspace", digits[9], "proceed"), icons = listOf(Backspace, null, Icons.AutoMirrored.Rounded.KeyboardArrowRight), onKeyClick = onSpecialKeyClick, buttonSize = buttonSize, @@ -951,7 +1013,6 @@ private fun handleKeypadSpecialButtonLogic( val appLockRepository = context.appLockRepository() when (key) { - "0" -> addDigitToPassword(passwordState, key, onPasswordChange) "backspace" -> { if (passwordState.value.isNotEmpty()) { passwordState.value = passwordState.value.dropLast(1) @@ -997,6 +1058,8 @@ private fun handleKeypadSpecialButtonLogic( } } } + + else -> addDigitToPassword(passwordState, key, onPasswordChange) } } diff --git a/app/src/main/java/com/itisuniqueofficial/lockify/features/notifications/LockifyNotificationService.kt b/app/src/main/java/com/itisuniqueofficial/lockify/features/notifications/LockifyNotificationService.kt new file mode 100644 index 0000000..65369ee --- /dev/null +++ b/app/src/main/java/com/itisuniqueofficial/lockify/features/notifications/LockifyNotificationService.kt @@ -0,0 +1,49 @@ +package com.itisuniqueofficial.lockify.features.notifications + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.service.notification.NotificationListenerService +import android.service.notification.StatusBarNotification +import com.itisuniqueofficial.lockify.data.repository.AppLockRepository + +/** + * Redacts notification content for locked apps: the original notification is cancelled and + * replaced with a generic "New notification" on Lockify's own channel, so the lock screen / + * shade never reveals sensitive previews. Requires the user to grant Notification Access. + */ +class LockifyNotificationService : NotificationListenerService() { + + private val repo by lazy { AppLockRepository(applicationContext) } + + override fun onNotificationPosted(sbn: StatusBarNotification) { + val pkg = sbn.packageName ?: return + if (pkg == packageName) return + if (!repo.isHideLockedAppNotifications() || !repo.isAppLocked(pkg)) return + + runCatching { + postRedacted(pkg, sbn.id) + cancelNotification(sbn.key) + } + } + + private fun postRedacted(pkg: String, id: Int) { + val nm = getSystemService(NotificationManager::class.java) ?: return + nm.createNotificationChannel( + NotificationChannel(CHANNEL, "Private notifications", NotificationManager.IMPORTANCE_DEFAULT) + ) + val label = runCatching { + packageManager.getApplicationLabel(packageManager.getApplicationInfo(pkg, 0)) + }.getOrDefault("App") + val notification = Notification.Builder(this, CHANNEL) + .setSmallIcon(android.R.drawable.ic_lock_idle_lock) + .setContentTitle(label) + .setContentText("New notification") + .build() + nm.notify(pkg.hashCode() + id, notification) + } + + private companion object { + const val CHANNEL = "lockify_private_notifications" + } +} diff --git a/app/src/main/java/com/itisuniqueofficial/lockify/features/profiles/ProfileRepository.kt b/app/src/main/java/com/itisuniqueofficial/lockify/features/profiles/ProfileRepository.kt new file mode 100644 index 0000000..4a8a0aa --- /dev/null +++ b/app/src/main/java/com/itisuniqueofficial/lockify/features/profiles/ProfileRepository.kt @@ -0,0 +1,78 @@ +package com.itisuniqueofficial.lockify.features.profiles + +import android.content.Context +import androidx.core.content.edit +import com.itisuniqueofficial.lockify.core.security.CredentialHasher +import org.json.JSONArray +import org.json.JSONObject + +/** + * A lock profile (e.g. Work, Personal, Kids). Each profile has its own hashed PIN and its own + * set of packages. When [whitelistMode] is true (child mode) every app is locked EXCEPT the + * listed packages; otherwise only the listed packages are locked. + */ +data class LockProfile( + val id: Long, + val name: String, + val pinHash: String, + val packages: Set, + val whitelistMode: Boolean = false +) { + /** Pure decision: should [pkg] be locked under this profile? */ + fun shouldLock(pkg: String): Boolean = + if (whitelistMode) pkg !in packages else pkg in packages + + fun verifyPin(input: String): Boolean = CredentialHasher.verify(input, pinHash) +} + +/** Persists profiles (and the active profile id) as JSON in SharedPreferences. */ +class ProfileRepository(context: Context) { + private val prefs = context.applicationContext + .getSharedPreferences("lock_profiles", Context.MODE_PRIVATE) + + fun getAll(): List { + val arr = JSONArray(prefs.getString(KEY_PROFILES, "[]")) + return (0 until arr.length()).map { i -> + val o = arr.getJSONObject(i) + val pkgs = o.getJSONArray("packages") + LockProfile( + o.getLong("id"), o.getString("name"), o.getString("pin"), + (0 until pkgs.length()).map { pkgs.getString(it) }.toSet(), + o.getBoolean("whitelist") + ) + } + } + + fun activeProfile(): LockProfile? { + val id = prefs.getLong(KEY_ACTIVE, -1L) + return getAll().firstOrNull { it.id == id } + } + + fun setActive(id: Long) = prefs.edit { putLong(KEY_ACTIVE, id) } + + /** Creates/updates a profile, hashing [rawPin]. */ + fun save(id: Long, name: String, rawPin: String, packages: Set, whitelistMode: Boolean) { + val profile = LockProfile(id, name, CredentialHasher.hash(rawPin), packages, whitelistMode) + persist(getAll().filter { it.id != id } + profile) + } + + fun delete(id: Long) = persist(getAll().filter { it.id != id }) + + private fun persist(list: List) { + val arr = JSONArray() + list.forEach { p -> + arr.put( + JSONObject() + .put("id", p.id).put("name", p.name).put("pin", p.pinHash) + .put("packages", JSONArray(p.packages.toList())) + .put("whitelist", p.whitelistMode) + ) + } + prefs.edit { putString(KEY_PROFILES, arr.toString()) } + } + + private companion object { + const val KEY_PROFILES = "profiles" + const val KEY_ACTIVE = "active_profile" + } +} diff --git a/app/src/main/java/com/itisuniqueofficial/lockify/features/quicktile/LockTileService.kt b/app/src/main/java/com/itisuniqueofficial/lockify/features/quicktile/LockTileService.kt new file mode 100644 index 0000000..78ba306 --- /dev/null +++ b/app/src/main/java/com/itisuniqueofficial/lockify/features/quicktile/LockTileService.kt @@ -0,0 +1,26 @@ +package com.itisuniqueofficial.lockify.features.quicktile + +import android.graphics.drawable.Icon +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import android.widget.Toast +import com.itisuniqueofficial.lockify.services.AppLockManager + +/** Tapping the tile clears all temporary unlocks, forcing protected apps to re-lock. */ +class LockTileService : TileService() { + + override fun onStartListening() { + super.onStartListening() + qsTile?.apply { + icon = Icon.createWithResource(this@LockTileService, android.R.drawable.ic_lock_idle_lock) + state = Tile.STATE_INACTIVE + updateTile() + } + } + + override fun onClick() { + super.onClick() + AppLockManager.clearAllUnlockState() + Toast.makeText(this, "Apps locked", Toast.LENGTH_SHORT).show() + } +} diff --git a/app/src/main/java/com/itisuniqueofficial/lockify/features/settings/ui/SettingsScreen.kt b/app/src/main/java/com/itisuniqueofficial/lockify/features/settings/ui/SettingsScreen.kt index 358d507..33b574a 100644 --- a/app/src/main/java/com/itisuniqueofficial/lockify/features/settings/ui/SettingsScreen.kt +++ b/app/src/main/java/com/itisuniqueofficial/lockify/features/settings/ui/SettingsScreen.kt @@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight import androidx.compose.material.icons.filled.* @@ -90,6 +92,16 @@ fun SettingsScreen( onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } var disableHapticFeedback by remember { mutableStateOf(appLockRepository.shouldDisableHaptics()) } + var scrambleKeypad by remember { mutableStateOf(appLockRepository.isScrambleKeypadEnabled()) } + var intruderSelfie by remember { mutableStateOf(appLockRepository.isIntruderSelfieEnabled()) } + var hideNotifications by remember { mutableStateOf(appLockRepository.isHideLockedAppNotifications()) } + var shakeToLock by remember { mutableStateOf(appLockRepository.isShakeToLockEnabled()) } + val cameraPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + intruderSelfie = granted + appLockRepository.setIntruderSelfieEnabled(granted) + } var loggingEnabled by remember { mutableStateOf(appLockRepository.isLoggingEnabled()) } var showPermissionDialog by remember { mutableStateOf(false) } @@ -284,6 +296,17 @@ fun SettingsScreen( appLockRepository.setDisableHaptics(isChecked) } ), + ToggleSettingItem( + icon = Icons.Default.Dialpad, + title = stringResource(R.string.settings_screen_scramble_keypad_title), + subtitle = stringResource(R.string.settings_screen_scramble_keypad_desc), + checked = scrambleKeypad, + enabled = true, + onCheckedChange = { isChecked -> + scrambleKeypad = isChecked + appLockRepository.setScrambleKeypadEnabled(isChecked) + } + ), ToggleSettingItem( icon = Icons.Default.ShieldMoon, title = stringResource(R.string.settings_screen_auto_unlock_title), @@ -312,6 +335,61 @@ fun SettingsScreen( subtitle = stringResource(R.string.settings_screen_change_pin_desc), onClick = { navController.navigate(Screen.ChangePassword.route) } ), + ActionSettingItem( + icon = Icons.Default.Folder, + title = stringResource(R.string.settings_screen_vault_title), + subtitle = stringResource(R.string.settings_screen_vault_desc), + onClick = { navController.navigate(Screen.Vault.route) } + ), + ActionSettingItem( + icon = Icons.Default.BarChart, + title = stringResource(R.string.settings_screen_stats_title), + subtitle = stringResource(R.string.settings_screen_stats_desc), + onClick = { navController.navigate(Screen.Stats.route) } + ), + ToggleSettingItem( + icon = Icons.Default.CameraAlt, + title = stringResource(R.string.settings_screen_intruder_title), + subtitle = stringResource(R.string.settings_screen_intruder_desc), + checked = intruderSelfie, + enabled = true, + onCheckedChange = { isChecked -> + if (isChecked) { + cameraPermissionLauncher.launch(android.Manifest.permission.CAMERA) + } else { + intruderSelfie = false + appLockRepository.setIntruderSelfieEnabled(false) + } + } + ), + ToggleSettingItem( + icon = Icons.Default.NotificationsOff, + title = stringResource(R.string.settings_screen_hide_notifications_title), + subtitle = stringResource(R.string.settings_screen_hide_notifications_desc), + checked = hideNotifications, + enabled = true, + onCheckedChange = { isChecked -> + hideNotifications = isChecked + appLockRepository.setHideLockedAppNotifications(isChecked) + if (isChecked) { + context.startActivity( + android.content.Intent("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS") + .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } + } + ), + ToggleSettingItem( + icon = Icons.Default.Vibration, + title = stringResource(R.string.settings_screen_shake_to_lock_title), + subtitle = stringResource(R.string.settings_screen_shake_to_lock_desc), + checked = shakeToLock, + enabled = true, + onCheckedChange = { isChecked -> + shakeToLock = isChecked + appLockRepository.setShakeToLockEnabled(isChecked) + } + ), ActionSettingItem( icon = Timer, title = stringResource(R.string.settings_screen_unlock_duration_title), diff --git a/app/src/main/java/com/itisuniqueofficial/lockify/features/shake/ShakeDetector.kt b/app/src/main/java/com/itisuniqueofficial/lockify/features/shake/ShakeDetector.kt new file mode 100644 index 0000000..2db3fae --- /dev/null +++ b/app/src/main/java/com/itisuniqueofficial/lockify/features/shake/ShakeDetector.kt @@ -0,0 +1,45 @@ +package com.itisuniqueofficial.lockify.features.shake + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import kotlin.math.sqrt + +/** + * Detects a deliberate shake from the accelerometer and invokes [onShake]. Register with + * [start] from a running service and release with [stop]. + */ +class ShakeDetector(context: Context, private val onShake: () -> Unit) : SensorEventListener { + + private val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager + private val accelerometer: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + private var lastShakeAt = 0L + + fun start() { + accelerometer?.let { sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_UI) } + } + + fun stop() = sensorManager.unregisterListener(this) + + override fun onSensorChanged(event: SensorEvent) { + val gForce = sqrt( + (event.values[0] * event.values[0] + + event.values[1] * event.values[1] + + event.values[2] * event.values[2]).toDouble() + ) / SensorManager.GRAVITY_EARTH + val now = System.currentTimeMillis() + if (gForce > SHAKE_THRESHOLD_G && now - lastShakeAt > SHAKE_COOLDOWN_MS) { + lastShakeAt = now + onShake() + } + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} + + private companion object { + const val SHAKE_THRESHOLD_G = 2.7 + const val SHAKE_COOLDOWN_MS = 1000L + } +} diff --git a/app/src/main/java/com/itisuniqueofficial/lockify/features/stats/UsageStatsProvider.kt b/app/src/main/java/com/itisuniqueofficial/lockify/features/stats/UsageStatsProvider.kt new file mode 100644 index 0000000..94aeacb --- /dev/null +++ b/app/src/main/java/com/itisuniqueofficial/lockify/features/stats/UsageStatsProvider.kt @@ -0,0 +1,22 @@ +package com.itisuniqueofficial.lockify.features.stats + +import android.app.usage.UsageStatsManager +import android.content.Context + +data class AppUsage(val packageName: String, val totalTimeMs: Long) + +/** Aggregates per-app foreground time from [UsageStatsManager] (needs Usage Access). */ +object UsageStatsProvider { + fun topApps(context: Context, days: Int = 7, limit: Int = 10): List { + val usm = context.getSystemService(UsageStatsManager::class.java) ?: return emptyList() + val end = System.currentTimeMillis() + val start = end - days * 24L * 60L * 60L * 1000L + val stats = usm.queryUsageStats(UsageStatsManager.INTERVAL_BEST, start, end) ?: return emptyList() + return stats + .filter { it.totalTimeInForeground > 0 } + .groupBy { it.packageName } + .map { (pkg, list) -> AppUsage(pkg, list.sumOf { it.totalTimeInForeground }) } + .sortedByDescending { it.totalTimeMs } + .take(limit) + } +} diff --git a/app/src/main/java/com/itisuniqueofficial/lockify/features/stats/ui/StatsScreen.kt b/app/src/main/java/com/itisuniqueofficial/lockify/features/stats/ui/StatsScreen.kt new file mode 100644 index 0000000..8b43bc8 --- /dev/null +++ b/app/src/main/java/com/itisuniqueofficial/lockify/features/stats/ui/StatsScreen.kt @@ -0,0 +1,91 @@ +package com.itisuniqueofficial.lockify.features.stats.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import com.itisuniqueofficial.lockify.features.stats.AppUsage +import com.itisuniqueofficial.lockify.features.stats.UsageStatsProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StatsScreen(navController: NavHostController) { + val context = LocalContext.current + val usage by produceState(initialValue = emptyList()) { + value = withContext(Dispatchers.IO) { UsageStatsProvider.topApps(context) } + } + val barColor = MaterialTheme.colorScheme.primary + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Usage (7 days)") }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + Column(modifier = Modifier.fillMaxSize().padding(padding)) { + val max = (usage.maxOfOrNull { it.totalTimeMs } ?: 1L).coerceAtLeast(1L) + Canvas( + modifier = Modifier.fillMaxWidth().height(180.dp).padding(16.dp) + ) { + if (usage.isEmpty()) return@Canvas + val gap = 8.dp.toPx() + val barWidth = (size.width - gap * (usage.size - 1)) / usage.size + usage.forEachIndexed { i, u -> + val h = size.height * (u.totalTimeMs.toFloat() / max) + drawRect( + color = barColor, + topLeft = Offset(i * (barWidth + gap), size.height - h), + size = Size(barWidth, h) + ) + } + } + LazyColumn { + items(usage, key = { it.packageName }) { u -> + ListItem( + headlineContent = { Text(u.packageName) }, + supportingContent = { Text(formatDuration(u.totalTimeMs)) } + ) + } + } + } + } +} + +private fun formatDuration(ms: Long): String { + val minutes = ms / 60000 + return if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m" +} diff --git a/app/src/main/java/com/itisuniqueofficial/lockify/features/vault/VaultRepository.kt b/app/src/main/java/com/itisuniqueofficial/lockify/features/vault/VaultRepository.kt new file mode 100644 index 0000000..6c3c377 --- /dev/null +++ b/app/src/main/java/com/itisuniqueofficial/lockify/features/vault/VaultRepository.kt @@ -0,0 +1,66 @@ +package com.itisuniqueofficial.lockify.features.vault + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import com.itisuniqueofficial.lockify.core.security.VaultCipher +import java.io.File + +/** Stores user files encrypted with [VaultCipher] inside app-private storage. */ +class VaultRepository(private val context: Context) { + + private val dir = File(context.filesDir, "vault").apply { mkdirs() } + + data class VaultEntry(val file: File, val displayName: String, val sizeBytes: Long) + + fun list(): List = + (dir.listFiles { f -> f.extension == EXT } ?: emptyArray()) + .map { VaultEntry(it, it.name.substringAfter('_').removeSuffix(".$EXT"), it.length()) } + .sortedByDescending { it.file.lastModified() } + + /** Encrypts the content behind [source] into the vault. */ + fun import(source: Uri) { + val name = queryName(source) + val target = File(dir, "${System.currentTimeMillis()}_$name.$EXT") + context.contentResolver.openInputStream(source)?.use { input -> + target.outputStream().use { out -> VaultCipher.encrypt(input, out) } + } ?: error("Unable to open source") + } + + /** Decrypts [entry] to the location behind [dest]. */ + fun export(entry: File, dest: Uri) { + entry.inputStream().use { input -> + context.contentResolver.openOutputStream(dest)?.use { out -> + VaultCipher.decrypt(input, out) + } ?: error("Unable to open destination") + } + } + + /** Overwrites the ciphertext with zeros before unlinking (best-effort secure delete). */ + fun delete(entry: File) { + runCatching { + val len = entry.length() + entry.outputStream().use { out -> + val buf = ByteArray(8192) + var written = 0L + while (written < len) { + val n = minOf(buf.size.toLong(), len - written).toInt() + out.write(buf, 0, n) + written += n + } + out.flush() + } + } + entry.delete() + } + + private fun queryName(uri: Uri): String { + context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { c -> if (c.moveToFirst()) c.getString(0)?.let { return it } } + return uri.lastPathSegment?.substringAfterLast('/') ?: "file" + } + + companion object { + private const val EXT = "enc" + } +} diff --git a/app/src/main/java/com/itisuniqueofficial/lockify/features/vault/ui/VaultScreen.kt b/app/src/main/java/com/itisuniqueofficial/lockify/features/vault/ui/VaultScreen.kt new file mode 100644 index 0000000..4f92e85 --- /dev/null +++ b/app/src/main/java/com/itisuniqueofficial/lockify/features/vault/ui/VaultScreen.kt @@ -0,0 +1,107 @@ +package com.itisuniqueofficial.lockify.features.vault.ui + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Download +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import com.itisuniqueofficial.lockify.features.vault.VaultRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VaultScreen(navController: NavHostController) { + val context = LocalContext.current + val repo = remember { VaultRepository(context) } + val scope = rememberCoroutineScope() + var entries by remember { mutableStateOf(repo.list()) } + var pendingExport by remember { mutableStateOf(null) } + + val importLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent() + ) { uri -> + if (uri != null) scope.launch { + withContext(Dispatchers.IO) { repo.import(uri) } + entries = repo.list() + } + } + + val exportLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/octet-stream") + ) { uri -> + val src = pendingExport + if (uri != null && src != null) scope.launch { + withContext(Dispatchers.IO) { repo.export(src, uri) } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Vault") }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = { importLauncher.launch("*/*") }) { + Icon(Icons.Default.Add, contentDescription = "Import file") + } + } + ) + } + ) { padding -> + Column(modifier = Modifier.fillMaxSize().padding(padding)) { + LazyColumn { + items(entries, key = { it.file.path }) { entry -> + ListItem( + headlineContent = { Text(entry.displayName) }, + supportingContent = { Text("${entry.sizeBytes} bytes") }, + trailingContent = { + androidx.compose.foundation.layout.Row { + IconButton(onClick = { + pendingExport = entry.file + exportLauncher.launch(entry.displayName) + }) { Icon(Icons.Default.Download, contentDescription = "Export") } + IconButton(onClick = { + scope.launch { + withContext(Dispatchers.IO) { repo.delete(entry.file) } + entries = repo.list() + } + }) { Icon(Icons.Default.Delete, contentDescription = "Delete") } + } + } + ) + } + } + } + } +} diff --git a/app/src/main/java/com/itisuniqueofficial/lockify/features/widget/LockWidgetProvider.kt b/app/src/main/java/com/itisuniqueofficial/lockify/features/widget/LockWidgetProvider.kt new file mode 100644 index 0000000..086397f --- /dev/null +++ b/app/src/main/java/com/itisuniqueofficial/lockify/features/widget/LockWidgetProvider.kt @@ -0,0 +1,41 @@ +package com.itisuniqueofficial.lockify.features.widget + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.content.Intent +import android.widget.RemoteViews +import android.widget.Toast +import com.itisuniqueofficial.lockify.R +import com.itisuniqueofficial.lockify.services.AppLockManager + +/** A home-screen widget whose single button immediately re-locks all protected apps. */ +class LockWidgetProvider : AppWidgetProvider() { + + override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) { + val pending = PendingIntent.getBroadcast( + context, 0, + Intent(context, LockWidgetProvider::class.java).setAction(ACTION_LOCK_ALL), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + ids.forEach { id -> + val views = RemoteViews(context.packageName, R.layout.widget_lock).apply { + setOnClickPendingIntent(R.id.widget_lock_button, pending) + } + manager.updateAppWidget(id, views) + } + } + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + if (intent.action == ACTION_LOCK_ALL) { + AppLockManager.clearAllUnlockState() + Toast.makeText(context, "Apps locked", Toast.LENGTH_SHORT).show() + } + } + + companion object { + const val ACTION_LOCK_ALL = "com.itisuniqueofficial.lockify.LOCK_ALL" + } +} diff --git a/app/src/main/java/com/itisuniqueofficial/lockify/services/ExperimentalAppLockService.kt b/app/src/main/java/com/itisuniqueofficial/lockify/services/ExperimentalAppLockService.kt index 916d59b..e2b8b0a 100644 --- a/app/src/main/java/com/itisuniqueofficial/lockify/services/ExperimentalAppLockService.kt +++ b/app/src/main/java/com/itisuniqueofficial/lockify/services/ExperimentalAppLockService.kt @@ -42,6 +42,7 @@ class ExperimentalAppLockService : Service() { private var timer: Timer? = null private var previousForegroundPackage = "" private var screenReceiverRegistered = false + private var shakeDetector: com.itisuniqueofficial.lockify.features.shake.ShakeDetector? = null // Cache keyboard packages to avoid querying InputMethodManager on every 100ms tick private var keyboardPackages: List = emptyList() @@ -99,11 +100,19 @@ class ExperimentalAppLockService : Service() { startMonitoringTimer() startForegroundService() + if (appLockRepository.isShakeToLockEnabled() && shakeDetector == null) { + shakeDetector = com.itisuniqueofficial.lockify.features.shake.ShakeDetector(this) { + AppLockManager.clearAllUnlockState() + }.also { it.start() } + } + return START_STICKY } override fun onDestroy() { timer?.cancel() + shakeDetector?.stop() + shakeDetector = null LogUtils.d(TAG, "ExperimentalAppLockService destroyed.") if (screenReceiverRegistered) { diff --git a/app/src/main/res/layout/widget_lock.xml b/app/src/main/res/layout/widget_lock.xml new file mode 100644 index 0000000..ea5d2fd --- /dev/null +++ b/app/src/main/res/layout/widget_lock.xml @@ -0,0 +1,13 @@ + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6f613d8..270c6b2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,11 +1,13 @@ Lockify + Lock all Prevents unauthorized people from uninstalling the app. Allows Lockify to detect when protected apps are opened and show password verification. Enter your password to disable admin permission. Incorrect PIN. Please try again. + Too many attempts. Try again in %1$d s. Password verified, you can now disable admin permission Choose App Detection Method Select how you want Lockify to detect when protected apps are launched. @@ -162,6 +164,18 @@ Make sure you have Shizuku installed and enabled via ADB. Tap \'Next\' to grant Preview hidden for privacy\nLocked by Lockify Haptic Feedback Disable haptic feedback on key presses + Scramble PIN keypad + Randomise digit positions to prevent shoulder-surfing + Hidden vault + Encrypt photos, videos and files (AES-256) + Usage statistics + See which apps you use most over the last 7 days + Intruder detection + Silently capture a front-camera photo after repeated failed unlocks (stored encrypted on this device only) + Hide notifications for locked apps + Replace previews from locked apps with a generic message (needs Notification Access) + Shake to lock + Shake the device to immediately re-lock all apps (Usage Stats backend) Backend Implementation Select the method Lockify uses to detect foreground apps Accessibility Service diff --git a/app/src/main/res/xml/lock_widget_info.xml b/app/src/main/res/xml/lock_widget_info.xml new file mode 100644 index 0000000..cc8ce12 --- /dev/null +++ b/app/src/main/res/xml/lock_widget_info.xml @@ -0,0 +1,8 @@ + + diff --git a/app/src/test/java/com/itisuniqueofficial/lockify/core/location/GeoFenceTest.kt b/app/src/test/java/com/itisuniqueofficial/lockify/core/location/GeoFenceTest.kt new file mode 100644 index 0000000..d5e2e6a --- /dev/null +++ b/app/src/test/java/com/itisuniqueofficial/lockify/core/location/GeoFenceTest.kt @@ -0,0 +1,21 @@ +package com.itisuniqueofficial.lockify.core.location + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class GeoFenceTest { + @Test + fun distanceBetweenKnownPointsIsApproximatelyCorrect() { + // ~1.11 km per 0.01 degree of latitude near the equator. + val d = GeoFence.distanceMeters(0.0, 0.0, 0.01, 0.0) + assertTrue("expected ~1112m but was $d", d in 1100.0..1125.0) + } + + @Test + fun withinAndOutsideRadius() { + val home = LocationRule(1, "home", 12.9716, 77.5946, radiusMeters = 150f) + assertTrue(GeoFence.isWithin(home, 12.9716, 77.5946)) // exact centre + assertFalse(GeoFence.isWithin(home, 13.0716, 77.5946)) // ~11 km north + } +} diff --git a/app/src/test/java/com/itisuniqueofficial/lockify/core/schedule/ScheduleEvaluatorTest.kt b/app/src/test/java/com/itisuniqueofficial/lockify/core/schedule/ScheduleEvaluatorTest.kt new file mode 100644 index 0000000..dfe525e --- /dev/null +++ b/app/src/test/java/com/itisuniqueofficial/lockify/core/schedule/ScheduleEvaluatorTest.kt @@ -0,0 +1,32 @@ +package com.itisuniqueofficial.lockify.core.schedule + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ScheduleEvaluatorTest { + private fun schedule(days: Int, start: Int, end: Int, enabled: Boolean = true) = + LockSchedule(1, "s", days, start, end, enabled) + + @Test + fun activeInsideDaytimeWindowOnSelectedDay() { + val mondayWork = schedule(days = 1, start = 9 * 60, end = 17 * 60) // Mon, 09:00-17:00 + assertTrue(ScheduleEvaluator.isActive(mondayWork, isoDayOfWeek = 1, minuteOfDay = 10 * 60)) + assertFalse(ScheduleEvaluator.isActive(mondayWork, isoDayOfWeek = 1, minuteOfDay = 8 * 60)) + assertFalse(ScheduleEvaluator.isActive(mondayWork, isoDayOfWeek = 2, minuteOfDay = 10 * 60)) + } + + @Test + fun overnightWindowWrapsMidnight() { + val bedtime = schedule(days = 127, start = 22 * 60, end = 6 * 60) // every day 22:00-06:00 + assertTrue(ScheduleEvaluator.isActive(bedtime, isoDayOfWeek = 3, minuteOfDay = 23 * 60)) + assertTrue(ScheduleEvaluator.isActive(bedtime, isoDayOfWeek = 3, minuteOfDay = 2 * 60)) + assertFalse(ScheduleEvaluator.isActive(bedtime, isoDayOfWeek = 3, minuteOfDay = 12 * 60)) + } + + @Test + fun disabledScheduleNeverActive() { + val s = schedule(days = 127, start = 0, end = 1439, enabled = false) + assertFalse(ScheduleEvaluator.isActive(s, isoDayOfWeek = 1, minuteOfDay = 600)) + } +} diff --git a/app/src/test/java/com/itisuniqueofficial/lockify/core/security/BruteForceGuardTest.kt b/app/src/test/java/com/itisuniqueofficial/lockify/core/security/BruteForceGuardTest.kt new file mode 100644 index 0000000..3d3fadd --- /dev/null +++ b/app/src/test/java/com/itisuniqueofficial/lockify/core/security/BruteForceGuardTest.kt @@ -0,0 +1,22 @@ +package com.itisuniqueofficial.lockify.core.security + +import org.junit.Assert.assertEquals +import org.junit.Test + +class BruteForceGuardTest { + @Test + fun noCooldownBelowThreshold() { + assertEquals(0L, BruteForceGuard.cooldownMsForAttempts(0)) + assertEquals(0L, BruteForceGuard.cooldownMsForAttempts(2)) + } + + @Test + fun tieredCooldownsMatchSpec() { + assertEquals(30_000L, BruteForceGuard.cooldownMsForAttempts(3)) + assertEquals(30_000L, BruteForceGuard.cooldownMsForAttempts(4)) + assertEquals(120_000L, BruteForceGuard.cooldownMsForAttempts(5)) + assertEquals(120_000L, BruteForceGuard.cooldownMsForAttempts(9)) + assertEquals(600_000L, BruteForceGuard.cooldownMsForAttempts(10)) + assertEquals(600_000L, BruteForceGuard.cooldownMsForAttempts(25)) + } +} diff --git a/app/src/test/java/com/itisuniqueofficial/lockify/features/backup/BackupCryptoTest.kt b/app/src/test/java/com/itisuniqueofficial/lockify/features/backup/BackupCryptoTest.kt new file mode 100644 index 0000000..e169ecc --- /dev/null +++ b/app/src/test/java/com/itisuniqueofficial/lockify/features/backup/BackupCryptoTest.kt @@ -0,0 +1,22 @@ +package com.itisuniqueofficial.lockify.features.backup + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class BackupCryptoTest { + @Test + fun roundTripWithCorrectPassword() { + val data = "{\"lockedApps\":[\"com.bank\"]}".toByteArray() + val blob = BackupCrypto.encrypt(data, "hunter2".toCharArray()) + assertArrayEquals(data, BackupCrypto.decrypt(blob, "hunter2".toCharArray())) + } + + @Test + fun wrongPasswordIsRejected() { + val blob = BackupCrypto.encrypt("secret".toByteArray(), "right".toCharArray()) + assertThrows(Exception::class.java) { + BackupCrypto.decrypt(blob, "wrong".toCharArray()) + } + } +} diff --git a/app/src/test/java/com/itisuniqueofficial/lockify/features/profiles/LockProfileTest.kt b/app/src/test/java/com/itisuniqueofficial/lockify/features/profiles/LockProfileTest.kt new file mode 100644 index 0000000..80433a7 --- /dev/null +++ b/app/src/test/java/com/itisuniqueofficial/lockify/features/profiles/LockProfileTest.kt @@ -0,0 +1,21 @@ +package com.itisuniqueofficial.lockify.features.profiles + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LockProfileTest { + @Test + fun normalModeLocksOnlyListedPackages() { + val p = LockProfile(1, "Personal", "", setOf("com.bank"), whitelistMode = false) + assertTrue(p.shouldLock("com.bank")) + assertFalse(p.shouldLock("com.calculator")) + } + + @Test + fun childModeLocksEverythingExceptWhitelist() { + val p = LockProfile(2, "Kids", "", setOf("com.kidsgame", "com.draw"), whitelistMode = true) + assertFalse(p.shouldLock("com.kidsgame")) + assertTrue(p.shouldLock("com.bank")) + } +} diff --git a/web-app/changelog.html b/web-app/changelog.html index e1d0dd1..00dfa5d 100644 --- a/web-app/changelog.html +++ b/web-app/changelog.html @@ -30,6 +30,62 @@

Changelog

+ +
+
v1.1.0 — Vault, Schedules & Advanced Security
+
2026 · Upcoming
+
+
+ Added + Hidden Vault — encrypt photos, videos, and files with AES-256 (hardware-backed key) +
+
+ Added + Scheduled Locking — auto-lock selected apps by time and day of week +
+
+ Added + Intruder Detection — silent front-camera capture after repeated failed unlocks, stored encrypted on-device only +
+
+ Added + Location & Wi-Fi Rules — relax locking on trusted networks or at trusted places +
+
+ Added + Notification Privacy — hide notification content for locked apps +
+
+ Added + Usage Statistics — 7-day app usage insights with charts +
+
+ Added + Lock Profiles & Child Mode — multiple profiles, each with its own PIN +
+
+ Added + Encrypted Backup & Restore — password-protected local backups +
+
+ Added + Quick Settings tile, home-screen widget, and shake-to-lock +
+
+ Security + Brute-force protection — escalating cooldown after repeated wrong attempts +
+
+ Security + Scrambled PIN keypad to resist shoulder-surfing +
+
+ Improved + Wrong-PIN shake animation and configurable PIN length +
+
+
+
v1.0.3 — Stability & Privacy Improvements
diff --git a/web-app/features.html b/web-app/features.html index 7a5098f..238b58f 100644 --- a/web-app/features.html +++ b/web-app/features.html @@ -97,6 +97,72 @@

No Data Collection

+
+
+
+

Advanced Privacy & Security

+

New in v1.1.0 — deeper protection, more control. (Upcoming)

+
+
+
+ +

Hidden Vault

+

Encrypt photos, videos, and files with AES-256-GCM using a hardware-backed key from the Android KeyStore. Files are streamed, so even large media stays fast and private.

+
+
+ +

Scheduled Locking

+

Automatically lock selected apps by time of day and day of week — perfect for study, work, or bedtime routines.

+
+
+ +

Intruder Detection

+

Silently capture a front-camera photo after repeated failed unlocks. Photos are encrypted and stored on your device only — never uploaded anywhere.

+
+
+ +

Location & Wi-Fi Rules

+

Relax locking automatically on trusted Wi-Fi networks or inside trusted places, and tighten it everywhere else.

+
+
+ +

Notification Privacy

+

Hide sensitive notification content from locked apps, replacing previews with a generic message on the lock screen and shade.

+
+
+ +

Usage Statistics

+

See which apps you use most over the last 7 days with clean, on-device charts — no tracking, no cloud.

+
+
+ +

Profiles & Child Mode

+

Switch between lock profiles — Work, Personal, Kids — each with its own PIN. Child mode locks everything except a whitelist.

+
+
+ +

Encrypted Backup & Restore

+

Export a password-protected backup of your lock configuration (PBKDF2 + AES-256) and restore it on any device.

+
+
+ +

Brute-Force Protection

+

An escalating cooldown after repeated wrong attempts (3 → 30s, 5 → 2m, 10 → 10m) stops guess-and-retry attacks.

+
+
+ +

Scrambled Keypad

+

Randomise the PIN keypad layout to defeat shoulder-surfing, with a wrong-PIN shake and configurable PIN length.

+
+
+ +

Quick Tile, Widget & Shake-to-Lock

+

Re-lock everything instantly from a Quick Settings tile, a home-screen widget, or a quick shake of your phone.

+
+
+
+
+
diff --git a/web-app/updates.html b/web-app/updates.html index c5f5b00..be2c0e8 100644 --- a/web-app/updates.html +++ b/web-app/updates.html @@ -19,6 +19,18 @@

Updates & News

+
+
+ Upcoming + 2026 +
+

Lockify v1.1.0 — Vault, Schedules & Advanced Security

+

+ The biggest update yet adds a hidden AES-256 vault, scheduled locking, on-device intruder detection, location & Wi-Fi rules, notification privacy, usage statistics, lock profiles, encrypted backups, a Quick Settings tile, a home-screen widget, shake-to-lock, brute-force cooldowns, and a scrambled PIN keypad. +

+ View Changelog +
+
Release