Commit 8729bdaf authored by peturbg's avatar peturbg
Browse files

feat(security): add admin mode pin + device admin" \

  -m "Adds Admin Mode secured by a PIN (PBKDF2 hash) and optional Device Admin receiver to make uninstall harder.

- Protect Settings behind PIN when Admin Mode is enabled
- Protect Clear Track behind PIN
- Add security preferences + strings + device_admin.xml and receiver
parent 193d831e
Loading
Loading
Loading
Loading
+15 −0
Original line number Diff line number Diff line
@@ -90,6 +90,21 @@
                <action android:name="${applicationId}.intent.action.COMMAND" />
            </intent-filter>
        </receiver>
        <!-- device admin: makes uninstall harder (requires deactivation first) -->
        <receiver
            android:name=".admin.AdminDeviceReceiver"
            android:description="@string/device_admin_desc"
            android:exported="true"
            android:label="@string/enable_device_admin"
            android:permission="android.permission.BIND_DEVICE_ADMIN">
            <meta-data
                android:name="android.app.device_admin"
                android:resource="@xml/device_admin" />
            <intent-filter>
                <action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
            </intent-filter>
        </receiver>

    </application>

</manifest>
 No newline at end of file
+34 −0
Original line number Diff line number Diff line
/*
 * Copyright (c) 2025
 *
 * This file is part of µlogger-android.
 * Licensed under GPL, either version 3, or any later.
 * See <http://www.gnu.org/licenses/>
 */

package net.fabiszewski.ulogger.admin;

import android.app.admin.DeviceAdminReceiver;
import android.content.Context;
import android.content.Intent;
import android.widget.Toast;

import net.fabiszewski.ulogger.R;

/**
 * Device admin receiver used only to make uninstall harder (requires deactivation first).
 */
public class AdminDeviceReceiver extends DeviceAdminReceiver {

    @Override
    public void onEnabled(Context context, Intent intent) {
        super.onEnabled(context, intent);
        Toast.makeText(context, R.string.device_admin_enabled, Toast.LENGTH_LONG).show();
    }

    @Override
    public void onDisabled(Context context, Intent intent) {
        super.onDisabled(context, intent);
        Toast.makeText(context, R.string.device_admin_disabled, Toast.LENGTH_LONG).show();
    }
}
+10 −0
Original line number Diff line number Diff line
@@ -51,6 +51,7 @@ import net.fabiszewski.ulogger.R;
import net.fabiszewski.ulogger.db.DbAccess;
import net.fabiszewski.ulogger.services.LoggerService;
import net.fabiszewski.ulogger.tasks.GpxExportTask;
import net.fabiszewski.ulogger.utils.AdminLock;

import java.util.concurrent.ExecutorService;

@@ -233,6 +234,14 @@ public class MainActivity extends AppCompatActivity


    private void clearTrack() {
        if (AdminLock.isEnabled(this) && AdminLock.isPinSet(this)) {
            AdminLock.showVerifyPinDialog(this, this::clearTrackUnlocked, null);
            return;
        }
        clearTrackUnlocked();
    }

    private void clearTrackUnlocked() {
        if (LoggerService.isRunning()) {
            showToast(getString(R.string.logger_running_warning));
            return;
@@ -254,6 +263,7 @@ public class MainActivity extends AppCompatActivity
        }
    }


    /**
     * Display toast message
     * @param text Message
+13 −1
Original line number Diff line number Diff line
@@ -11,6 +11,8 @@ package net.fabiszewski.ulogger.ui;

import android.os.Bundle;

import net.fabiszewski.ulogger.utils.AdminLock;

import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.graphics.Insets;
@@ -41,6 +43,10 @@ public class SettingsActivity extends AppCompatActivity {
    public static final String KEY_ALLOW_EXTERNAL = "prefAllowExternal";
    public static final String KEY_AUTO_NAME = "prefAutoName";

    public static final String KEY_ADMIN_MODE = "prefAdminMode";
    public static final String KEY_ADMIN_CHANGE_PIN = "prefAdminChangePin";
    public static final String KEY_DEVICE_ADMIN = "prefDeviceAdmin";

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
@@ -50,9 +56,15 @@ public class SettingsActivity extends AppCompatActivity {
            return WindowInsetsCompat.CONSUMED;
        });
        if (savedInstanceState == null) {
            getSupportFragmentManager().beginTransaction()
            Runnable load = () -> getSupportFragmentManager().beginTransaction()
                    .replace(android.R.id.content, new SettingsFragment())
                    .commit();

            if (AdminLock.isEnabled(this) && AdminLock.isPinSet(this)) {
                AdminLock.showVerifyPinDialog(this, load, this::finish);
            } else {
                load.run();
            }
        }
    }

+193 −3
Original line number Diff line number Diff line
@@ -2,29 +2,37 @@
 * Copyright (c) 2018 Bartek Fabiszewski
 * http://www.fabiszewski.net
 *
 * This file is part of μlogger-android.
 * This file is part of µlogger-android.
 * Licensed under GPL, either version 3, or any later.
 * See <http://www.gnu.org/licenses/>
 */

