Skip to content
Open
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
58 changes: 58 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
.PHONY: build clean install install-debug install-release test help
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add missing targets to .PHONY declaration.

The .PHONY declaration is incomplete. Several targets defined in the Makefile are missing: build-release, assemble, uninstall, lint, and run. Without declaring these as phony, Make may not execute them correctly if files with matching names exist.

📝 Proposed fix
-.PHONY: build clean install install-debug install-release test help
+.PHONY: help build build-release assemble clean install install-debug install-release uninstall test lint run
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.PHONY: build clean install install-debug install-release test help
.PHONY: help build build-release assemble clean install install-debug install-release uninstall test lint run
🧰 Tools
🪛 checkmake (0.2.2)

[warning] 1-1: Missing required phony target "all"

(minphony)

🤖 Prompt for AI Agents
In @Makefile at line 1, The .PHONY declaration is missing several targets so
Make may treat them as files; update the .PHONY line (the existing .PHONY: build
clean install install-debug install-release test help) to include the additional
phony targets build-release, assemble, uninstall, lint, and run so Make always
executes those rules instead of treating same‑named files as up‑to‑date.


# Default target
help:
@echo "Available targets:"
@echo " build - Build debug APK"
@echo " build-release - Build release APK"
@echo " clean - Clean build artifacts"
@echo " install - Install debug APK to connected device"
@echo " install-release- Install release APK to connected device"
@echo " uninstall - Uninstall app from connected device"
@echo " test - Run tests"
@echo " lint - Run lint checks"
@echo " assemble - Build all variants"

# Build debug APK
build:
./gradlew assembleDebug

# Build release APK
build-release:
./gradlew assembleRelease

# Build all variants
assemble:
./gradlew assemble

# Clean build artifacts
clean:
./gradlew clean

# Install debug APK to connected device
install: build
./gradlew installDebug

# Install debug APK without building (if already built)
install-debug:
./gradlew installDebug

# Install release APK to connected device
install-release: build-release
./gradlew installRelease

# Uninstall app from device
uninstall:
./gradlew uninstallAll

# Run tests
test:
./gradlew test

# Run lint checks
lint:
./gradlew lint

# Run app on connected device
run: install
adb shell am start -n com.immichframe.immichframe/.MainActivity
46 changes: 45 additions & 1 deletion app/src/main/java/com/immichframe/immichframe/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,13 @@ import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.text.InputType
import android.widget.Button
import android.widget.EditText
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
Expand Down Expand Up @@ -161,7 +164,7 @@ class MainActivity : AppCompatActivity() {
onNextCommand = { runOnUiThread { nextAction() } },
onPreviousCommand = { runOnUiThread { previousAction() } },
onPauseCommand = { runOnUiThread { pauseAction() } },
onSettingsCommand = { runOnUiThread { settingsAction() } },
onSettingsCommand = { runOnUiThread { settingsActionFromRpc() } },
onBrightnessCommand = { brightness -> runOnUiThread { screenBrightnessAction(brightness) } },
)
rcpServer.start()
Expand Down Expand Up @@ -700,11 +703,52 @@ class MainActivity : AppCompatActivity() {
}

private fun settingsAction() {
val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this)
val savedPassword = sharedPrefs.getString("settings_pincode", "")

if (!savedPassword.isNullOrEmpty()) {
// Password is set, prompt for verification
promptForPasswordVerification(savedPassword)
} else {
// No password, open settings directly
openSettings()
}
}

private fun settingsActionFromRpc() {
// RPC access bypasses password protection
openSettings()
}

private fun openSettings() {
val intent = Intent(this, SettingsActivity::class.java)
stopImageTimer()
settingsLauncher.launch(intent)
}

private fun promptForPasswordVerification(correctPassword: String) {
val input = EditText(this)
input.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD

AlertDialog.Builder(this)
.setTitle("Enter Pincode")
.setMessage("Enter your pincode to access settings:")
.setView(input)
.setPositiveButton("OK") { _, _ ->
val enteredPassword = input.text.toString()
if (enteredPassword == correctPassword) {
openSettings()
} else {
Toast.makeText(this, "Incorrect pincode", Toast.LENGTH_SHORT).show()
}
}
.setNegativeButton("Cancel") { dialog, _ ->
dialog.dismiss()
}
.setCancelable(false)
.show()
}
Comment on lines +729 to +750
Copy link

