This file tells an AI coding agent how to build and maintain this project. Read it fully before writing code. When a rule here conflicts with a guess, follow the rule. When something is not covered, ask or leave a TODO(decision) comment; do not invent.
An Android-only kiosk app that records employee time-in / time-out punches using face recognition.
Core flow
Non-goals: iOS, cloud face APIs, paid SDKs, computing payroll hours on the device.
| Area | Decision |
|---|---|
| Language / platform | Kotlin, Android only |
| UI | Jetpack Compose, MVVM |
| Camera | CameraX (ImageAnalysis) |
| Face detection | Google ML Kit Face Detection (on-device) |
| Face recognition | MobileFaceNet / ArcFace-style embedding model on LiteRT (TFLite) |
| Anti-spoofing | Self-built: MiniFASNet passive model + random active challenge |
| Cost | Free / open-source only. No paid SDKs, no cloud face APIs |
| Phone database | Room (SQLite). MySQL cannot run on the phone |
| Server database | MySQL / MariaDB behind a PHP API |
| Sync | WorkManager, idempotent batches |
| DI / async | Hilt (or Koin), Kotlin Coroutines + Flow |
"Save in the phone" means a local SQLite (Room) database that mirrors the data model of the server's MySQL database. They are two databases kept in sync, not one.
app/
camera/ CameraX setup, frame analyzer, lifecycle
face/ detector wrapper, quality gate, embedder, matcher
liveness/ passive model (MiniFASNet), challenge manager, consistency check
data/
local/ Room entities, DAOs, migrations
remote/ Retrofit API, DTOs, auth interceptor
repo/ repositories (single source of truth = Room)
sync/ WorkManager workers, sync state, conflict rules
ui/ register, punch, admin, settings screens (Compose)
di/ Hilt modules
util/ time provider, crypto, logging
Rules
Dispatchers.Default). Never block ImageAnalysis; use STRATEGY_KEEP_ONLY_LATEST.TimeProvider so it can be tested.ENROLL_INTRA_MIN).DUPLICATE_THRESHOLD, block enrollment and flag it.face_templates) with model_version, mark sync_status = PENDING.Re-enrollment: revoke old templates (revoked_at), never hard-delete locally until synced.
best_score >= MATCH_THRESHOLD and best_score - runner_up_score >= MATCH_MARGIN.DEBOUNCE_SECONDS.The device records raw events only. It does not compute hours, lateness, or undertime, and it does not make the final IN/OUT decision. It may show a suggested IN/OUT label as a hint.
Reject a frame before matching unless all pass (tune the constants in one FaceConfig object):
MIN_FACE_RATIO of frame widthMAX_YAW, MAX_PITCH, MAX_ROLLMIN_EYE_OPEN)MIN_SHARPNESS[MIN_LUMA, MAX_LUMA]Use all layers; no single check is trusted alone.
LIVENESS_MIN.Honest limits: RGB-only anti-spoofing reduces risk but is not perfect against high-quality masks or 3D attacks. Always store an audit snapshot so HR can review disputes.
Licensing: before bundling any pretrained model, check its license (code and weights separately). Some popular weights are non-commercial. Record each model's name, source, license, and model_version in docs/MODELS.md. If a license is unclear, stop and ask.
@Entity(tableName = "employees")
data class EmployeeEntity(
@PrimaryKey val employeeId: String,
val name: String,
val isActive: Boolean,
val updatedAt: Long // epoch ms
)
@Entity(tableName = "face_templates")
data class FaceTemplateEntity(
@PrimaryKey val templateId: String, // UUID
val employeeId: String,
val embedding: ByteArray, // encrypted at rest
val modelVersion: String,
val qualityScore: Float,
val createdAt: Long,
val revokedAt: Long?,
val syncStatus: Int // 0 pending, 1 synced
)
@Entity(
tableName = "punches",
indices = [Index("syncStatus", "punchedAtEpochMs"), Index("employeeId", "punchedAtEpochMs")]
)
data class PunchEntity(
@PrimaryKey val punchUuid: String, // generated on device, used for idempotency
val employeeId: String,
val deviceId: String,
val punchedAtEpochMs: Long, // wall clock (UTC epoch ms)
val tzOffsetMinutes: Int, // device tz offset at punch time
val elapsedRealtimeMs: Long, // monotonic clock, for drift detection
val suggestedType: String?, // "IN" | "OUT" hint only
val matchScore: Float,
val runnerUpScore: Float,
val livenessScore: Float,
val snapshotPath: String?,
val source: String, // "FACE" | "FALLBACK_PIN" | "ADMIN"
val syncStatus: Int, // 0 pending, 1 synced, 2 rejected
val syncedAt: Long?,
val rejectReason: String?
)
Storage rules: encrypt embeddings (Android Keystore-backed key, or SQLCipher). Store snapshots in app-private storage and purge them after sync + retention period.
Align with the existing timekeeping tables. Before creating tables, inspect the existing
tk_tables and reuse naming and conventions. Map accepted punches into the existing raw-log table instead of duplicating logic. Mark unknownsTODO(decision).
CREATE TABLE kiosk_devices (
device_id VARCHAR(64) PRIMARY KEY,
name VARCHAR(100) NOT NULL,
token_hash CHAR(64) NOT NULL,
last_seen_at DATETIME NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1
) ENGINE=InnoDB;
CREATE TABLE face_templates (
template_id CHAR(36) PRIMARY KEY,
employee_id VARCHAR(32) NOT NULL,
embedding BLOB NOT NULL,
model_version VARCHAR(32) NOT NULL,
quality_score FLOAT NULL,
created_at DATETIME NOT NULL,
revoked_at DATETIME NULL,
INDEX idx_emp (employee_id),
INDEX idx_model (model_version)
) ENGINE=InnoDB;
CREATE TABLE face_punches (
punch_uuid CHAR(36) PRIMARY KEY, -- idempotency key
employee_id VARCHAR(32) NOT NULL,
device_id VARCHAR(64) NOT NULL,
punched_at_utc DATETIME(3) NOT NULL,
tz_offset_minutes SMALLINT NOT NULL,
server_received_at DATETIME(3) NOT NULL,
clock_drift_ms BIGINT NULL,
suggested_type ENUM('IN','OUT') NULL,
match_score FLOAT NULL,
runner_up_score FLOAT NULL,
liveness_score FLOAT NULL,
source ENUM('FACE','FALLBACK_PIN','ADMIN') NOT NULL,
snapshot_ref VARCHAR(255) NULL,
status ENUM('ACCEPTED','FLAGGED','REJECTED') NOT NULL DEFAULT 'ACCEPTED',
INDEX idx_emp_time (employee_id, punched_at_utc),
INDEX idx_device (device_id, punched_at_utc)
) ENGINE=InnoDB;
Time rules
clock_drift_ms = server_now - device_time at sync and flags large drift.Principles
punch_uuid; the server must treat repeated uploads as no-ops.Workers (WorkManager)
PunchUploadWorker: constraints NetworkType.CONNECTED, exponential backoff, batches of ~50, oldest first. Mark each punch by the per-item result.TemplateSyncWorker: upload pending templates, download new/updated/revoked templates using a since cursor.EmployeeSyncWorker: pull employee list (active/inactive) using a since cursor.Conflict rules
model_version changes, templates must be re-enrolled; do not mix embedding versions in one matching pass.The backend runs PHP 7.3 on Windows with MySQL/MariaDB. Do not use PHP 8-only features (no match, no named arguments, no union types, no str_contains, no constructor property promotion, no nullsafe ?->). Use prepared statements (PDO) everywhere.
Base: /api/v1/kiosk. All requests send Authorization: Bearer <device_token> and X-Device-Id. HTTPS only.
| Method | Path | Purpose |
|---|---|---|
| POST | /punches/sync | Upload a batch of punches |
| POST | /templates | Upload new face templates |
| GET | /templates?since=<cursor> | Download new/updated/revoked templates |
| GET | /employees?since=<cursor> | Download employee list changes |
| GET | /health | Connectivity + server time check |
POST /punches/sync request:
{
"deviceId": "KIOSK-01",
"sentAtEpochMs": 1760000000000,
"punches": [
{
"punchUuid": "…",
"employeeId": "E1234",
"punchedAtEpochMs": 1759999990000,
"tzOffsetMinutes": 480,
"suggestedType": "IN",
"matchScore": 0.71,
"runnerUpScore": 0.32,
"livenessScore": 0.93,
"source": "FACE"
}
]
}
Response (per item, never fail the whole batch for one bad row):
{
"results": [
{ "punchUuid": "…", "status": "ACCEPTED" },
{ "punchUuid": "…", "status": "DUPLICATE" },
{ "punchUuid": "…", "status": "REJECTED", "reason": "UNKNOWN_EMPLOYEE" }
],
"serverTimeEpochMs": 1760000001000
}
Treat ACCEPTED and DUPLICATE as synced. Treat REJECTED as terminal (keep for review, do not retry forever).
FLAG_SECURE) on admin screens, protect admin and registration with auth.ktlint + detekt must pass.!! in production code.face/, liveness/, and sync/ have KDoc and unit tests.FaceConfig; never scatter magic numbers.AppLog) that can be disabled and never prints PII or embeddings.Unit: matcher (threshold, margin, tie cases), quality gate, debounce logic, sync result handling, time/timezone conversion, overnight punch timestamps.
Instrumented: Room DAOs + migrations, WorkManager workers with a fake API (success, partial failure, timeout, duplicate responses).
Biometric evaluation (required before pilot): collect a small labeled set from real employees under varied lighting. Report:
Do not raise thresholds or lower margins just to make demo punches succeed.
Offline scenarios: punch with airplane mode on; restart app/device while offline; sync after hours or days offline; same punch uploaded twice; server returns 500 mid-batch.
docs/MODELS.md; verify on a handful of people.Complete and verify one milestone before starting the next. After each milestone, update docs/PROGRESS.md with what was done, what is untested, and open decisions.
docs/PROGRESS.md.tk_ tables, shift rules, or model licensing), stop and ask rather than guess.FaceConfigPROGRESS.md, MODELS.md if models changed)TODO(decision): exact column mapping from face_punches into the existing tk_ raw-log tableTODO(decision): shift/overnight rules the server applies when pairing IN/OUTTODO(decision): target kiosk device model (camera quality, IR/depth sensor availability)TODO(decision): number of employees per kiosk (affects matching speed and thresholds)TODO(decision): snapshot retention period and fallback (PIN/admin) policy
comments (0)