package net.fabiszewski.ulogger.ui;

import static net.fabiszewski.ulogger.ui.SettingsActivity.KEY_ADMIN_CHANGE_PIN;
import static net.fabiszewski.ulogger.ui.SettingsActivity.KEY_ADMIN_MODE;
import static net.fabiszewski.ulogger.ui.SettingsActivity.KEY_ALLOW_EXTERNAL;
import static net.fabiszewski.ulogger.ui.SettingsActivity.KEY_AUTO_NAME;
import static net.fabiszewski.ulogger.ui.SettingsActivity.KEY_AUTO_START;
import static net.fabiszewski.ulogger.ui.SettingsActivity.KEY_DEVICE_ADMIN;
import static net.fabiszewski.ulogger.ui.SettingsActivity.KEY_HOST;
import static net.fabiszewski.ulogger.ui.SettingsActivity.KEY_LIVE_SYNC;
import static net.fabiszewski.ulogger.ui.SettingsActivity.KEY_PASS;
import static net.fabiszewski.ulogger.ui.SettingsActivity.KEY_PROVIDER;
import static net.fabiszewski.ulogger.ui.SettingsActivity.KEY_USERNAME;

import android.app.admin.DevicePolicyManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.widget.Toast;

import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.preference.EditTextPreference;
@@ -32,11 +40,14 @@ import androidx.preference.ListPreference;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
import androidx.preference.PreferenceManager;
import androidx.preference.SwitchPreferenceCompat;
import androidx.preference.TwoStatePreference;

import net.fabiszewski.ulogger.Logger;
import net.fabiszewski.ulogger.R;
import net.fabiszewski.ulogger.admin.AdminDeviceReceiver;
import net.fabiszewski.ulogger.db.DbAccess;
import net.fabiszewski.ulogger.utils.AdminLock;
import net.fabiszewski.ulogger.utils.PermissionHelper;
import net.fabiszewski.ulogger.utils.WebHelper;

@@ -46,6 +57,16 @@ public class SettingsFragment extends PreferenceFragmentCompat implements Permis

    final PermissionHelper permissionHelper;

    // Security/admin-mode
    private DevicePolicyManager dpm;
    private ComponentName deviceAdmin;
    private SwitchPreferenceCompat adminModePref;
    private Preference changePinPref;
    private SwitchPreferenceCompat deviceAdminPref;

    private final ActivityResultLauncher<Intent> enableDeviceAdminLauncher =
            registerForActivityResult(new StartActivityForResult(), result -> updateDeviceAdminPref());

    public SettingsFragment() {
        permissionHelper = new PermissionHelper(this, this);
    }
@@ -54,6 +75,7 @@ public class SettingsFragment extends PreferenceFragmentCompat implements Permis
    public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
        setPreferencesFromResource(R.xml.preferences, rootKey);
        setListeners();
        setupSecurityPreferences();
    }

    @SuppressWarnings("deprecation")
@@ -80,6 +102,15 @@ public class SettingsFragment extends PreferenceFragmentCompat implements Permis
        }
    }

    @Override
    public void onResume() {
        super.onResume();
        updateSecurityUi();
        updateDeviceAdminPref();
    }

    // --- Existing settings listeners ---

    /**
     * Set various listeners
     */