@coderabbitai coderabbitai bot Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Consider UX and security improvements to PIN verification.

Three concerns:

  1. Input masking: TYPE_NUMBER_VARIATION_PASSWORD may not mask input on all Android versions/devices. Consider testing or adding a fallback.

  2. Dialog cancelability: Line 748 sets setCancelable(false), forcing users to either enter a PIN or click Cancel. This could be frustrating if triggered accidentally. Consider setCancelable(true).

  3. Rate limiting: No protection against brute-force attempts. An attacker could try all 10,000 4-digit PINs rapidly.

🔒 Recommended improvements

For rate limiting, consider adding a simple lockout after failed attempts:

private var failedPinAttempts = 0
private var pinLockoutUntil = 0L

private fun promptForPasswordVerification(correctPassword: String) {
    // Check lockout
    if (System.currentTimeMillis() < pinLockoutUntil) {
        val remainingSec = (pinLockoutUntil - System.currentTimeMillis()) / 1000
        Toast.makeText(this, "Too many attempts. Try again in ${remainingSec}s", Toast.LENGTH_SHORT).show()
        return
    }

    val input = EditText(this)
    input.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD

    AlertDialog.Builder(this)
        .setTitle("Enter Pincode")
        .setMessage("Enter your pincode to access settings:")
        .setView(input)
        .setPositiveButton("OK") { _, _ ->
            val enteredPassword = input.text.toString()
            if (enteredPassword == correctPassword) {
                failedPinAttempts = 0
                openSettings()
            } else {
                failedPinAttempts++
                if (failedPinAttempts >= 3) {
                    pinLockoutUntil = System.currentTimeMillis() + 30_000 // 30s lockout
                    Toast.makeText(this, "Too many failed attempts. Locked for 30 seconds.", Toast.LENGTH_LONG).show()
                } else {
                    Toast.makeText(this, "Incorrect pincode", Toast.LENGTH_SHORT).show()
                }
            }
        }
        .setNegativeButton("Cancel") { dialog, _ ->
            dialog.dismiss()
        }
        .setCancelable(true)  // Allow dismissal
        .show()
}
🤖 Prompt for AI Agents
In @app/src/main/java/com/immichframe/immichframe/MainActivity.kt around lines
729 - 750, promptForPasswordVerification currently may not reliably mask input,
forces non-cancelable dialogs, and lacks brute-force protection; update it to
use a masked input reliably (ensure EditText uses passwordTransformationMethod
fallback when InputType.TYPE_NUMBER_VARIATION_PASSWORD doesn't mask), change
setCancelable(false) to setCancelable(true) so users can dismiss the dialog, and
add simple rate-limiting state (e.g. private vars failedPinAttempts and
pinLockoutUntil) checked at the start of promptForPasswordVerification to refuse
attempts while locked and to increment failedPinAttempts on wrong PIN, reset
failedPinAttempts to 0 on success (openSettings), and set pinLockoutUntil to
now+lockoutDuration after N failures to enforce a temporary lockout.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None of these are particular concerns for the use-case of the frame in a residential setting.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@juanjsebgarcia Understood! For a residential frame protecting against children, the current implementation is appropriate. Thank you for clarifying the use case.

Comment on lines +729 to +750
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Add rate limiting to prevent brute-force attacks.

The PIN verification dialog has no rate limiting or maximum attempt restrictions. An attacker with physical access could repeatedly attempt PINs (especially if they're short) until finding the correct one.

Consider:

  • Adding a delay after failed attempts (exponential backoff)
  • Implementing a temporary lockout after N failed attempts
  • Logging failed attempts

Additionally, note that setCancelable(false) at line 748 is redundant since a Cancel button is already provided at line 745.

🤖 Prompt for AI Agents
In @app/src/main/java/com/immichframe/immichframe/MainActivity.kt around lines
729 - 750, The PIN dialog in promptForPasswordVerification lacks rate limiting
and a max-attempt lockout and also uses a redundant setCancelable(false); fix by
adding an attempt counter and last-failure timestamp (e.g., in
SharedPreferences) keyed to the settings access, check these values before
verifying and refuse/show a locked message when the temporary lockout is active,
increment attempts on each wrong entry, compute an exponential backoff delay (or
fixed lock duration after N failures), persist the backoff/lockout state and
reset attempts on successful auth, disable the OK button (or ignore input) while
the backoff timer is active, log each failed attempt with timestamps (using Log
or existing analytics/logging helpers), and remove the redundant
setCancelable(false) call from the AlertDialog chain in
promptForPasswordVerification.


private fun screenBrightnessAction(brightness: Float) {
val lp = window.attributes
lp.screenBrightness = brightness
Expand Down
69 changes: 69 additions & 0 deletions app/src/main/java/com/immichframe/immichframe/SettingsFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,44 @@ class SettingsFragment : PreferenceFragmentCompat() {
true
}

val chkSettingsPincode = findPreference<SwitchPreferenceCompat>("settingsPincode")
val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext())

// Update switch state based on whether password exists
val hasPassword = !sharedPrefs.getString("settings_pincode", "").isNullOrEmpty()
chkSettingsPincode?.isChecked = hasPassword

chkSettingsPincode?.setOnPreferenceChangeListener { preference, newValue ->
val enabling = newValue as Boolean

if (enabling) {
// Show confirmation dialog first
AlertDialog.Builder(requireContext())
.setTitle("Confirm Action")
.setMessage(
"This will require a pincode to access the settings screen. " +
"The only way to reset is via RPC commands (or uninstall/reinstall).\n" +
"Are you sure?"
)
.setPositiveButton("Yes") { _, _ ->
// Now prompt for password creation
promptForPasswordCreation(chkSettingsPincode)
}
.setNegativeButton("No") { dialog, _ ->
dialog.dismiss()
}
.show()
false // Don't change preference yet, wait for password creation
} else {
// Disabling - delete the password
sharedPrefs.edit()
.remove("settings_pincode")
.apply()
Toast.makeText(requireContext(), "Pincode protection removed", Toast.LENGTH_SHORT).show()
true
}
}


val btnClose = findPreference<Preference>("closeSettings")
btnClose?.setOnPreferenceClickListener {
Expand Down Expand Up @@ -138,4 +176,35 @@ class SettingsFragment : PreferenceFragmentCompat() {
}
}
}

private fun promptForPasswordCreation(chkSettingsPincode: SwitchPreferenceCompat?) {
val input = android.widget.EditText(requireContext())
input.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD

AlertDialog.Builder(requireContext())
.setTitle("Create Pincode")
.setMessage("Enter a pincode to protect settings access:")
.setView(input)
.setPositiveButton("Set") { _, _ ->
val password = input.text.toString()
if (password.isEmpty()) {
Toast.makeText(requireContext(), "Pincode cannot be empty", Toast.LENGTH_SHORT).show()
} else {
// Save the password
val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
sharedPrefs.edit()
.putString("settings_pincode", password)
.apply()

// Update the switch
chkSettingsPincode?.isChecked = true

Toast.makeText(requireContext(), "Pincode protection enabled", Toast.LENGTH_SHORT).show()
}
}
.setNegativeButton("Cancel") { dialog, _ ->
dialog.dismiss()
}
.show()
}
Comment on lines +180 to +209
Copy link

@coderabbitai coderabbitai bot Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Enhance PIN validation and user experience.

Several improvements would strengthen this feature:

  1. No PIN confirmation: Users should re-enter the PIN to prevent typos (especially since input is masked).

  2. Minimal validation: Line 190 only checks isEmpty(). Consider enforcing a minimum length (e.g., 4 digits) for basic security.

  3. Input masking: TYPE_NUMBER_VARIATION_PASSWORD may not mask input consistently across devices (same concern as in MainActivity).

🔐 Recommended improvements

Add PIN confirmation and minimum length validation:

private fun promptForPasswordCreation(chkSettingsPincode: SwitchPreferenceCompat?) {
    val input = android.widget.EditText(requireContext())
    input.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
    input.hint = "Minimum 4 digits"

    AlertDialog.Builder(requireContext())
        .setTitle("Create Pincode")
        .setMessage("Enter a pincode to protect settings access:")
        .setView(input)
        .setPositiveButton("Next") { _, _ ->
            val password = input.text.toString()
            if (password.length < 4) {
                Toast.makeText(requireContext(), "Pincode must be at least 4 digits", Toast.LENGTH_SHORT).show()
            } else {
                // Prompt for confirmation
                promptForPasswordConfirmation(password, chkSettingsPincode)
            }
        }
        .setNegativeButton("Cancel") { dialog, _ ->
            dialog.dismiss()
        }
        .show()
}

private fun promptForPasswordConfirmation(originalPassword: String, chkSettingsPincode: SwitchPreferenceCompat?) {
    val confirmInput = android.widget.EditText(requireContext())
    confirmInput.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD

    AlertDialog.Builder(requireContext())
        .setTitle("Confirm Pincode")
        .setMessage("Re-enter your pincode:")
        .setView(confirmInput)
        .setPositiveButton("Set") { _, _ ->
            val confirmedPassword = confirmInput.text.toString()
            if (confirmedPassword == originalPassword) {
                // Save the password
                val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
                sharedPrefs.edit()
                    .putString("settings_pincode", originalPassword)
                    .apply()

                // Update the switch
                chkSettingsPincode?.isChecked = true

                Toast.makeText(requireContext(), "Pincode protection enabled", Toast.LENGTH_SHORT).show()
            } else {
                Toast.makeText(requireContext(), "Pincodes do not match", Toast.LENGTH_SHORT).show()
                chkSettingsPincode?.isChecked = false
            }
        }
        .setNegativeButton("Cancel") { dialog, _ ->
            dialog.dismiss()
        }
        .show()
}
🤖 Prompt for AI Agents
In @app/src/main/java/com/immichframe/immichframe/SettingsFragment.kt around
lines 180 - 209, The PIN creation flow in promptForPasswordCreation lacks
confirmation, minimal length validation, and reliable masking; update
promptForPasswordCreation to enforce a minimum length (e.g., >=4), show a "Next"
action that calls a new promptForPasswordConfirmation(originalPassword,
chkSettingsPincode) which asks the user to re-enter the PIN and only saves to
PreferenceManager.getDefaultSharedPreferences when both entries match; ensure
both EditText instances enable consistent masking (e.g.,
setTransformationMethod(PasswordTransformationMethod.getInstance()) and/or
proper inputType) and add user-facing hints/toasts for "PIN too short" and "PINs
do not match" while unchecking chkSettingsPincode on failure.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implementation is not designed to be high security, just to keep the innocent out

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@juanjsebgarcia, that makes perfect sense! For the use case of keeping children out, the current implementation is appropriate. Thank you for clarifying the design intent.

Comment on lines +180 to +209
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Enforce minimum PIN length for security.

The PIN creation only validates that it's not empty (line 190), allowing single-digit PINs that provide minimal security. Even against the stated use case (preventing children from changing settings), short PINs are easily guessable.

Recommend enforcing a minimum PIN length of 4-6 digits.

🔒 Proposed fix to enforce minimum PIN length
             .setPositiveButton("Set") { _, _ ->
                 val password = input.text.toString()
-                if (password.isEmpty()) {
-                    Toast.makeText(requireContext(), "Pincode cannot be empty", Toast.LENGTH_SHORT).show()
+                if (password.length < 4) {
+                    Toast.makeText(requireContext(), "Pincode must be at least 4 digits", Toast.LENGTH_SHORT).show()
                 } else {
                     // Save the password
                     val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext())

}
9 changes: 8 additions & 1 deletion app/src/main/res/xml/settings_view.xml
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,17 @@
android:defaultValue="false"
android:key="showCurrentDate"
android:title="Show Current Date?" />
</PreferenceCategory>

<PreferenceCategory android:title="Tamper Protection">
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="settingsPincode"
android:title="Protect access to these settings with pincode?" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="settingsLock"
android:title="Lock access to these settings?" />
android:title="Fully disable/lock access to these settings?" />
</PreferenceCategory>

<PreferenceCategory android:title="Screen Dimming">
Expand Down