Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<br/>

## Play Store
Expand Down
18 changes: 18 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
40 changes: 40 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@
tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-feature
android:name="android.hardware.camera.front"
android:required="false" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
Expand Down Expand Up @@ -117,6 +124,16 @@
android:value="accessibility" />
</service>

<service
android:name=".features.notifications.LockifyNotificationService"
android:exported="false"
android:label="@string/app_name"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>

<receiver
android:name=".core.broadcast.DeviceAdmin"
android:description="@string/device_admin_description"
Expand Down Expand Up @@ -155,5 +172,28 @@
android:resource="@xml/file_paths" />
</provider>

<service
android:name=".features.quicktile.LockTileService"
android:exported="true"
android:icon="@android:drawable/ic_lock_idle_lock"
android:label="@string/app_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>

<receiver
android:name=".features.widget.LockWidgetProvider"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="com.itisuniqueofficial.lockify.LOCK_ALL" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/lock_widget_info" />
</receiver>

</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -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<LocationRule> {
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<LocationRule>) {
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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Original file line number Diff line number Diff line change
@@ -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<LockSchedule> {
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<LockSchedule>) {
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"
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading
Loading