@@ -144,11 +175,9 @@ public class SettingsFragment extends PreferenceFragmentCompat implements Permis
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            // On change listener to check permission for background location
            Preference.OnPreferenceChangeListener permissionLevelChanged = (preference, newValue) -> {
//                final Context context = preference.getContext();
                if (Boolean.parseBoolean(newValue.toString())) {
                    if (!permissionHelper.hasBackgroundLocationPermission()) {
                        permissionHelper.requestBackgroundLocationPermission(preference.getKey());
//                        requestBackgroundLocationPermission(context, preference.getKey());
                        return false;
                    }
                }
@@ -172,6 +201,167 @@ public class SettingsFragment extends PreferenceFragmentCompat implements Permis
        }
    }

    // --- Security/admin-mode ---

    private void setupSecurityPreferences() {
        final Context context = getContext();
        if (context == null) {
            return;
        }

        adminModePref = findPreference(KEY_ADMIN_MODE);
        changePinPref = findPreference(KEY_ADMIN_CHANGE_PIN);
        deviceAdminPref = findPreference(KEY_DEVICE_ADMIN);

        dpm = (DevicePolicyManager) context.getSystemService(Context.DEVICE_POLICY_SERVICE);
        deviceAdmin = new ComponentName(context.getApplicationContext(), AdminDeviceReceiver.class);

        if (adminModePref != null) {
            adminModePref.setChecked(AdminLock.isEnabled(context));
            adminModePref.setOnPreferenceChangeListener((preference, newValue) -> {
                boolean enable = Boolean.parseBoolean(newValue.toString());
                if (Logger.DEBUG) {
                    Log.d(TAG, "[adminModePref change: " + enable + "]");
                }

                if (getActivity() == null) {
                    return false;
                }

                if (enable) {
                    if (AdminLock.isPinSet(context)) {
                        AdminLock.setEnabled(context, true);
                        updateSecurityUi();
                        return true;
                    }
                    // Ask to create PIN first.
                    AdminLock.showCreatePinDialog(requireActivity(), () -> {
                        AdminLock.setEnabled(context, true);
                        adminModePref.setChecked(true);
                        updateSecurityUi();
                        Toast.makeText(context, R.string.admin_mode_enabled, Toast.LENGTH_SHORT).show();
                    }, () -> adminModePref.setChecked(false));
                    return false;
                } else {
                    if (!AdminLock.isPinSet(context)) {
                        AdminLock.setEnabled(context, false);
                        updateSecurityUi();
                        return true;
                    }
                    // Verify PIN to disable.
                    AdminLock.showVerifyPinDialog(requireActivity(), () -> {
                        AdminLock.setEnabled(context, false);
                        adminModePref.setChecked(false);

                        // Optional: also disable device-admin when admin mode is turned off.
                        if (dpm != null && deviceAdmin != null && dpm.isAdminActive(deviceAdmin)) {
                            dpm.removeActiveAdmin(deviceAdmin);
                        }

                        updateSecurityUi();
                        updateDeviceAdminPref();
                        Toast.makeText(context, R.string.admin_mode_disabled, Toast.LENGTH_SHORT).show();
                    }, () -> adminModePref.setChecked(true));
                    return false;
                }
            });
        }

        if (changePinPref != null) {
            changePinPref.setOnPreferenceClickListener(preference -> {
                if (getActivity() == null) {
                    return true;
                }
                if (!AdminLock.isPinSet(context)) {
                    AdminLock.showCreatePinDialog(requireActivity(), () ->
                            Toast.makeText(context, R.string.admin_pin_set, Toast.LENGTH_SHORT).show(), null);
                    return true;
                }

                AdminLock.showVerifyPinDialog(requireActivity(), () ->
                        AdminLock.showCreatePinDialog(requireActivity(), () ->
                                Toast.makeText(context, R.string.admin_pin_changed, Toast.LENGTH_SHORT).show(), null), null);
                return true;
            });
        }

        if (deviceAdminPref != null) {
            deviceAdminPref.setOnPreferenceChangeListener((preference, newValue) -> {
                boolean enable = Boolean.parseBoolean(newValue.toString());

                if (getActivity() == null) {
                    return false;
                }

                if (!AdminLock.isEnabled(context)) {
                    Toast.makeText(context, R.string.device_admin_requires_admin_mode, Toast.LENGTH_LONG).show();
                    updateSecurityUi();
                    return false;
                }
                if (!AdminLock.isPinSet(context)) {
                    Toast.makeText(context, R.string.admin_pin_not_set, Toast.LENGTH_LONG).show();
                    return false;
                }

                AdminLock.showVerifyPinDialog(requireActivity(), () -> {
                    if (dpm == null || deviceAdmin == null) {
                        return;
                    }
                    if (enable) {
                        Intent intent = new Intent(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN);
                        intent.putExtra(DevicePolicyManager.EXTRA_DEVICE_ADMIN, deviceAdmin);
                        intent.putExtra(DevicePolicyManager.EXTRA_ADD_EXPLANATION, getString(R.string.device_admin_desc));
                        enableDeviceAdminLauncher.launch(intent);
                    } else {
                        if (dpm.isAdminActive(deviceAdmin)) {
                            dpm.removeActiveAdmin(deviceAdmin);
                        }
                        updateDeviceAdminPref();
                    }
                }, this::updateDeviceAdminPref);

                // We'll set the switch state based on actual system state.
                return false;
            });
        }

        updateSecurityUi();
        updateDeviceAdminPref();
    }

    private void updateSecurityUi() {
        Context context = getContext();
        if (context == null) {
            return;
        }
        boolean adminEnabled = AdminLock.isEnabled(context);

        if (adminModePref != null && adminModePref.isChecked() != adminEnabled) {
            adminModePref.setChecked(adminEnabled);
        }

        if (changePinPref != null) {
            changePinPref.setVisible(adminEnabled);
        }

        if (deviceAdminPref != null) {
            deviceAdminPref.setEnabled(adminEnabled);
            if (!adminEnabled) {
                deviceAdminPref.setSummary(R.string.pref_device_admin_summary_disabled);
            } else {
                deviceAdminPref.setSummary(R.string.pref_device_admin_summary);
            }
        }
    }

    private void updateDeviceAdminPref() {
        if (deviceAdminPref == null || dpm == null || deviceAdmin == null) {
            return;
        }
        boolean active = dpm.isAdminActive(deviceAdmin);
        deviceAdminPref.setChecked(active);
    }

    /**
     * Disable live sync preference, reset checkbox
     * @param context Context
Loading