diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9c09fd4
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,110 @@
+# Built application files
+*.apk
+*.ap_
+*.aab
+
+# Files for the ART/Dalvik VM
+*.dex
+
+# Java class files
+*.class
+
+# Generated files
+bin/
+gen/
+out/
+release/
+
+# Gradle files
+.gradle/
+build/
+**/build/
+
+# Local configuration file (sdk path, etc)
+local.properties
+
+# Proguard folder generated by Eclipse
+proguard/
+
+# Log Files
+*.log
+
+# Android Studio Navigation editor temp files
+.navigation/
+
+# Android Studio captures folder
+captures/
+
+# IntelliJ
+*.iml
+.idea/
+*.ipr
+*.iws
+
+# Keystore files
+*.jks
+*.keystore
+
+# External native build folder generated in Android Studio 2.2 and later
+.externalNativeBuild
+.cxx/
+
+# Google Services (e.g. APIs or Firebase)
+google-services.json
+
+# Freeline
+freeline.py
+freeline/
+freeline_project_description.json
+
+# fastlane
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots
+fastlane/test_output
+fastlane/readme.md
+
+# Version control
+vcs.xml
+
+# lint
+lint/intermediates/
+lint/generated/
+lint/outputs/
+lint/tmp/
+lint-results*.xml
+lint-results*.html
+
+# Android Profiling
+*.hprof
+
+# macOS
+.DS_Store
+.AppleDouble
+.LSOverride
+._*
+
+# Thumbnails
+Thumbs.db
+
+# Backup files
+*~
+*.swp
+*.bak
+
+# Temporary files
+*.tmp
+tmp/
+
+# NDK
+obj/
+.externalNativeBuild
+
+# Miscellaneous
+.vscode/
+*.orig
+
+# Claude Code and Serena config
+CLAUDE.md
+.claude/
+.serena/
diff --git a/README.md b/README.md
index 4852480..623c36c 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,407 @@
-# Bloodhound
-
+# Bloodhound
-Take control of your device without any of the privacy issues.
+**Take control of your device without any of the privacy issues.**
-Bloodhound is free and open source device tracker for Android. With Bloodhound you can track your device's location, take secret pictures from both your front and back camera,
-listen in on your device's microphone, and set an alarm off even if your device is on silent. There's no worry about privacy issues either, Bloodhound is completely in your control.
+Bloodhound is a free and open-source device tracker for Android designed for authorized device recovery and monitoring. Track your own devices for legitimate anti-theft purposes with complete privacy control.
-You can set up Bloodhound to be connected to a Nextcloud account, or start the tracking from an SMS trigger. You can also set up an Emergency Tracking phone number, where you can enable Bloodhound from within the application and it will send the information to the number via SMS. So not only will your phone feel safer with Bloodhound installed, you'll feel safer too.
+
+
+
-Notes:
-I'm still working on the Nextcloud server side app / haven't decided if I want to build a Bloodhound server in NodeJS. Will wait until the school semester is finished though...
-For right now you can place the "Bloodhound" directory in your root Nextcloud file directory. To enable the service, set the check binary file to 1 in "Bloodhound/Config" and enable or disable which tracking flags you want in the config.json file. The uploaded pictures, locations, and microphone recordings will be places in "Bloodhound/Pictures", "Bloodhound/Location", and "Bloodhound/Recordings" respectively.
+## ⚠️ Important Security Notice
-Also the username and password is stored in plain text in the application preferences. While this is somewhat shameful it is only temporary...
+**This application is intended ONLY for:**
+- Tracking your own devices for anti-theft purposes
+- Authorized security testing and research
+- Educational purposes with proper consent
+
+**Unauthorized use of tracking software may be illegal in your jurisdiction.**
+
+## Features
+
+- 📍 **GPS Location Tracking** - Real-time location updates
+- 📷 **Remote Camera Capture** - Take photos from front/back cameras
+- 🎤 **Audio Recording** - Remote microphone access
+- 🔔 **Remote Alarm** - Trigger alarm even on silent mode
+- 📱 **SMS Trigger** - Activate via text message
+- ☁️ **Nextcloud Integration** - Store data on your private cloud
+- 🚨 **Emergency SMS** - Send tracking data via SMS
+- 🔒 **Privacy First** - No third-party servers, you control everything
+
+## Requirements
+
+### Development Environment
+- **Android Studio** (latest version recommended)
+- **JDK 17 or newer** (JDK 22 recommended)
+- **Android SDK API 34** (automatically downloaded via Gradle)
+- **Gradle 8.8+** (included via wrapper)
+
+### Device Requirements
+- **Minimum Android Version:** Android 7.0 (API 24)
+- **Target Android Version:** Android 14 (API 34)
+- Required permissions: Location, Camera, Microphone, SMS, Storage
+
+## Building the App
+
+### Quick Start
+
+1. **Clone the repository**
+ ```bash
+ git clone
+ cd Bloodhound
+ ```
+
+2. **Set up environment** (macOS/Linux)
+ ```bash
+ export ANDROID_HOME=$HOME/Library/Android/sdk
+ export PATH=$PATH:$ANDROID_HOME/emulator
+ export PATH=$PATH:$ANDROID_HOME/platform-tools
+ ```
+
+3. **Build the app**
+ ```bash
+ ./gradlew assembleDebug
+ ```
+
+4. **Install on device/emulator**
+ ```bash
+ ./gradlew installDebug
+ ```
+
+### Build Commands
+
+```bash
+# Clean build
+./gradlew clean
+
+# Build debug APK
+./gradlew assembleDebug
+
+# Build release APK
+./gradlew assembleRelease
+
+# Run tests
+./gradlew test
+
+# Install to connected device
+./gradlew installDebug
+
+# Run lint checks
+./gradlew lint
+```
+
+### Running in an Emulator
+
+1. **List available emulators**
+ ```bash
+ emulator -list-avds
+ ```
+
+2. **Start an emulator**
+ ```bash
+ # Replace with your emulator name from the list above
+ emulator -avd
+
+ # Example:
+ emulator -avd Pixel_7
+ ```
+
+3. **Wait for the emulator to fully boot, then install the app**
+ ```bash
+ # Build and install in one command
+ ./gradlew installDebug
+
+ # Or install a pre-built APK manually
+ adb install -r app/build/outputs/apk/debug/app-debug.apk
+ ```
+
+4. **Launch the app**
+ ```bash
+ adb shell am start -n me.dbarnett.bloodhound/.BloodhoundActivity
+ ```
+
+**Tip**: You can run the emulator in the background by appending `&` to the command:
+```bash
+emulator -avd Pixel_7 &
+```
+
+### Testing Location Capture on Emulator
+
+Location updates are configured with a 5-minute interval and 20-meter minimum movement. On an emulator sitting still, no locations will be captured automatically. Use mock locations to test:
+
+```bash
+# Send mock location to emulator while tracking is active
+adb emu geo fix -122.4194 37.7749
+
+# Or with different coordinates to trigger a location change
+adb emu geo fix -122.4200 37.7755
+```
+
+**Test steps:**
+1. Start tracking (tap FAB button)
+2. Run the `geo fix` command above
+3. Wait a moment, then check Capture Browser
+4. Location files should appear in `/files/Location/`
+
+## Nextcloud Setup (Optional)
+
+To use Nextcloud integration for remote control and data storage:
+
+1. **Create Bloodhound directory** in your Nextcloud root:
+ ```
+ Nextcloud/
+ └── Bloodhound/
+ ├── Config/
+ │ ├── check # Binary flag (0 or 1)
+ │ └── config.json # Feature configuration
+ ├── Pictures/ # Uploaded photos
+ ├── Location/ # Location JSON files
+ └── Recordings/ # Audio recordings
+ ```
+
+2. **Configure tracking** in `config.json`:
+ ```json
+ {
+ "location": "true",
+ "camera": "false",
+ "alarm": "false",
+ "mic": "false"
+ }
+ ```
+
+3. **Enable/disable tracking** via the `check` file:
+ - `1` = tracking enabled
+ - `0` = tracking disabled
+
+## Architecture
+
+Bloodhound uses a service-based architecture:
+
+- **BloodhoundService** - Core tracking service (location, camera, mic, alarm)
+- **CheckService** - Polls Nextcloud every 60 seconds for remote commands
+- **SmsReceiver** - Listens for SMS trigger messages
+- **BootReceiver** - Auto-starts services on device boot
+
+Data is uploaded to your Nextcloud server or sent via SMS based on configuration.
+
+## Using the App
+
+### First-Time Setup
+
+1. **Grant Permissions** - On first launch, approve all requested permissions:
+ - Location (Fine & Coarse)
+ - Camera
+ - Microphone
+ - SMS (Send, Receive, Read)
+ - System Alert Window
+ - Battery Optimization Exclusion
+
+2. **Configure Nextcloud** (Optional)
+ - Tap the menu (three dots) → "Nextcloud Login"
+ - Enter your Nextcloud server URL, username, and password
+ - The app will create required directories automatically
+
+3. **Set Emergency Contact**
+ - Tap the menu → "Settings"
+ - Enter a phone number for emergency SMS tracking notifications
+
+### Triggering Camera Tracking
+
+There are three ways to activate camera tracking:
+
+#### Method 1: Manual Activation (Long-Press)
+1. Long-press the paw logo on the main screen (hold for 2 seconds)
+2. Select **"Start Full Tracking"** from the menu
+3. Camera will begin capturing photos immediately (back camera → front camera → 60s delay → repeat)
+
+#### Method 2: SMS Trigger
+1. Configure trigger phrase in Settings
+2. Send SMS with trigger phrase to the device
+3. Camera tracking starts automatically
+
+#### Method 3: Nextcloud Remote Control
+1. Set up Nextcloud integration
+2. Update `Bloodhound/Config/check` file to `1`
+3. Configure features in `config.json`
+4. CheckService polls server every 60 seconds and starts tracking when enabled
+
+### Verifying Camera Is Working
+
+#### Check via ADB Logs
+```bash
+# Monitor camera activity in real-time
+adb logcat | grep -E "CameraX|BloodhoundService|ImageCapture"
+
+# You should see logs like:
+# D/CameraXCaptureManager: Starting capture sequence
+# D/CameraXCaptureManager: Capturing photo with BACK camera
+# D/CameraXCaptureManager: Photo saved successfully: /data/data/.../Pictures/IMG_20250126_143052.jpg
+```
+
+#### Check Captured Files
+```bash
+# List captured photos
+adb shell "run-as me.dbarnett.bloodhound ls -la /data/data/me.dbarnett.bloodhound/files/Pictures/"
+
+# Expected output:
+# -rw------- 1 u0_a123 u0_a123 245678 2025-01-26 14:30 IMG_20250126_143052.jpg
+# -rw------- 1 u0_a123 u0_a123 198234 2025-01-26 14:30 IMG_20250126_143053.jpg
+```
+
+#### Check Service Status
+```bash
+# Verify BloodhoundService is running
+adb shell dumpsys activity services me.dbarnett.bloodhound
+
+# Look for:
+# ServiceRecord{...BloodhoundService...}
+# app=ProcessRecord{...} (should NOT be null)
+```
+
+### Troubleshooting
+
+#### Camera Not Capturing Photos
+
+**Problem**: No photos in Pictures directory after triggering tracking
+
+**Solutions**:
+1. **Check service is actually running**:
+ ```bash
+ adb shell dumpsys activity services me.dbarnett.bloodhound | grep "app="
+ ```
+ If `app=null`, the service didn't start. Check if:
+ - Emergency phone number is configured in Settings
+ - All camera permissions are granted
+ - Battery optimization is disabled for Bloodhound
+
+2. **Check for camera initialization errors**:
+ ```bash
+ adb logcat | grep -E "CameraX|Camera.*error"
+ ```
+
+3. **Verify storage directory exists**:
+ ```bash
+ adb shell "run-as me.dbarnett.bloodhound mkdir -p /data/data/me.dbarnett.bloodhound/files/Pictures"
+ ```
+
+4. **Restart the app completely**:
+ - Force stop app
+ - Clear app data if necessary
+ - Re-grant all permissions
+ - Try triggering again
+
+#### Service Stops Immediately
+
+**Problem**: Service starts but stops after a few seconds
+
+**Cause**: Android's background service restrictions on newer versions
+
+**Solutions**:
+- Disable battery optimization for Bloodhound
+- Consider using "Start Foreground Tracking" instead (shows persistent notification)
+- Check for crashes in logcat: `adb logcat | grep AndroidRuntime`
+
+#### Photos Not Uploading to Nextcloud
+
+**Problem**: Photos are captured locally but not appearing on Nextcloud
+
+**Solutions**:
+1. Verify Nextcloud credentials in Settings
+2. Check network connectivity
+3. Ensure `Bloodhound/Pictures/` directory exists on Nextcloud server
+4. Check upload logs: `adb logcat | grep -E "Nextcloud|Upload"`
+
+## Data Storage
+
+All data is stored locally in app-specific storage before being uploaded to Nextcloud. Files are stored in:
+
+### Local Device Storage
+Base path: `/data/data/me.dbarnett.bloodhound/files/`
+
+| Type | Directory | File Format | Example |
+|------|-----------|-------------|---------|
+| **Camera Photos** | `Pictures/` | `IMG_yyyyMMdd_HHmmss.jpg` | `IMG_20250126_143052.jpg` |
+| **Location Data** | `Location/` | `yyyyMMdd_HHmmss.json` | `20250126_143052.json` |
+| **Audio Recordings** | `Recordings/` | `yyyyMMdd_HHmmss.mp3` | `20250126_143052.mp3` |
+| **Config Files** | `/` (root) | `check` | Downloaded from Nextcloud |
+| **Database** | `/databases/` | `bloodhound.db` | SQLite tracking history |
+| **Settings** | SharedPreferences | Default | Nextcloud credentials, SMS triggers |
+
+### Accessing Files via ADB
+
+```bash
+# Pull all photos from device
+adb pull /data/data/me.dbarnett.bloodhound/files/Pictures/ ./photos/
+
+# Pull location data
+adb pull /data/data/me.dbarnett.bloodhound/files/Location/ ./location/
+
+# Pull audio recordings
+adb pull /data/data/me.dbarnett.bloodhound/files/Recordings/ ./audio/
+
+# View tracking history database
+adb pull /data/data/me.dbarnett.bloodhound/databases/bloodhound.db ./
+```
+
+### Nextcloud Upload Paths
+
+Captured data is automatically uploaded to corresponding directories on your Nextcloud server:
+
+- Photos → `Nextcloud/Bloodhound/Pictures/`
+- Location → `Nextcloud/Bloodhound/Location/`
+- Recordings → `Nextcloud/Bloodhound/Recordings/`
+
+### Storage Notes
+
+- **App-Specific Storage**: All files use `context.getFilesDir()` (app-specific storage)
+- **No External Storage**: The app doesn't require external storage permissions for normal operation
+- **Automatic Cleanup**: Files remain on device until manually deleted or app is uninstalled
+- **Privacy**: All data stored in app-private directory, inaccessible to other apps
+- **Backup**: Data is NOT backed up by Android's Auto Backup feature (by design)
+
+## Technology Stack
+
+- **Language:** Java
+- **Build System:** Gradle 8.8
+- **Android SDK:** API 34 (Android 14)
+- **Architecture:** AndroidX with Material Design
+- **Storage:** SQLite for tracking history
+- **Cloud:** Nextcloud Android Library
+
+## Known Limitations
+
+- **Credentials Storage:** Nextcloud credentials are stored in plain text in SharedPreferences (temporary limitation - encryption planned).
+- **Background Restrictions:** Modern Android versions restrict background services. Foreground service implementation recommended.
+- **SMS Restrictions:** Background SMS access is restricted on newer Android versions.
+
+## Recent Updates (2025)
+
+This project has been modernized from the original 2017 codebase:
+
+- ✅ Updated from Android SDK 25 → SDK 34
+- ✅ Migrated from Support Library → AndroidX
+- ✅ Updated Gradle 3.3 → 8.8
+- ✅ Fixed Android 12+ compatibility issues
+- ✅ Updated all dependencies to latest versions
+- ✅ **Migrated from legacy Camera API to CameraX** (November 2025)
+ - Eliminated deprecated android.hardware.Camera API
+ - Removed 1x1 pixel SurfaceView overlay hack
+ - Improved battery efficiency and capture speed
+ - Better compatibility with modern Android versions
+
+## Contributing
+
+Contributions are welcome! Please ensure:
+
+1. All features respect user privacy
+2. Code follows existing architecture patterns
+3. Tests are included for new features
+4. Documentation is updated
+
+## License
+
+See [license.txt](license.txt) for details.
+
+## Disclaimer
+
+The developers of this software are not responsible for any misuse. Users must comply with all applicable laws regarding device tracking and privacy in their jurisdiction. Always obtain proper authorization before tracking any device.
diff --git a/app/build.gradle b/app/build.gradle
index 9e622ce..6d291e7 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -1,39 +1,78 @@
-apply plugin: 'com.android.application'
+plugins {
+ id 'com.android.application'
+}
android {
- compileSdkVersion 25
- buildToolsVersion "25.0.2"
+ namespace 'me.dbarnett.bloodhound'
+ compileSdk 34
+
defaultConfig {
applicationId 'me.dbarnett.bloodhound'
- minSdkVersion 22
- targetSdkVersion 25
- versionCode 1
- versionName "0.1"
- testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
+ minSdk 26
+ targetSdk 34
+ versionCode 3
+ versionName "1.1"
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
+
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
- productFlavors {
+
+ buildFeatures {
+ viewBinding true
}
-}
-repositories {
- maven { url "https://jitpack.io" }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
}
dependencies {
- compile fileTree(include: ['*.jar'], dir: 'libs')
- androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', {
- exclude group: 'com.android.support', module: 'support-annotations'
- })
- compile 'com.android.support:appcompat-v7:25.3.0'
- compile 'com.android.support.constraint:constraint-layout:1.0.2'
- compile 'com.github.nextcloud:android-library:1.0.15'
- compile 'com.android.support:support-v4:25.3.0'
- compile 'com.android.support:design:25.3.0'
- testCompile 'junit:junit:4.12'
+ implementation fileTree(include: ['*.jar'], dir: 'libs')
+
+ // AndroidX Core libraries
+ implementation 'androidx.appcompat:appcompat:1.6.1'
+ implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
+ implementation 'androidx.legacy:legacy-support-v4:1.0.0'
+ implementation 'androidx.recyclerview:recyclerview:1.3.2'
+ implementation 'androidx.core:core:1.12.0'
+
+ // Material Design 3
+ implementation 'com.google.android.material:material:1.11.0'
+
+ // Navigation Component
+ def nav_version = "2.7.6"
+ implementation "androidx.navigation:navigation-fragment:$nav_version"
+ implementation "androidx.navigation:navigation-ui:$nav_version"
+
+ // Preference Library (replaces legacy PreferenceActivity)
+ implementation 'androidx.preference:preference:1.2.1'
+
+ // Lifecycle Components
+ def lifecycle_version = "2.7.0"
+ implementation "androidx.lifecycle:lifecycle-viewmodel:$lifecycle_version"
+ implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version"
+ implementation "androidx.lifecycle:lifecycle-runtime:$lifecycle_version"
+
+ // SwipeRefreshLayout
+ implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
+
+ // CameraX dependencies
+ def camerax_version = "1.3.1"
+ implementation "androidx.camera:camera-core:${camerax_version}"
+ implementation "androidx.camera:camera-camera2:${camerax_version}"
+ implementation "androidx.camera:camera-lifecycle:${camerax_version}"
+
+ // Nextcloud library - keeping original version for now
+ implementation 'com.github.nextcloud:android-library:1.0.15'
+
+ // Testing
+ testImplementation 'junit:junit:4.13.2'
+ androidTestImplementation 'androidx.test.ext:junit:1.1.5'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 733ea19..392a456 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,6 +1,5 @@
-
+
@@ -18,11 +17,16 @@
-
+
+
+
+
+
+
@@ -30,19 +34,24 @@
-
+ android:theme="@style/Theme.Bloodhound">
+
-
+
@@ -50,13 +59,16 @@
+ android:enabled="true"
+ android:foregroundServiceType="location|camera|microphone|mediaProjection" />
-
+
@@ -64,6 +76,7 @@
+ android:theme="@style/Theme.Bloodhound">
-
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/me/dbarnett/bloodhound/BloodhoundActivity.java b/app/src/main/java/me/dbarnett/bloodhound/BloodhoundActivity.java
index 9dc27d2..b2c97a2 100644
--- a/app/src/main/java/me/dbarnett/bloodhound/BloodhoundActivity.java
+++ b/app/src/main/java/me/dbarnett/bloodhound/BloodhoundActivity.java
@@ -4,243 +4,201 @@
import android.app.AlertDialog;
import android.app.NotificationManager;
import android.content.Context;
-import android.content.DialogInterface;
import android.content.Intent;
-import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.os.PowerManager;
import android.preference.PreferenceManager;
import android.provider.Settings;
-import android.support.design.widget.FloatingActionButton;
-import android.support.design.widget.Snackbar;
-import android.support.v7.app.AppCompatActivity;
-import android.support.v7.widget.Toolbar;
-import android.view.Menu;
-import android.view.MenuItem;
+import android.content.SharedPreferences;
import android.view.View;
-import android.widget.RelativeLayout;
+import android.view.animation.Animation;
+import android.view.animation.AnimationUtils;
import android.widget.Toast;
-import me.dbarnett.bloodhound.Nextcloud.NextcloudLoginActivity;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.navigation.NavController;
+import androidx.navigation.fragment.NavHostFragment;
+import androidx.navigation.ui.NavigationUI;
+
import me.dbarnett.bloodhound.Services.BloodhoundService;
import me.dbarnett.bloodhound.Services.CheckService;
+import me.dbarnett.bloodhound.databinding.ActivityMainBinding;
/**
- * The type Bloodhound activity.
+ * Main activity with bottom navigation and dashboard.
*/
-public class BloodhoundActivity extends AppCompatActivity {
- /**
- * The Context.
- */
- Context context;
- /**
- * The Prefs.
- */
- SharedPreferences prefs;
+public class BloodhoundActivity extends AppCompatActivity implements SharedPreferences.OnSharedPreferenceChangeListener {
- /**
- * The M layout.
- */
- RelativeLayout mLayout;
- /**
- * The Intent.
- */
- Intent intent;
+ private ActivityMainBinding binding;
+ private Context context;
+ private SharedPreferences prefs;
+ private NavController navController;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
+
+ binding = ActivityMainBinding.inflate(getLayoutInflater());
+ setContentView(binding.getRoot());
+
context = getApplicationContext();
- setContentView(R.layout.activity_bloodhound);
- Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
- setSupportActionBar(toolbar);
- intent = new Intent(context, BloodhoundService.class);
prefs = PreferenceManager.getDefaultSharedPreferences(context);
- mLayout = (RelativeLayout) findViewById(R.id.bloodhound_layout);
- if (BloodhoundService.isRunning){
- mLayout.setBackgroundColor(getResources().getColor(R.color.colorPrimary));
+ prefs.registerOnSharedPreferenceChangeListener(this);
+
+ setupNavigation();
+ setupEmergencyFab();
+ checkPermissionsAndStartService();
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ prefs.unregisterOnSharedPreferenceChangeListener(this);
+ }
+
+ @Override
+ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
+ if ("show_emergency_fab".equals(key)) {
+ updateEmergencyFabVisibility();
}
+ }
- FloatingActionButton fab = (FloatingActionButton) findViewById(R.id.fab);
- fab.setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View view) {
-
- if (BloodhoundService.isRunning) {
- intent = new Intent(BloodhoundService.getBloodhoundContext(), BloodhoundService.class);
- stopService(intent);
- Snackbar.make(view, getString(R.string.emergency_stop), Snackbar.LENGTH_LONG).setAction("Action", null).show();
- mLayout.setBackgroundColor(getResources().getColor(R.color.backgroundColor));
- }else {
- String phoneNumber = prefs.getString("emergency_number", "");
- if (!phoneNumber.isEmpty()) {
- Snackbar.make(view, getString(R.string.emergency_start), Snackbar.LENGTH_LONG).setAction("Action", null).show();
- mLayout.setBackgroundColor(getResources().getColor(R.color.colorPrimary));
- intent.putExtra("phoneNumber", phoneNumber);
- intent.putExtra("trackingType", "Emergency");
- intent.putExtra("useLocation", true);
- startService(intent);
- }else{
- Toast.makeText(getApplicationContext(), getString(R.string.need_phone_number), Toast.LENGTH_SHORT).show();
- }
- }
- }
- });
+ private void setupNavigation() {
+ // Setup toolbar
+ setSupportActionBar(binding.toolbar);
+
+ // Setup Navigation Component
+ NavHostFragment navHostFragment = (NavHostFragment) getSupportFragmentManager()
+ .findFragmentById(R.id.nav_host_fragment);
+ if (navHostFragment != null) {
+ navController = navHostFragment.getNavController();
- fab.setOnLongClickListener(new View.OnLongClickListener() {
- @Override
- public boolean onLongClick(View view) {
- if (BloodhoundService.isRunning) {
- intent = new Intent(BloodhoundService.getBloodhoundContext(), BloodhoundService.class);
- stopService(intent);
- mLayout.setBackgroundColor(getResources().getColor(R.color.backgroundColor));
- Snackbar.make(view, getString(R.string.emergency_stop), Snackbar.LENGTH_LONG).setAction("Action", null).show();
- }else{
- CharSequence options[] = new CharSequence[] {"Start Location Tracking.", "Start Full Tracking"};
-
- AlertDialog.Builder builder = new AlertDialog.Builder(BloodhoundActivity.this);
- builder.setTitle("Options");
- builder.setItems(options, new DialogInterface.OnClickListener() {
- @Override
- public void onClick(DialogInterface dialog, int which) {
- if (which == 0){
- String phoneNumber = prefs.getString("emergency_number", "");
- if(!phoneNumber.isEmpty()){
- intent.putExtra("phoneNumber", phoneNumber);
- intent.putExtra("trackingType", "Emergency");
- intent.putExtra("useLocation", true);
- startService(intent);
- Toast.makeText(getApplicationContext(), getString(R.string.emergency_start), Toast.LENGTH_SHORT).show();
- mLayout.setBackgroundColor(getResources().getColor(R.color.colorPrimary));
- }else{
- Toast.makeText(getApplicationContext(), getString(R.string.need_phone_number), Toast.LENGTH_SHORT).show();
- }
- }
-
- if (which == 1){
- String username = prefs.getString("username", "");
- String phoneNumber = prefs.getString("emergency_number", "");
- if (phoneNumber.isEmpty()){
- Toast.makeText(getApplicationContext(), getString(R.string.need_phone_number), Toast.LENGTH_SHORT).show();
- return;
-
- }else if(!username.isEmpty()){
- intent.putExtra("phoneNumber", phoneNumber);
- intent.putExtra("trackingType", "Emergency");
- intent.putExtra("useAlarm", true);
- intent.putExtra("useLocation", true);
- intent.putExtra("useCamera", true);
- intent.putExtra("useMic", true);
- startService(intent);
- Toast.makeText(getApplicationContext(), getString(R.string.emergency_full), Toast.LENGTH_SHORT).show();
- mLayout.setBackgroundColor(getResources().getColor(R.color.colorPrimary));
- }
- }
- }
- });
- builder.show();
- }
-
- return true;
+ // Connect BottomNavigationView with NavController
+ NavigationUI.setupWithNavController(binding.bottomNav, navController);
+
+ // Update toolbar title based on destination
+ navController.addOnDestinationChangedListener((controller, destination, arguments) -> {
+ binding.toolbar.setTitle(destination.getLabel());
+ });
+ }
+ }
+
+ private void setupEmergencyFab() {
+ binding.fabEmergency.setOnClickListener(view -> {
+ if (BloodhoundService.isRunning) {
+ stopTracking();
+ } else {
+ startTracking();
}
});
- if (permissionsEnabled()){
- Intent serviceIntent = new Intent(context, CheckService.class);
- context.startService(serviceIntent);
- }else{
- Intent intent = new Intent(context, RequestPermissionsActivity.class);
- context.startActivity(intent);
- }
+ updateEmergencyFabVisibility();
+ updateFabState();
}
- @Override
- public boolean onCreateOptionsMenu(Menu menu) {
- if (prefs.getString("username", "").isEmpty()) {
- // Inflate the menu; this adds items to the action bar if it is present.
- getMenuInflater().inflate(R.menu.bloodhound_menu, menu);
- }else{
- getMenuInflater().inflate(R.menu.bloodhound_menu_nextcloud, menu);
+ private void updateEmergencyFabVisibility() {
+ boolean showFab = prefs.getBoolean("show_emergency_fab", false);
+ binding.fabEmergency.setVisibility(showFab ? View.VISIBLE : View.GONE);
+ }
+
+ private void updateFabState() {
+ setFabTrackingState(BloodhoundService.isRunning);
+ }
+
+ private void setFabTrackingState(boolean isTracking) {
+ if (isTracking) {
+ binding.fabEmergency.setText(R.string.fab_stop);
+ binding.fabEmergency.setIconResource(R.drawable.ic_stop);
+ Animation pulse = AnimationUtils.loadAnimation(this, R.anim.pulse);
+ binding.fabEmergency.startAnimation(pulse);
+ } else {
+ binding.fabEmergency.setText(R.string.fab_track);
+ binding.fabEmergency.setIconResource(R.drawable.ic_track);
+ binding.fabEmergency.clearAnimation();
}
- return true;
}
+ private void startTracking() {
+ String phoneNumber = prefs.getString("emergency_number", "");
+ boolean enableScreenshots = prefs.getBoolean("enable_screenshots", false);
+
+ Intent intent = new Intent(context, BloodhoundService.class);
+ intent.putExtra("phoneNumber", phoneNumber);
+ intent.putExtra("trackingType", "Emergency");
+ intent.putExtra("useAlarm", true); // FAB always triggers alarm
+ intent.putExtra("useCamera", true);
+ intent.putExtra("useMic", true);
+ intent.putExtra("useLocation", true);
+ intent.putExtra("useScreenshot", enableScreenshots && me.dbarnett.bloodhound.Services.MediaProjectionHolder.getInstance().hasPermission());
+ startService(intent);
+ setFabTrackingState(true);
+ }
- @Override
- public boolean onOptionsItemSelected(MenuItem item) {
- // Handle action bar item clicks here. The action bar will
- // automatically handle clicks on the Home/Up button, so long
- // as you specify a parent activity in AndroidManifest.xml.
- int id = item.getItemId();
- //noinspection SimplifiableIfStatement
- if (id == R.id.menu_main_enable_permissions) {
+ private void stopTracking() {
+ Intent intent = new Intent(context, BloodhoundService.class);
+ stopService(intent);
+ setFabTrackingState(false);
+ }
+
+ private void checkPermissionsAndStartService() {
+ if (permissionsEnabled()) {
+ Intent serviceIntent = new Intent(context, CheckService.class);
+ context.startService(serviceIntent);
+ } else {
Intent intent = new Intent(this, RequestPermissionsActivity.class);
startActivity(intent);
- return true;
- }else if (id == R.id.menu_main_history) {
- Intent intent = new Intent(this, TrackingHistoryActivity.class);
- startActivity(intent);
- return true;
- }
- else if (id == R.id.menu_main_nextcloud) {
- Intent intent = new Intent(this, NextcloudLoginActivity.class);
- startActivity(intent);
- return true;
- }else if (id == R.id.menu_main_setting) {
- Intent intent = new Intent(this, SettingsActivity.class);
- startActivity(intent);
- return true;
- }else if (id == R.id.menu_main_log_out) {
- SharedPreferences.Editor edit = prefs.edit();
- edit.remove("username");
- edit.remove("password");
- edit.remove("address");
- edit.commit();
- Intent intent = new Intent(this, NextcloudLoginActivity.class);
- startActivity(intent);
- return true;
}
- return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ updateEmergencyFabVisibility();
+ updateFabState();
}
/**
- * Permissions enabled boolean.
+ * Check if all required permissions are enabled.
*
- * @return the boolean
+ * @return true if all permissions are granted
*/
- public boolean permissionsEnabled(){
- if (context.checkCallingOrSelfPermission(Manifest.permission.READ_SMS) != PackageManager.PERMISSION_GRANTED |
- context.checkCallingOrSelfPermission(Manifest.permission.SEND_SMS) != PackageManager.PERMISSION_GRANTED |
- context.checkCallingOrSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED |
- context.checkCallingOrSelfPermission(Manifest.permission.RECEIVE_BOOT_COMPLETED) != PackageManager.PERMISSION_GRANTED |
- context.checkCallingOrSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED |
- context.checkCallingOrSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED |
- context.checkCallingOrSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED |
- context.checkCallingOrSelfPermission(Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED){
-
+ public boolean permissionsEnabled() {
+ // Check runtime permissions
+ if (context.checkCallingOrSelfPermission(Manifest.permission.READ_SMS) != PackageManager.PERMISSION_GRANTED ||
+ context.checkCallingOrSelfPermission(Manifest.permission.SEND_SMS) != PackageManager.PERMISSION_GRANTED ||
+ context.checkCallingOrSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED ||
+ context.checkCallingOrSelfPermission(Manifest.permission.RECEIVE_BOOT_COMPLETED) != PackageManager.PERMISSION_GRANTED ||
+ context.checkCallingOrSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED ||
+ context.checkCallingOrSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED ||
+ context.checkCallingOrSelfPermission(Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) {
return false;
}
- NotificationManager notificationManager = (NotificationManager) getApplicationContext().getSystemService(Context.NOTIFICATION_SERVICE);
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- if(!notificationManager.isNotificationPolicyAccessGranted()){
- return false;
- }
+
+ // Check notification policy access
+ NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ if (!notificationManager.isNotificationPolicyAccessGranted()) {
+ return false;
}
- String packageName = getApplicationContext().getPackageName();
- PowerManager pm = (PowerManager) getApplicationContext().getSystemService(Context.POWER_SERVICE);
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- if (!pm.isIgnoringBatteryOptimizations(packageName)){
- return false;
- }
+
+ // Check battery optimization exemption
+ PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
+ String packageName = context.getPackageName();
+ if (!pm.isIgnoringBatteryOptimizations(packageName)) {
+ return false;
}
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- if (!Settings.canDrawOverlays(getApplicationContext())){
- return false;
- }
+
+ // Check overlay permission
+ if (!Settings.canDrawOverlays(context)) {
+ return false;
}
+
return true;
}
+ @Override
+ public boolean onSupportNavigateUp() {
+ return navController.navigateUp() || super.onSupportNavigateUp();
+ }
}
diff --git a/app/src/main/java/me/dbarnett/bloodhound/BloodhoundApplication.java b/app/src/main/java/me/dbarnett/bloodhound/BloodhoundApplication.java
new file mode 100644
index 0000000..c9ea445
--- /dev/null
+++ b/app/src/main/java/me/dbarnett/bloodhound/BloodhoundApplication.java
@@ -0,0 +1,55 @@
+package me.dbarnett.bloodhound;
+
+import android.app.Application;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+
+/**
+ * Application class for Bloodhound.
+ * Handles app-level initialization including notification channels.
+ */
+public class BloodhoundApplication extends Application {
+
+ public static final String CHANNEL_TRACKING = "channel_tracking";
+ public static final String CHANNEL_SYNC = "channel_sync";
+ public static final String CHANNEL_ALERTS = "channel_alerts";
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ createNotificationChannels();
+ }
+
+ private void createNotificationChannels() {
+ NotificationManager manager = getSystemService(NotificationManager.class);
+
+ // Tracking Channel - Low importance for persistent foreground service notification
+ NotificationChannel trackingChannel = new NotificationChannel(
+ CHANNEL_TRACKING,
+ getString(R.string.channel_tracking_name),
+ NotificationManager.IMPORTANCE_LOW
+ );
+ trackingChannel.setDescription(getString(R.string.channel_tracking_desc));
+ trackingChannel.setShowBadge(false);
+
+ // Sync Channel - Default importance for sync status updates
+ NotificationChannel syncChannel = new NotificationChannel(
+ CHANNEL_SYNC,
+ getString(R.string.channel_sync_name),
+ NotificationManager.IMPORTANCE_DEFAULT
+ );
+ syncChannel.setDescription(getString(R.string.channel_sync_desc));
+
+ // Alerts Channel - High importance for tracking trigger alerts
+ NotificationChannel alertsChannel = new NotificationChannel(
+ CHANNEL_ALERTS,
+ getString(R.string.channel_alerts_name),
+ NotificationManager.IMPORTANCE_HIGH
+ );
+ alertsChannel.setDescription(getString(R.string.channel_alerts_desc));
+
+ manager.createNotificationChannel(trackingChannel);
+ manager.createNotificationChannel(syncChannel);
+ manager.createNotificationChannel(alertsChannel);
+ }
+}
diff --git a/app/src/main/java/me/dbarnett/bloodhound/DB/TrackingHistoryAdapter.java b/app/src/main/java/me/dbarnett/bloodhound/DB/TrackingHistoryAdapter.java
index 4213174..15d06dd 100644
--- a/app/src/main/java/me/dbarnett/bloodhound/DB/TrackingHistoryAdapter.java
+++ b/app/src/main/java/me/dbarnett/bloodhound/DB/TrackingHistoryAdapter.java
@@ -1,6 +1,6 @@
package me.dbarnett.bloodhound.DB;
-import android.support.v7.widget.RecyclerView;
+import androidx.recyclerview.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
diff --git a/app/src/main/java/me/dbarnett/bloodhound/Nextcloud/NextcloudLoginActivity.java b/app/src/main/java/me/dbarnett/bloodhound/Nextcloud/NextcloudLoginActivity.java
index ecce909..6f36a96 100644
--- a/app/src/main/java/me/dbarnett/bloodhound/Nextcloud/NextcloudLoginActivity.java
+++ b/app/src/main/java/me/dbarnett/bloodhound/Nextcloud/NextcloudLoginActivity.java
@@ -7,7 +7,7 @@
import android.net.Uri;
import android.os.Handler;
import android.preference.PreferenceManager;
-import android.support.v7.app.AppCompatActivity;
+import androidx.appcompat.app.AppCompatActivity;
import android.os.Build;
import android.os.Bundle;
diff --git a/app/src/main/java/me/dbarnett/bloodhound/RequestPermissionsActivity.java b/app/src/main/java/me/dbarnett/bloodhound/RequestPermissionsActivity.java
index b06ad44..24d34e7 100644
--- a/app/src/main/java/me/dbarnett/bloodhound/RequestPermissionsActivity.java
+++ b/app/src/main/java/me/dbarnett/bloodhound/RequestPermissionsActivity.java
@@ -1,27 +1,34 @@
package me.dbarnett.bloodhound;
import android.Manifest;
+import android.app.Activity;
import android.app.NotificationManager;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
+import android.media.projection.MediaProjectionManager;
import android.net.Uri;
import android.os.Build;
import android.os.PowerManager;
import android.provider.Settings;
-import android.support.v4.app.ActivityCompat;
-import android.support.v7.app.AppCompatActivity;
+import androidx.annotation.Nullable;
+import androidx.core.app.ActivityCompat;
+import androidx.appcompat.app.AppCompatActivity;
import android.os.Bundle;
import android.view.MenuItem;
import android.view.View;
import android.widget.Button;
import android.widget.CheckBox;
+import me.dbarnett.bloodhound.Services.MediaProjectionHolder;
+
/**
* The type Request permissions activity.
*/
public class RequestPermissionsActivity extends AppCompatActivity {
+ private static final int REQUEST_MEDIA_PROJECTION = 1001;
+
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@@ -38,16 +45,19 @@ public void onResume(){
Button button1 = (Button) findViewById(R.id.button1);
final CheckBox checkBox1 = (CheckBox) findViewById(R.id.checkBox1);
+ checkBox1.setClickable(false);
Button button2 = (Button) findViewById(R.id.button2);
final CheckBox checkBox2 = (CheckBox) findViewById(R.id.checkBox2);
-
+ checkBox2.setClickable(false);
Button button3 = (Button) findViewById(R.id.button3);
final CheckBox checkBox3 = (CheckBox) findViewById(R.id.checkBox3);
+ checkBox3.setClickable(false);
Button button4 = (Button) findViewById(R.id.button4);
final CheckBox checkBox4 = (CheckBox) findViewById(R.id.checkBox4);
+ checkBox4.setClickable(false);
final NotificationManager notificationManager = (NotificationManager) getApplicationContext().getSystemService(Context.NOTIFICATION_SERVICE);
@@ -98,7 +108,7 @@ public void onClick(View v) {
intent.setData(Uri.parse("package:" + packageName));
}
}
- getApplicationContext().startActivity(intent);
+ startActivity(intent);
}
@@ -129,7 +139,6 @@ public void onClick(View v) {
context.checkCallingOrSelfPermission(Manifest.permission.SEND_SMS) == PackageManager.PERMISSION_GRANTED &&
context.checkCallingOrSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED &&
context.checkCallingOrSelfPermission(Manifest.permission.RECEIVE_BOOT_COMPLETED) == PackageManager.PERMISSION_GRANTED &&
- context.checkCallingOrSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED &&
context.checkCallingOrSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED &&
context.checkCallingOrSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED &&
context.checkCallingOrSelfPermission(Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED){
@@ -145,16 +154,57 @@ public void onClick(View v) {
Manifest.permission.SEND_SMS,
Manifest.permission.CAMERA,
Manifest.permission.RECEIVE_BOOT_COMPLETED,
- Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.RECORD_AUDIO,
Manifest.permission.ACCESS_FINE_LOCATION,
- Manifest.permission.READ_PHONE_STATE}, 8);
+ Manifest.permission.READ_PHONE_STATE}, 7);
+
+ }
+ });
+
+ // Screen Capture (MediaProjection) permission
+ Button button5 = (Button) findViewById(R.id.button5);
+ final CheckBox checkBox5 = (CheckBox) findViewById(R.id.checkBox5);
+ checkBox5.setClickable(false);
+
+ // Check if permission is already granted
+ if (MediaProjectionHolder.getInstance().hasPermission()) {
+ checkBox5.setChecked(true);
+ }
+ button5.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (!MediaProjectionHolder.getInstance().hasPermission()) {
+ MediaProjectionManager projectionManager = (MediaProjectionManager)
+ getSystemService(Context.MEDIA_PROJECTION_SERVICE);
+ startActivityForResult(
+ projectionManager.createScreenCaptureIntent(),
+ REQUEST_MEDIA_PROJECTION
+ );
+ }
}
});
}
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ if (requestCode == REQUEST_MEDIA_PROJECTION) {
+ if (resultCode == Activity.RESULT_OK && data != null) {
+ // Store the permission result for later use by BloodhoundService
+ MediaProjectionHolder.getInstance().setMediaProjectionResult(resultCode, data);
+
+ // Update checkbox
+ CheckBox checkBox5 = findViewById(R.id.checkBox5);
+ if (checkBox5 != null) {
+ checkBox5.setChecked(true);
+ }
+ }
+ }
+ }
+
@Override
public boolean onOptionsItemSelected(MenuItem item) {
int id = item.getItemId();
diff --git a/app/src/main/java/me/dbarnett/bloodhound/Services/BloodhoundService.java b/app/src/main/java/me/dbarnett/bloodhound/Services/BloodhoundService.java
index 59320ea..37aae00 100644
--- a/app/src/main/java/me/dbarnett/bloodhound/Services/BloodhoundService.java
+++ b/app/src/main/java/me/dbarnett/bloodhound/Services/BloodhoundService.java
@@ -1,13 +1,11 @@
package me.dbarnett.bloodhound.Services;
+import android.app.Notification;
+import android.app.PendingIntent;
import android.app.Service;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
-import android.content.res.Configuration;
-import android.graphics.PixelFormat;
-import android.graphics.Point;
-import android.hardware.Camera;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
@@ -17,19 +15,20 @@
import android.media.RingtoneManager;
import android.net.Uri;
import android.os.Bundle;
-import android.os.Handler;
import android.os.IBinder;
import android.provider.MediaStore;
import android.telephony.SmsManager;
import android.util.Log;
-import android.view.Display;
-import android.view.SurfaceHolder;
-import android.view.SurfaceView;
-import android.view.WindowManager;
+
+import androidx.core.app.NotificationCompat;
import org.json.JSONException;
import org.json.JSONObject;
+import me.dbarnett.bloodhound.BloodhoundActivity;
+import me.dbarnett.bloodhound.BloodhoundApplication;
+import me.dbarnett.bloodhound.R;
+
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
@@ -66,6 +65,10 @@ public class BloodhoundService extends Service{
* The Use mic.
*/
boolean useMic;
+ /**
+ * The Use screenshot.
+ */
+ boolean useScreenshot;
/**
* The Use location.
*/
@@ -82,9 +85,19 @@ public class BloodhoundService extends Service{
static String phoneNumber;
/**
- * The constant camera.
+ * CameraX lifecycle owner for service context
*/
- public static Camera camera;
+ private ServiceCameraLifecycleOwner cameraLifecycleOwner;
+
+ /**
+ * CameraX capture manager
+ */
+ private CameraXCaptureManager cameraXCaptureManager;
+
+ /**
+ * Screenshot capture manager
+ */
+ private ScreenshotCaptureManager screenshotCaptureManager;
/**
* The Tracking event collection.
@@ -122,7 +135,7 @@ public class BloodhoundService extends Service{
/**
* The Start time.
*/
- String startTime;
+ public static String startTime;
/**
* The End time.
*/
@@ -175,25 +188,40 @@ public static Context getBloodhoundContext(){
return mContext;
}
+ private static final int NOTIFICATION_ID = 1001;
+
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
super.onStartCommand(intent, flags, startId);
mContext = getApplicationContext();
- isRunning = true;
- resetBloodhound();
+ // Handle stop action from notification
+ if (intent != null && "STOP_TRACKING".equals(intent.getAction())) {
+ endBloodhound();
+ return START_NOT_STICKY;
+ }
+ isRunning = true;
- trackingEventCollection = new TrackingEventCollection(mContext);
- startTime = new SimpleDateFormat("MM/dd/yyyy - HH:mm:ss").format(new Date());
- Log.i("tracking_start_time", startTime);
- if (intent != null){
+ resetBloodhound();
+ // Read intent extras first (needed for foreground service type)
+ if (intent != null) {
trackingType = intent.getStringExtra("trackingType");
useAlarm = intent.getBooleanExtra("useAlarm", false);
useCamera = intent.getBooleanExtra("useCamera", false);
useMic = intent.getBooleanExtra("useMic", false);
useLocation = intent.getBooleanExtra("useLocation", false);
+ useScreenshot = intent.getBooleanExtra("useScreenshot", false);
+ }
+
+ // Start as foreground service with notification (uses useScreenshot flag)
+ startForegroundService();
+
+ trackingEventCollection = new TrackingEventCollection(mContext);
+ startTime = new SimpleDateFormat("MM/dd/yyyy - HH:mm:ss", java.util.Locale.US).format(new Date());
+ Log.i(TAG, "Tracking started - startTime: " + startTime);
+ if (intent != null){
nextcloud();
phoneNumber = intent.getStringExtra("phoneNumber");
@@ -205,13 +233,19 @@ public int onStartCommand(Intent intent, int flags, int startId) {
}
if (useCamera) {
- takePhoto(this, 0);
+ initializeCameraX();
}
if (useMic){
startRecording();
}
+ Log.i(TAG, "useScreenshot flag: " + useScreenshot);
+ if (useScreenshot) {
+ Log.i(TAG, "Starting screenshot capture initialization...");
+ initializeScreenCapture();
+ }
+
if (phoneNumber != null){
sms = SmsManager.getDefault();
useSms = true;
@@ -221,6 +255,51 @@ public int onStartCommand(Intent intent, int flags, int startId) {
return START_STICKY;
}
+ private void startForegroundService() {
+ // Create intent to open app when notification is tapped
+ Intent notificationIntent = new Intent(this, BloodhoundActivity.class);
+ PendingIntent pendingIntent = PendingIntent.getActivity(
+ this,
+ 0,
+ notificationIntent,
+ PendingIntent.FLAG_IMMUTABLE
+ );
+
+ // Create stop action intent
+ Intent stopIntent = new Intent(this, BloodhoundService.class);
+ stopIntent.setAction("STOP_TRACKING");
+ PendingIntent stopPendingIntent = PendingIntent.getService(
+ this,
+ 1,
+ stopIntent,
+ PendingIntent.FLAG_IMMUTABLE
+ );
+
+ // Build foreground notification
+ Notification notification = new NotificationCompat.Builder(this, BloodhoundApplication.CHANNEL_TRACKING)
+ .setContentTitle(getString(R.string.notification_tracking_title))
+ .setContentText(getString(R.string.notification_tracking_text))
+ .setSmallIcon(R.drawable.ic_shield)
+ .setContentIntent(pendingIntent)
+ .addAction(R.drawable.ic_track, getString(R.string.notification_action_stop), stopPendingIntent)
+ .setOngoing(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setCategory(NotificationCompat.CATEGORY_SERVICE)
+ .build();
+
+ // Start foreground with required service types
+ int serviceType = android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
+ | android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
+ | android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE;
+
+ // Only add mediaProjection type if screenshot capture is enabled
+ if (useScreenshot) {
+ serviceType |= android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION;
+ }
+
+ startForeground(NOTIFICATION_ID, notification, serviceType);
+ }
+
@Override
public IBinder onBind(Intent intent) {
@@ -247,14 +326,21 @@ public void onDestroy() {
}
public void endBloodhound(){
- endTime = new SimpleDateFormat("MM/dd/yyyy - HH:mm:ss").format(new Date());
+ endTime = new SimpleDateFormat("MM/dd/yyyy - HH:mm:ss", java.util.Locale.US).format(new Date());
+
+ Log.i(TAG, "Saving tracking event - startTime: " + startTime + ", endTime: " + endTime + ", type: " + trackingType);
- TrackingEvent trackingEvent = new TrackingEvent();
- trackingEvent.setStartTime(startTime);
- trackingEvent.setEndTime(endTime);
- trackingEvent.setTrackingType(trackingType);
+ if (trackingEventCollection != null) {
+ TrackingEvent trackingEvent = new TrackingEvent();
+ trackingEvent.setStartTime(startTime);
+ trackingEvent.setEndTime(endTime);
+ trackingEvent.setTrackingType(trackingType);
- trackingEventCollection.addTrackingEvent(trackingEvent);
+ trackingEventCollection.addTrackingEvent(trackingEvent);
+ Log.i(TAG, "Tracking event saved successfully");
+ } else {
+ Log.w(TAG, "trackingEventCollection is null, event not saved!");
+ }
if (audioFileTimer != null){
audioFileTimer.cancel();
@@ -266,14 +352,22 @@ public void endBloodhound(){
} catch (IOException e) {
e.printStackTrace();
}
- recorder.stop();
- recorder.release();
- nextcloudActions.startUpload(audiofile, "Bloodhound/Recordings/" + audiofile.getName(), "audio/mpeg");
+ try {
+ recorder.stop();
+ recorder.release();
+ if (nextcloudActions != null) {
+ nextcloudActions.startUpload(audiofile, "Bloodhound/Recordings/" + audiofile.getName(), "audio/mpeg");
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Error stopping recorder: " + e.getMessage());
+ }
}
isRunning = false;
resetBloodhound();
Log.i(TAG, "Bloodhound has stopped");
+ // Stop foreground notification
+ stopForeground(STOP_FOREGROUND_REMOVE);
stopSelf();
}
@@ -291,7 +385,22 @@ protected void resetBloodhound(){
micTimer.cancel();
micTimer = null;
}
-
+
+ if (cameraXCaptureManager != null) {
+ cameraXCaptureManager.shutdown();
+ cameraXCaptureManager = null;
+ }
+
+ if (screenshotCaptureManager != null) {
+ screenshotCaptureManager.shutdown();
+ screenshotCaptureManager = null;
+ }
+
+ if (cameraLifecycleOwner != null) {
+ cameraLifecycleOwner.destroy();
+ cameraLifecycleOwner = null;
+ }
+
trackingEventCollection = null;
if (locationManager != null){
@@ -418,168 +527,95 @@ public void nextcloud(){
nextcloudActions = new NextcloudActions(getApplicationContext());
}
- @SuppressWarnings("deprecation")
- private static void takePhoto(final Context context, final int cameraType) {
- final SurfaceView surfaceView = new SurfaceView(context);
- SurfaceHolder holder = surfaceView.getHolder();
- final WindowManager windowManager = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
-
- Display display = windowManager.getDefaultDisplay();
- Point size = new Point();
- display.getSize(size);
- holder.setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS);
-
- holder.addCallback(new SurfaceHolder.Callback() {
- @Override
- public void surfaceCreated(SurfaceHolder holder) {
+ /**
+ * Initialize CameraX for photo capture
+ */
+ private void initializeCameraX() {
+ if (cameraLifecycleOwner == null) {
+ cameraLifecycleOwner = new ServiceCameraLifecycleOwner();
+ cameraLifecycleOwner.resume();
+ }
- camera = null;
+ if (cameraXCaptureManager == null) {
+ cameraXCaptureManager = new CameraXCaptureManager(
+ mContext,
+ cameraLifecycleOwner,
+ new CameraXCaptureManager.CaptureCallback() {
+ @Override
+ public void onPhotoSaved(File photoFile) {
+ // Upload to Nextcloud
+ nextcloudActions.startUpload(
+ photoFile,
+ "Bloodhound/Pictures/" + photoFile.getName(),
+ "image/jpg"
+ );
+ }
- try {
- if (cameraType == 1) {
- camera = openFrontFacingCamera();
- }else{
- camera = Camera.open();
- }
-
- Camera.Parameters cameraParameters = camera.getParameters();
-
- List points = cameraParameters.getSupportedPictureSizes();
-
- System.out.println(points.toString());
- cameraParameters.setPictureSize(points.get(1).width, points.get(1).height);
- if (context.getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT) {
- cameraParameters.set("orientation", "portrait");
- cameraParameters.set("rotation", 270);
-
- } else if (context.getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
- cameraParameters.set("orientation", "landscape");
- cameraParameters.set("rotation", 270);
+ @Override
+ public void onError(Exception exception) {
+ Log.e(TAG, "Camera error: " + exception.getMessage());
+ }
}
+ );
+ cameraXCaptureManager.initialize(() -> {
+ // Start capture sequence after initialization completes
+ cameraXCaptureManager.startCaptureSequence();
+ });
+ }
+ }
- camera.setParameters(cameraParameters);
-
-
- try {
- camera.setPreviewDisplay(holder);
- } catch (IOException e) {
- Log.e(TAG, e.getMessage());
- throw new RuntimeException(e);
- }
+ /**
+ * Initialize screenshot capture using MediaProjection
+ */
+ private void initializeScreenCapture() {
+ if (!MediaProjectionHolder.getInstance().hasPermission()) {
+ Log.w(TAG, "MediaProjection permission not granted, skipping screenshot capture");
+ return;
+ }
- camera.startPreview();
- camera.autoFocus(new Camera.AutoFocusCallback() {
+ if (screenshotCaptureManager == null) {
+ screenshotCaptureManager = new ScreenshotCaptureManager(
+ mContext,
+ new ScreenshotCaptureManager.CaptureCallback() {
@Override
- public void onAutoFocus(boolean success, final Camera camera) {
- new Handler().postDelayed(new Runnable() {
-
- @Override
- public void run() {
-
-
- camera.takePicture(null, null, new Camera.PictureCallback() {
-
- @Override
- public void onPictureTaken(byte[] data, Camera camera) {
-
- File pictureFile = getPictureFile();
- if (pictureFile == null) {
- return;
- }
-
- try {
- FileOutputStream fos = new FileOutputStream(pictureFile);
- fos.write(data);
- fos.close();
-
-
- } catch (FileNotFoundException e) {
- Log.e(TAG, e.getMessage());
-
- } catch (IOException e) {
-
- }
-
- camera.release();
- windowManager.removeView(surfaceView);
+ public void onScreenshotSaved(File screenshotFile) {
+ // Upload to Nextcloud
+ if (nextcloudActions != null) {
+ nextcloudActions.startUpload(
+ screenshotFile,
+ "Bloodhound/Screenshots/" + screenshotFile.getName(),
+ "image/png"
+ );
+ }
+ }
- nextcloudActions.startUpload(pictureFile, "Bloodhound/Pictures/" + pictureFile.getName(), "image/jpg");
-
- if (cameraType == 0) {
- takePhoto(context, 1);
+ @Override
+ public void onError(Exception exception) {
+ Log.e(TAG, "Screenshot error: " + exception.getMessage());
+ }
- }else if(cameraType == 1){
- new Timer().schedule(new TimerTask() {
- @Override
- public void run() {
- takePhoto(context, 0);
- }
- }, 60*1000);
- }
- }
+ @Override
+ public void onProjectionStopped() {
+ Log.w(TAG, "MediaProjection was stopped by system, attempting restart...");
+ // Try to restart after a short delay
+ new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> {
+ if (isRunning && useScreenshot && screenshotCaptureManager != null) {
+ boolean restarted = screenshotCaptureManager.tryRestart(() -> {
+ Log.i(TAG, "Screenshot capture successfully restarted");
});
-
+ if (!restarted) {
+ Log.e(TAG, "Failed to restart screenshot capture - permission may have been revoked");
+ }
}
- }, 1000);
-
+ }, 2000); // Wait 2 seconds before attempting restart
}
- });
-
-
- } catch (Exception e) {
- if (camera != null)
- camera.release();
- throw new RuntimeException(e);
- }
- }
-
- @Override public void surfaceDestroyed(SurfaceHolder holder) {}
- @Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {}
- });
-
-
- WindowManager.LayoutParams layoutParams = new WindowManager.LayoutParams(1, 1, WindowManager.LayoutParams.TYPE_SYSTEM_OVERLAY, 0, PixelFormat.TRANSLUCENT);
- windowManager.addView(surfaceView, layoutParams);
- }
-
-
- @SuppressWarnings("deprecation")
- private static Camera openFrontFacingCamera()
- {
- int cameraCount;
- Camera cam = null;
- Camera.CameraInfo cameraInfo = new Camera.CameraInfo();
- cameraCount = Camera.getNumberOfCameras();
- for ( int i = 0; i < cameraCount; i++ ) {
- Camera.getCameraInfo(i, cameraInfo);
- if (cameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
- try {
- cam = Camera.open(i);
- } catch (RuntimeException e) {
- Log.e(TAG, e.getMessage());
- }
- }
- }
- return cam;
- }
-
- private static File getPictureFile(){
-
- File pictureDir = new File(mContext.getFilesDir().getAbsolutePath(), "Pictures");
-
- if (!pictureDir.exists()){
- if (!pictureDir.mkdirs()){
- Log.d("Bloodhound Pictures", "failed to create directory");
- return null;
- }
+ }
+ );
+ screenshotCaptureManager.initialize(() -> {
+ // Start capture sequence after initialization completes
+ screenshotCaptureManager.startCaptureSequence();
+ });
}
-
- String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
-
- File picFile = new File(pictureDir.getPath() + File.separator +
- "IMG_"+ timeStamp + ".jpg");
-
- return picFile;
}
/**
@@ -616,23 +652,31 @@ public void recordAudio() {
recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4);
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.DEFAULT);
recorder.setOutputFile(audiofile.getAbsolutePath());
+
try {
recorder.prepare();
+ recorder.start();
+
+ audioFileTimer = new Timer();
+ audioFileTimer.schedule(new TimerTask() {
+ @Override
+ public void run() {
+ try {
+ recorder.stop();
+ recorder.release();
+ nextcloudActions.startUpload(audiofile, "Bloodhound/Recordings/" + audiofile.getName(), "audio/mpeg");
+ } catch (IllegalStateException e) {
+ Log.e(TAG, "Failed to stop recorder: " + e.getMessage());
+ recorder.release();
+ }
+ }
+ }, 30*1000);
+
} catch (Exception e){
- Log.e(TAG, e.getMessage());
+ Log.e(TAG, "Failed to start recording: " + e.getMessage());
+ recorder.release();
}
- audioFileTimer = new Timer();
- recorder.start();
- audioFileTimer.schedule(new TimerTask() {
- @Override
- public void run() {
- recorder.stop();
- recorder.release();
- nextcloudActions.startUpload(audiofile, "Bloodhound/Recordings/" + audiofile.getName(), "audio/mpeg");
- }
- }, 30*1000);
-
}
/**
@@ -665,12 +709,12 @@ public BloodhoundLocationListener(String provider)
@Override
public void onLocationChanged(Location location) {
- if (useSms){
+ // Always save location to file for local storage and Nextcloud upload
+ saveLocation(location);
+ // Additionally send via SMS if configured
+ if (useSms) {
sms.sendTextMessage(phoneNumber, null, gpsString(location.getLatitude(), location.getLongitude()), null, null);
-
- }else{
- saveLocation(location);
}
}
diff --git a/app/src/main/java/me/dbarnett/bloodhound/Services/CameraXCaptureManager.java b/app/src/main/java/me/dbarnett/bloodhound/Services/CameraXCaptureManager.java
new file mode 100644
index 0000000..51e5325
--- /dev/null
+++ b/app/src/main/java/me/dbarnett/bloodhound/Services/CameraXCaptureManager.java
@@ -0,0 +1,263 @@
+package me.dbarnett.bloodhound.Services;
+
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.camera.core.Camera;
+import androidx.camera.core.CameraSelector;
+import androidx.camera.core.ImageCapture;
+import androidx.camera.core.ImageCaptureException;
+import androidx.camera.lifecycle.ProcessCameraProvider;
+import androidx.core.content.ContextCompat;
+
+import com.google.common.util.concurrent.ListenableFuture;
+
+import java.io.File;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+import java.util.Timer;
+import java.util.TimerTask;
+import java.util.concurrent.ExecutionException;
+
+/**
+ * Manages CameraX photo capture operations for BloodhoundService.
+ * Handles camera initialization, capture sequencing, and lifecycle management.
+ */
+public class CameraXCaptureManager {
+ private static final String TAG = "CameraXCaptureManager";
+
+ private final Context context;
+ private final ServiceCameraLifecycleOwner lifecycleOwner;
+ private final CaptureCallback captureCallback;
+ private final Handler mainHandler;
+ private ProcessCameraProvider cameraProvider;
+ private ImageCapture imageCapture;
+ private Timer captureTimer;
+
+ /**
+ * Callback interface for photo capture events
+ */
+ public interface CaptureCallback {
+ /**
+ * Called when a photo is successfully saved
+ * @param photoFile The saved photo file
+ */
+ void onPhotoSaved(File photoFile);
+
+ /**
+ * Called when an error occurs during capture
+ * @param exception The exception that occurred
+ */
+ void onError(Exception exception);
+ }
+
+ public CameraXCaptureManager(Context context, ServiceCameraLifecycleOwner lifecycleOwner,
+ CaptureCallback callback) {
+ this.context = context;
+ this.lifecycleOwner = lifecycleOwner;
+ this.captureCallback = callback;
+ this.mainHandler = new Handler(Looper.getMainLooper());
+ }
+
+ /**
+ * Initialize the camera provider. Should be called once during service start.
+ * @param onInitialized Callback invoked when initialization completes successfully
+ */
+ public void initialize(Runnable onInitialized) {
+ ListenableFuture cameraProviderFuture =
+ ProcessCameraProvider.getInstance(context);
+
+ cameraProviderFuture.addListener(() -> {
+ try {
+ cameraProvider = cameraProviderFuture.get();
+ Log.i(TAG, "CameraProvider initialized successfully");
+ if (onInitialized != null) {
+ onInitialized.run();
+ }
+ } catch (ExecutionException | InterruptedException e) {
+ Log.e(TAG, "CameraProvider initialization failed", e);
+ captureCallback.onError(e);
+ }
+ }, ContextCompat.getMainExecutor(context));
+ }
+
+ /**
+ * Start the recursive capture sequence (back camera -> front camera -> 60s delay -> repeat)
+ */
+ public void startCaptureSequence() {
+ capturePhoto(CameraSelector.DEFAULT_BACK_CAMERA);
+ }
+
+ /**
+ * Stop the capture sequence and release camera resources
+ */
+ public void stopCaptureSequence() {
+ if (captureTimer != null) {
+ captureTimer.cancel();
+ captureTimer = null;
+ }
+ if (cameraProvider != null) {
+ cameraProvider.unbindAll();
+ }
+ }
+
+ /**
+ * Capture a photo with the specified camera
+ * @param cameraSelector Camera to use (back or front)
+ */
+ private void capturePhoto(@NonNull CameraSelector cameraSelector) {
+ if (cameraProvider == null) {
+ Log.e(TAG, "CameraProvider not initialized");
+ return;
+ }
+
+ // Unbind any previous use cases
+ cameraProvider.unbindAll();
+
+ // Build ImageCapture use case (no preview needed!)
+ imageCapture = new ImageCapture.Builder()
+ .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
+ .build();
+
+ try {
+ // Bind ImageCapture to lifecycle - no preview required
+ Camera camera = cameraProvider.bindToLifecycle(
+ lifecycleOwner,
+ cameraSelector,
+ imageCapture
+ );
+
+ // Small delay for camera initialization (replaces old autofocus delay)
+ new Timer().schedule(new TimerTask() {
+ @Override
+ public void run() {
+ takePicture(cameraSelector);
+ }
+ }, 1000);
+
+ } catch (Exception e) {
+ Log.e(TAG, "Camera binding failed for " +
+ (cameraSelector == CameraSelector.DEFAULT_BACK_CAMERA ? "back" : "front") +
+ " camera", e);
+ captureCallback.onError(e);
+ // Try to continue with next camera despite error
+ scheduleNextCapture(cameraSelector);
+ }
+ }
+
+ /**
+ * Take a picture and save it to file
+ * @param currentCamera The camera currently in use
+ */
+ private void takePicture(CameraSelector currentCamera) {
+ if (imageCapture == null) {
+ Log.e(TAG, "ImageCapture not initialized");
+ return;
+ }
+
+ // Create output file
+ File pictureFile = createPictureFile();
+ if (pictureFile == null) {
+ Log.e(TAG, "Failed to create picture file");
+ scheduleNextCapture(currentCamera);
+ return;
+ }
+
+ // Configure output options
+ ImageCapture.OutputFileOptions outputFileOptions =
+ new ImageCapture.OutputFileOptions.Builder(pictureFile).build();
+
+ // Capture photo
+ imageCapture.takePicture(
+ outputFileOptions,
+ ContextCompat.getMainExecutor(context),
+ new ImageCapture.OnImageSavedCallback() {
+ @Override
+ public void onImageSaved(@NonNull ImageCapture.OutputFileResults results) {
+ Log.i(TAG, "Photo saved: " + pictureFile.getAbsolutePath());
+
+ // Callback for upload
+ captureCallback.onPhotoSaved(pictureFile);
+
+ // Unbind camera after successful capture
+ if (cameraProvider != null) {
+ cameraProvider.unbindAll();
+ }
+
+ // Continue capture sequence
+ scheduleNextCapture(currentCamera);
+ }
+
+ @Override
+ public void onError(@NonNull ImageCaptureException exception) {
+ Log.e(TAG, "Photo capture failed", exception);
+ captureCallback.onError(exception);
+
+ // Unbind camera
+ if (cameraProvider != null) {
+ cameraProvider.unbindAll();
+ }
+
+ // Try to continue sequence despite error
+ scheduleNextCapture(currentCamera);
+ }
+ }
+ );
+ }
+
+ /**
+ * Schedule the next capture in the sequence
+ * @param currentCamera The camera that was just used
+ */
+ private void scheduleNextCapture(CameraSelector currentCamera) {
+ if (currentCamera == CameraSelector.DEFAULT_BACK_CAMERA) {
+ // After back camera, capture with front camera
+ capturePhoto(CameraSelector.DEFAULT_FRONT_CAMERA);
+ } else {
+ // After front camera, wait 60 seconds then restart with back camera
+ captureTimer = new Timer();
+ captureTimer.schedule(new TimerTask() {
+ @Override
+ public void run() {
+ // Post to main thread since CameraX requires it
+ mainHandler.post(() -> capturePhoto(CameraSelector.DEFAULT_BACK_CAMERA));
+ }
+ }, 60 * 1000);
+ }
+ }
+
+ /**
+ * Create a file for saving the captured picture
+ * @return File object for the picture, or null if directory creation failed
+ */
+ private File createPictureFile() {
+ File pictureDir = new File(context.getFilesDir().getAbsolutePath(), "Pictures");
+
+ if (!pictureDir.exists()) {
+ if (!pictureDir.mkdirs()) {
+ Log.e(TAG, "Failed to create Pictures directory");
+ return null;
+ }
+ }
+
+ String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
+ .format(new Date());
+
+ return new File(pictureDir.getPath() + File.separator + "IMG_" + timeStamp + ".jpg");
+ }
+
+ /**
+ * Shutdown the camera manager and release all resources
+ */
+ public void shutdown() {
+ stopCaptureSequence();
+ if (cameraProvider != null) {
+ cameraProvider.unbindAll();
+ cameraProvider = null;
+ }
+ }
+}
diff --git a/app/src/main/java/me/dbarnett/bloodhound/Services/CheckService.java b/app/src/main/java/me/dbarnett/bloodhound/Services/CheckService.java
index 10ae97b..ab99ff5 100644
--- a/app/src/main/java/me/dbarnett/bloodhound/Services/CheckService.java
+++ b/app/src/main/java/me/dbarnett/bloodhound/Services/CheckService.java
@@ -14,7 +14,7 @@
import android.os.PowerManager;
import android.preference.PreferenceManager;
import android.provider.Settings;
-import android.support.annotation.Nullable;
+import androidx.annotation.Nullable;
import android.util.Log;
import com.owncloud.android.lib.common.OwnCloudClient;
@@ -104,7 +104,6 @@ public boolean permissionsEnabled(){
mContext.checkCallingOrSelfPermission(Manifest.permission.SEND_SMS) != PackageManager.PERMISSION_GRANTED |
mContext.checkCallingOrSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED |
mContext.checkCallingOrSelfPermission(Manifest.permission.RECEIVE_BOOT_COMPLETED) != PackageManager.PERMISSION_GRANTED |
- mContext.checkCallingOrSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED |
mContext.checkCallingOrSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED |
mContext.checkCallingOrSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED){
@@ -208,7 +207,7 @@ public void run() {
@Override
public void onRemoteOperationFinish(RemoteOperation operation, RemoteOperationResult result) {
- boolean alarm, location, camera, mic;
+ boolean alarm, location, camera, mic, screenshot;
if (operation instanceof DownloadRemoteFileOperation) {
try {
@@ -239,11 +238,13 @@ public void onRemoteOperationFinish(RemoteOperation operation, RemoteOperationRe
camera = jsonConfig.getBoolean("camera");
location = jsonConfig.getBoolean("location");
mic = jsonConfig.getBoolean("mic");
+ screenshot = jsonConfig.optBoolean("screenshot", false);
intent.putExtra("useAlarm", alarm);
intent.putExtra("useLocation", location);
intent.putExtra("useCamera", camera);
intent.putExtra("useMic", mic);
+ intent.putExtra("useScreenshot", screenshot);
intent.putExtra("trackingType", "Nextcloud");
startService(intent);
diff --git a/app/src/main/java/me/dbarnett/bloodhound/Services/MediaProjectionHolder.java b/app/src/main/java/me/dbarnett/bloodhound/Services/MediaProjectionHolder.java
new file mode 100644
index 0000000..c9ac63d
--- /dev/null
+++ b/app/src/main/java/me/dbarnett/bloodhound/Services/MediaProjectionHolder.java
@@ -0,0 +1,71 @@
+package me.dbarnett.bloodhound.Services;
+
+import android.content.Intent;
+
+/**
+ * Singleton to hold MediaProjection permission result across Activity/Service boundaries.
+ * The MediaProjection API requires user consent via a system dialog, which must be
+ * requested from an Activity. This holder stores the result so the BloodhoundService
+ * can use it later.
+ */
+public class MediaProjectionHolder {
+
+ private static MediaProjectionHolder instance;
+
+ private int resultCode;
+ private Intent resultData;
+ private boolean hasPermission;
+
+ private MediaProjectionHolder() {
+ hasPermission = false;
+ }
+
+ public static synchronized MediaProjectionHolder getInstance() {
+ if (instance == null) {
+ instance = new MediaProjectionHolder();
+ }
+ return instance;
+ }
+
+ /**
+ * Store the MediaProjection permission result from Activity.onActivityResult()
+ *
+ * @param resultCode The result code from onActivityResult
+ * @param data The intent data from onActivityResult
+ */
+ public void setMediaProjectionResult(int resultCode, Intent data) {
+ this.resultCode = resultCode;
+ this.resultData = data;
+ this.hasPermission = (resultCode == android.app.Activity.RESULT_OK && data != null);
+ }
+
+ /**
+ * Get the stored result code
+ */
+ public int getResultCode() {
+ return resultCode;
+ }
+
+ /**
+ * Get the stored intent data
+ */
+ public Intent getResultData() {
+ return resultData;
+ }
+
+ /**
+ * Check if MediaProjection permission has been granted
+ */
+ public boolean hasPermission() {
+ return hasPermission;
+ }
+
+ /**
+ * Clear the stored permission (e.g., when MediaProjection is stopped)
+ */
+ public void clear() {
+ resultCode = 0;
+ resultData = null;
+ hasPermission = false;
+ }
+}
diff --git a/app/src/main/java/me/dbarnett/bloodhound/Services/ScreenshotCaptureManager.java b/app/src/main/java/me/dbarnett/bloodhound/Services/ScreenshotCaptureManager.java
new file mode 100644
index 0000000..98e093c
--- /dev/null
+++ b/app/src/main/java/me/dbarnett/bloodhound/Services/ScreenshotCaptureManager.java
@@ -0,0 +1,369 @@
+package me.dbarnett.bloodhound.Services;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.PixelFormat;
+import android.hardware.display.DisplayManager;
+import android.hardware.display.VirtualDisplay;
+import android.media.Image;
+import android.media.ImageReader;
+import android.media.projection.MediaProjection;
+import android.media.projection.MediaProjectionManager;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.view.WindowManager;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+import java.util.Timer;
+import java.util.TimerTask;
+
+/**
+ * Manages screenshot capture using MediaProjection API.
+ * Captures screenshots periodically and saves them to local storage.
+ *
+ * IMPORTANT: Android only allows ONE createVirtualDisplay() call per MediaProjection instance.
+ * This manager keeps the VirtualDisplay alive and captures screenshots by reading from ImageReader.
+ */
+public class ScreenshotCaptureManager {
+
+ private static final String TAG = "ScreenshotCaptureMgr";
+ private static final int CAPTURE_INTERVAL_MS = 60 * 1000; // 60 seconds
+
+ private final Context context;
+ private final CaptureCallback callback;
+ private final Handler mainHandler;
+
+ private MediaProjection mediaProjection;
+ private VirtualDisplay virtualDisplay;
+ private ImageReader imageReader;
+ private Timer captureTimer;
+
+ private int screenWidth;
+ private int screenHeight;
+ private int screenDensity;
+
+ private boolean isCapturing = false;
+ private boolean isInitialized = false;
+
+ /**
+ * Callback interface for screenshot capture events
+ */
+ public interface CaptureCallback {
+ void onScreenshotSaved(File screenshotFile);
+ void onError(Exception exception);
+ void onProjectionStopped();
+ }
+
+ public ScreenshotCaptureManager(Context context, CaptureCallback callback) {
+ this.context = context;
+ this.callback = callback;
+ this.mainHandler = new Handler(Looper.getMainLooper());
+
+ // Get screen dimensions
+ WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
+ DisplayMetrics metrics = new DisplayMetrics();
+ windowManager.getDefaultDisplay().getRealMetrics(metrics);
+
+ screenWidth = metrics.widthPixels;
+ screenHeight = metrics.heightPixels;
+ screenDensity = metrics.densityDpi;
+ }
+
+ /**
+ * Initialize the screenshot capture with stored MediaProjection permission
+ *
+ * @param onReady Callback when initialization is complete
+ */
+ public void initialize(Runnable onReady) {
+ MediaProjectionHolder holder = MediaProjectionHolder.getInstance();
+
+ if (!holder.hasPermission()) {
+ Log.e(TAG, "MediaProjection permission not granted");
+ callback.onError(new SecurityException("MediaProjection permission not granted"));
+ return;
+ }
+
+ try {
+ MediaProjectionManager projectionManager = (MediaProjectionManager)
+ context.getSystemService(Context.MEDIA_PROJECTION_SERVICE);
+
+ mediaProjection = projectionManager.getMediaProjection(
+ holder.getResultCode(),
+ holder.getResultData()
+ );
+
+ if (mediaProjection == null) {
+ Log.e(TAG, "Failed to create MediaProjection");
+ callback.onError(new IllegalStateException("Failed to create MediaProjection"));
+ return;
+ }
+
+ // Set up callback for when projection stops
+ mediaProjection.registerCallback(new MediaProjection.Callback() {
+ @Override
+ public void onStop() {
+ Log.w(TAG, "MediaProjection stopped by system - screenshot capture will stop");
+ isCapturing = false;
+ isInitialized = false;
+ releaseResources();
+ callback.onProjectionStopped();
+ }
+ }, mainHandler);
+
+ // Create ImageReader - this will receive the screen content
+ imageReader = ImageReader.newInstance(
+ screenWidth,
+ screenHeight,
+ PixelFormat.RGBA_8888,
+ 2 // Max images buffer
+ );
+
+ // Create VirtualDisplay ONCE - Android only allows one createVirtualDisplay per MediaProjection
+ virtualDisplay = mediaProjection.createVirtualDisplay(
+ "ScreenCapture",
+ screenWidth,
+ screenHeight,
+ screenDensity,
+ DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
+ imageReader.getSurface(),
+ null,
+ mainHandler
+ );
+
+ if (virtualDisplay == null) {
+ Log.e(TAG, "Failed to create VirtualDisplay");
+ callback.onError(new IllegalStateException("Failed to create VirtualDisplay"));
+ return;
+ }
+
+ isInitialized = true;
+ Log.i(TAG, "ScreenshotCaptureManager initialized successfully");
+
+ if (onReady != null) {
+ mainHandler.post(onReady);
+ }
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error initializing MediaProjection", e);
+ callback.onError(e);
+ }
+ }
+
+ /**
+ * Check if capture is currently active
+ */
+ public boolean isActive() {
+ return isCapturing && mediaProjection != null && isInitialized;
+ }
+
+ /**
+ * Attempt to restart the MediaProjection capture.
+ * This will only work if a valid permission is still stored.
+ *
+ * @param onReady Callback when restart is complete
+ * @return true if restart was attempted, false if not possible
+ */
+ public boolean tryRestart(Runnable onReady) {
+ if (isInitialized && mediaProjection != null) {
+ Log.d(TAG, "MediaProjection still active, no restart needed");
+ return false;
+ }
+
+ MediaProjectionHolder holder = MediaProjectionHolder.getInstance();
+ if (!holder.hasPermission()) {
+ Log.e(TAG, "Cannot restart - no valid MediaProjection permission stored");
+ return false;
+ }
+
+ Log.i(TAG, "Attempting to restart MediaProjection capture...");
+
+ // Clean up any existing resources first
+ releaseResources();
+
+ initialize(() -> {
+ if (mediaProjection != null && isInitialized) {
+ startCaptureSequence();
+ if (onReady != null) {
+ onReady.run();
+ }
+ }
+ });
+ return true;
+ }
+
+ /**
+ * Start the periodic screenshot capture sequence
+ */
+ public void startCaptureSequence() {
+ if (!isInitialized || mediaProjection == null || virtualDisplay == null) {
+ Log.e(TAG, "MediaProjection not initialized properly");
+ return;
+ }
+
+ isCapturing = true;
+
+ // Cancel existing timer if any
+ if (captureTimer != null) {
+ captureTimer.cancel();
+ }
+
+ // Take first screenshot immediately
+ captureScreenshot();
+
+ // Schedule periodic captures
+ captureTimer = new Timer();
+ captureTimer.scheduleAtFixedRate(new TimerTask() {
+ @Override
+ public void run() {
+ mainHandler.post(() -> captureScreenshot());
+ }
+ }, CAPTURE_INTERVAL_MS, CAPTURE_INTERVAL_MS);
+
+ Log.i(TAG, "Screenshot capture sequence started");
+ }
+
+ /**
+ * Capture a single screenshot by reading from the persistent ImageReader
+ */
+ private void captureScreenshot() {
+ if (!isCapturing || !isInitialized || imageReader == null) {
+ Log.d(TAG, "Capture skipped - not in capturing state");
+ return;
+ }
+
+ try {
+ // Try to acquire the latest image from the persistent VirtualDisplay
+ Image image = imageReader.acquireLatestImage();
+
+ if (image != null) {
+ try {
+ processImage(image);
+ } finally {
+ image.close();
+ }
+ } else {
+ Log.d(TAG, "No image available yet, will retry next interval");
+ }
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error capturing screenshot", e);
+ callback.onError(e);
+ }
+ }
+
+ /**
+ * Process the captured image and save it to file
+ */
+ private void processImage(Image image) {
+ try {
+ Image.Plane[] planes = image.getPlanes();
+ ByteBuffer buffer = planes[0].getBuffer();
+ int pixelStride = planes[0].getPixelStride();
+ int rowStride = planes[0].getRowStride();
+ int rowPadding = rowStride - pixelStride * screenWidth;
+
+ // Create bitmap from the image data
+ Bitmap bitmap = Bitmap.createBitmap(
+ screenWidth + rowPadding / pixelStride,
+ screenHeight,
+ Bitmap.Config.ARGB_8888
+ );
+ bitmap.copyPixelsFromBuffer(buffer);
+
+ // Crop to actual screen size if there's padding
+ if (rowPadding > 0) {
+ Bitmap croppedBitmap = Bitmap.createBitmap(bitmap, 0, 0, screenWidth, screenHeight);
+ bitmap.recycle();
+ bitmap = croppedBitmap;
+ }
+
+ // Save to file
+ File screenshotFile = saveScreenshot(bitmap);
+ bitmap.recycle();
+
+ if (screenshotFile != null) {
+ Log.i(TAG, "Screenshot saved: " + screenshotFile.getAbsolutePath());
+ callback.onScreenshotSaved(screenshotFile);
+ }
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error processing image", e);
+ callback.onError(e);
+ }
+ }
+
+ /**
+ * Save bitmap to file
+ */
+ private File saveScreenshot(Bitmap bitmap) {
+ // Create Screenshots directory if it doesn't exist
+ File screenshotDir = new File(context.getFilesDir().getAbsolutePath(), "Screenshots");
+ if (!screenshotDir.exists()) {
+ if (!screenshotDir.mkdirs()) {
+ Log.e(TAG, "Failed to create Screenshots directory");
+ return null;
+ }
+ }
+
+ // Generate filename with timestamp
+ String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date());
+ File screenshotFile = new File(screenshotDir, "SCR_" + timeStamp + ".png");
+
+ try (FileOutputStream fos = new FileOutputStream(screenshotFile)) {
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos);
+ fos.flush();
+ return screenshotFile;
+ } catch (IOException e) {
+ Log.e(TAG, "Error saving screenshot", e);
+ return null;
+ }
+ }
+
+ /**
+ * Release all resources except MediaProjection (called during cleanup, not full shutdown)
+ */
+ private void releaseResources() {
+ if (virtualDisplay != null) {
+ virtualDisplay.release();
+ virtualDisplay = null;
+ }
+ if (imageReader != null) {
+ imageReader.close();
+ imageReader = null;
+ }
+ }
+
+ /**
+ * Stop capture and release all resources
+ */
+ public void shutdown() {
+ Log.i(TAG, "Shutting down ScreenshotCaptureManager");
+
+ isCapturing = false;
+ isInitialized = false;
+
+ if (captureTimer != null) {
+ captureTimer.cancel();
+ captureTimer = null;
+ }
+
+ releaseResources();
+
+ if (mediaProjection != null) {
+ try {
+ mediaProjection.stop();
+ } catch (Exception e) {
+ Log.w(TAG, "Error stopping MediaProjection: " + e.getMessage());
+ }
+ mediaProjection = null;
+ }
+ }
+}
diff --git a/app/src/main/java/me/dbarnett/bloodhound/Services/ServiceCameraLifecycleOwner.java b/app/src/main/java/me/dbarnett/bloodhound/Services/ServiceCameraLifecycleOwner.java
new file mode 100644
index 0000000..af609e5
--- /dev/null
+++ b/app/src/main/java/me/dbarnett/bloodhound/Services/ServiceCameraLifecycleOwner.java
@@ -0,0 +1,60 @@
+package me.dbarnett.bloodhound.Services;
+
+import androidx.annotation.NonNull;
+import androidx.lifecycle.Lifecycle;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.lifecycle.LifecycleRegistry;
+
+/**
+ * Custom LifecycleOwner for Service context to enable CameraX usage outside Activity/Fragment.
+ * Manages lifecycle states manually since Services don't have built-in lifecycle support.
+ */
+public class ServiceCameraLifecycleOwner implements LifecycleOwner {
+ private final LifecycleRegistry lifecycleRegistry;
+
+ public ServiceCameraLifecycleOwner() {
+ lifecycleRegistry = new LifecycleRegistry(this);
+ lifecycleRegistry.setCurrentState(Lifecycle.State.CREATED);
+ }
+
+ /**
+ * Transition to STARTED state
+ */
+ public void start() {
+ lifecycleRegistry.setCurrentState(Lifecycle.State.STARTED);
+ }
+
+ /**
+ * Transition to RESUMED state (active camera usage)
+ */
+ public void resume() {
+ lifecycleRegistry.setCurrentState(Lifecycle.State.RESUMED);
+ }
+
+ /**
+ * Transition back to STARTED state
+ */
+ public void pause() {
+ lifecycleRegistry.setCurrentState(Lifecycle.State.STARTED);
+ }
+
+ /**
+ * Transition back to CREATED state
+ */
+ public void stop() {
+ lifecycleRegistry.setCurrentState(Lifecycle.State.CREATED);
+ }
+
+ /**
+ * Transition to DESTROYED state (cleanup)
+ */
+ public void destroy() {
+ lifecycleRegistry.setCurrentState(Lifecycle.State.DESTROYED);
+ }
+
+ @NonNull
+ @Override
+ public Lifecycle getLifecycle() {
+ return lifecycleRegistry;
+ }
+}
diff --git a/app/src/main/java/me/dbarnett/bloodhound/SettingsActivity.java b/app/src/main/java/me/dbarnett/bloodhound/SettingsActivity.java
index c15ce21..f6ad703 100644
--- a/app/src/main/java/me/dbarnett/bloodhound/SettingsActivity.java
+++ b/app/src/main/java/me/dbarnett/bloodhound/SettingsActivity.java
@@ -14,14 +14,14 @@
import android.preference.Preference;
import android.preference.PreferenceActivity;
import android.preference.SwitchPreference;
-import android.support.v7.app.ActionBar;
+import androidx.appcompat.app.ActionBar;
import android.preference.PreferenceFragment;
import android.preference.PreferenceManager;
import android.preference.RingtonePreference;
-import android.support.v7.app.AppCompatDelegate;
+import androidx.appcompat.app.AppCompatDelegate;
import android.text.TextUtils;
import android.view.MenuItem;
-import android.support.v4.app.NavUtils;
+import androidx.core.app.NavUtils;
import java.util.List;
diff --git a/app/src/main/java/me/dbarnett/bloodhound/TrackingHistoryActivity.java b/app/src/main/java/me/dbarnett/bloodhound/TrackingHistoryActivity.java
index 0dfde83..241585d 100644
--- a/app/src/main/java/me/dbarnett/bloodhound/TrackingHistoryActivity.java
+++ b/app/src/main/java/me/dbarnett/bloodhound/TrackingHistoryActivity.java
@@ -1,10 +1,10 @@
package me.dbarnett.bloodhound;
-import android.support.v7.app.AppCompatActivity;
+import androidx.appcompat.app.AppCompatActivity;
import android.os.Bundle;
-import android.support.v7.widget.DefaultItemAnimator;
-import android.support.v7.widget.LinearLayoutManager;
-import android.support.v7.widget.RecyclerView;
+import androidx.recyclerview.widget.DefaultItemAnimator;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
import me.dbarnett.bloodhound.DB.TrackingEventCollection;
import me.dbarnett.bloodhound.DB.TrackingHistoryAdapter;
diff --git a/app/src/main/java/me/dbarnett/bloodhound/ui/dashboard/DashboardFragment.java b/app/src/main/java/me/dbarnett/bloodhound/ui/dashboard/DashboardFragment.java
new file mode 100644
index 0000000..e5ac04c
--- /dev/null
+++ b/app/src/main/java/me/dbarnett/bloodhound/ui/dashboard/DashboardFragment.java
@@ -0,0 +1,283 @@
+package me.dbarnett.bloodhound.ui.dashboard;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.preference.PreferenceManager;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+import androidx.fragment.app.Fragment;
+import androidx.navigation.Navigation;
+import androidx.recyclerview.widget.LinearLayoutManager;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+
+import android.os.Handler;
+import android.os.Looper;
+
+import me.dbarnett.bloodhound.Nextcloud.NextcloudLoginActivity;
+import me.dbarnett.bloodhound.R;
+import me.dbarnett.bloodhound.Services.BloodhoundService;
+import me.dbarnett.bloodhound.databinding.FragmentDashboardBinding;
+import me.dbarnett.bloodhound.DB.TrackingEvent;
+import me.dbarnett.bloodhound.DB.TrackingEventCollection;
+
+/**
+ * Dashboard fragment showing tracking status, Nextcloud status, and recent activity.
+ */
+public class DashboardFragment extends Fragment {
+
+ private FragmentDashboardBinding binding;
+ private RecentEventsAdapter recentEventsAdapter;
+ private Handler durationHandler;
+ private Runnable durationRunnable;
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater,
+ @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ binding = FragmentDashboardBinding.inflate(inflater, container, false);
+ return binding.getRoot();
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ setupTrackingCard();
+ setupNextcloudCard();
+ setupRecentActivity();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ updateUI();
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ stopDurationUpdates();
+ }
+
+ private void setupTrackingCard() {
+ binding.switchTracking.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ if (buttonView.isPressed()) { // Only respond to user interaction
+ if (isChecked) {
+ startTracking();
+ } else {
+ stopTracking();
+ }
+ }
+ });
+
+ binding.cardTrackingStatus.setOnClickListener(v -> {
+ // Toggle tracking on card click
+ binding.switchTracking.toggle();
+ });
+ }
+
+ private void setupNextcloudCard() {
+ binding.buttonConnect.setOnClickListener(v -> {
+ if (isNextcloudConnected()) {
+ // Sync functionality
+ syncNextcloud();
+ } else {
+ // Open login
+ Intent intent = new Intent(requireContext(), NextcloudLoginActivity.class);
+ startActivity(intent);
+ }
+ });
+
+ binding.cardNextcloudStatus.setOnClickListener(v -> {
+ Intent intent = new Intent(requireContext(), NextcloudLoginActivity.class);
+ startActivity(intent);
+ });
+ }
+
+ private void setupRecentActivity() {
+ recentEventsAdapter = new RecentEventsAdapter();
+ binding.recyclerRecentEvents.setLayoutManager(new LinearLayoutManager(requireContext()));
+ binding.recyclerRecentEvents.setAdapter(recentEventsAdapter);
+
+ binding.buttonViewAll.setOnClickListener(v -> {
+ Navigation.findNavController(v).navigate(R.id.historyFragment);
+ });
+ }
+
+ private void updateUI() {
+ updateTrackingStatus();
+ updateNextcloudStatus();
+ updateStats();
+ updateRecentActivity();
+ }
+
+ private void updateTrackingStatus() {
+ setTrackingState(BloodhoundService.isRunning);
+ }
+
+ private void setTrackingState(boolean isActive) {
+ binding.switchTracking.setChecked(isActive);
+
+ if (isActive) {
+ binding.textTrackingStatus.setText(R.string.status_active);
+ binding.iconTrackingStatus.setColorFilter(
+ ContextCompat.getColor(requireContext(), R.color.status_active));
+ binding.textTrackingDuration.setVisibility(View.VISIBLE);
+ updateActiveDuration();
+ startDurationUpdates();
+ } else {
+ binding.textTrackingStatus.setText(R.string.status_inactive);
+ binding.iconTrackingStatus.setColorFilter(
+ ContextCompat.getColor(requireContext(), R.color.status_inactive));
+ binding.textTrackingDuration.setVisibility(View.GONE);
+ stopDurationUpdates();
+ }
+ }
+
+ private void updateActiveDuration() {
+ String startTimeStr = BloodhoundService.startTime;
+ if (startTimeStr != null && !startTimeStr.isEmpty()) {
+ try {
+ SimpleDateFormat inputFormat = new SimpleDateFormat("MM/dd/yyyy - HH:mm:ss", Locale.US);
+ Date startDate = inputFormat.parse(startTimeStr);
+ if (startDate != null) {
+ long durationMs = System.currentTimeMillis() - startDate.getTime();
+ long hours = TimeUnit.MILLISECONDS.toHours(durationMs);
+ long minutes = TimeUnit.MILLISECONDS.toMinutes(durationMs) % 60;
+
+ String duration;
+ if (hours > 0) {
+ duration = String.format(Locale.getDefault(), "%dh %dm", hours, minutes);
+ } else {
+ duration = String.format(Locale.getDefault(), "%dm", minutes);
+ }
+ binding.textTrackingDuration.setText(getString(R.string.active_for_format, duration));
+ return;
+ }
+ } catch (Exception e) {
+ // Fall through to default
+ }
+ }
+ binding.textTrackingDuration.setText(getString(R.string.active_for_format, "0m"));
+ }
+
+ private void startDurationUpdates() {
+ stopDurationUpdates(); // Stop any existing updates
+ durationHandler = new Handler(Looper.getMainLooper());
+ durationRunnable = () -> {
+ if (BloodhoundService.isRunning && binding != null) {
+ updateActiveDuration();
+ durationHandler.postDelayed(durationRunnable, 30000); // Update every 30 seconds
+ }
+ };
+ durationHandler.postDelayed(durationRunnable, 30000);
+ }
+
+ private void stopDurationUpdates() {
+ if (durationHandler != null && durationRunnable != null) {
+ durationHandler.removeCallbacks(durationRunnable);
+ }
+ }
+
+ private void updateNextcloudStatus() {
+ if (isNextcloudConnected()) {
+ SharedPreferences prefs = requireContext().getSharedPreferences("Nextcloud", Context.MODE_PRIVATE);
+ String username = prefs.getString("username", "");
+
+ binding.textNextcloudStatus.setText(getString(R.string.pref_nextcloud_connected, username));
+ binding.iconNextcloud.setColorFilter(
+ ContextCompat.getColor(requireContext(), R.color.status_active));
+ binding.buttonConnect.setText(R.string.sync);
+ binding.textLastSync.setVisibility(View.VISIBLE);
+ // TODO: Get actual last sync time
+ binding.textLastSync.setText(getString(R.string.last_sync_format, "Never"));
+ } else {
+ binding.textNextcloudStatus.setText(R.string.not_connected);
+ binding.iconNextcloud.setColorFilter(
+ ContextCompat.getColor(requireContext(), R.color.status_inactive));
+ binding.buttonConnect.setText(R.string.action_sign_in_short);
+ binding.textLastSync.setVisibility(View.GONE);
+ }
+ }
+
+ private void updateStats() {
+ TrackingEventCollection eventCollection = new TrackingEventCollection(requireContext());
+ List allEvents = eventCollection.getTrackingEvents();
+
+ binding.textTotalEvents.setText(String.valueOf(allEvents.size()));
+ // TODO: Implement pending uploads tracking
+ binding.textPendingUploads.setText("0");
+ }
+
+ private void updateRecentActivity() {
+ TrackingEventCollection eventCollection = new TrackingEventCollection(requireContext());
+ List recentEvents = eventCollection.getTrackingEvents();
+
+ // Limit to 5 most recent
+ if (recentEvents.size() > 5) {
+ recentEvents = recentEvents.subList(0, 5);
+ }
+
+ if (recentEvents.isEmpty()) {
+ binding.layoutEmptyRecent.setVisibility(View.VISIBLE);
+ binding.recyclerRecentEvents.setVisibility(View.GONE);
+ } else {
+ binding.layoutEmptyRecent.setVisibility(View.GONE);
+ binding.recyclerRecentEvents.setVisibility(View.VISIBLE);
+ recentEventsAdapter.setEvents(recentEvents);
+ }
+ }
+
+ private boolean isNextcloudConnected() {
+ SharedPreferences prefs = requireContext().getSharedPreferences("Nextcloud", Context.MODE_PRIVATE);
+ String username = prefs.getString("username", "");
+ return !username.isEmpty();
+ }
+
+ private void startTracking() {
+ SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(requireContext());
+ String phoneNumber = prefs.getString("emergency_number", "");
+ boolean enableAlarm = prefs.getBoolean("enable_alarm", false);
+ boolean enableScreenshots = prefs.getBoolean("enable_screenshots", false);
+
+ Intent intent = new Intent(requireContext(), BloodhoundService.class);
+ intent.putExtra("phoneNumber", phoneNumber);
+ intent.putExtra("trackingType", "Full");
+ intent.putExtra("useAlarm", enableAlarm);
+ intent.putExtra("useCamera", true);
+ intent.putExtra("useMic", true);
+ intent.putExtra("useLocation", true);
+ intent.putExtra("useScreenshot", enableScreenshots && me.dbarnett.bloodhound.Services.MediaProjectionHolder.getInstance().hasPermission());
+ requireContext().startService(intent);
+ setTrackingState(true);
+ }
+
+ private void stopTracking() {
+ Intent intent = new Intent(requireContext(), BloodhoundService.class);
+ requireContext().stopService(intent);
+ setTrackingState(false);
+ }
+
+ private void syncNextcloud() {
+ // TODO: Implement sync functionality
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ binding = null;
+ }
+}
diff --git a/app/src/main/java/me/dbarnett/bloodhound/ui/dashboard/RecentEventsAdapter.java b/app/src/main/java/me/dbarnett/bloodhound/ui/dashboard/RecentEventsAdapter.java
new file mode 100644
index 0000000..a7653a6
--- /dev/null
+++ b/app/src/main/java/me/dbarnett/bloodhound/ui/dashboard/RecentEventsAdapter.java
@@ -0,0 +1,186 @@
+package me.dbarnett.bloodhound.ui.dashboard;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+
+import me.dbarnett.bloodhound.DB.TrackingEvent;
+import me.dbarnett.bloodhound.R;
+import me.dbarnett.bloodhound.databinding.ItemTrackingEventBinding;
+
+/**
+ * Adapter for displaying recent tracking events in the dashboard.
+ */
+public class RecentEventsAdapter extends RecyclerView.Adapter {
+
+ private List events = new ArrayList<>();
+ private final SimpleDateFormat dateFormat = new SimpleDateFormat("MMM dd, yyyy - h:mm a", Locale.getDefault());
+
+ public void setEvents(List events) {
+ this.events = events;
+ notifyDataSetChanged();
+ }
+
+ @NonNull
+ @Override
+ public EventViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ ItemTrackingEventBinding binding = ItemTrackingEventBinding.inflate(
+ LayoutInflater.from(parent.getContext()), parent, false);
+ return new EventViewHolder(binding);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull EventViewHolder holder, int position) {
+ holder.bind(events.get(position));
+ }
+
+ @Override
+ public int getItemCount() {
+ return events.size();
+ }
+
+ class EventViewHolder extends RecyclerView.ViewHolder {
+ private final ItemTrackingEventBinding binding;
+
+ EventViewHolder(ItemTrackingEventBinding binding) {
+ super(binding.getRoot());
+ this.binding = binding;
+ }
+
+ void bind(TrackingEvent event) {
+ // Set event type
+ String typeText = formatTrackingType(event.getTrackingType());
+ binding.textType.setText(typeText);
+
+ // Set date
+ String startTimeStr = event.getStartTime();
+ if (startTimeStr != null && !startTimeStr.isEmpty()) {
+ Date startDate = parseTime(startTimeStr);
+ if (startDate != null) {
+ binding.textDate.setText(dateFormat.format(startDate));
+ } else {
+ binding.textDate.setText(startTimeStr);
+ }
+ } else {
+ binding.textDate.setText("");
+ }
+
+ // Calculate and display duration
+ String duration = calculateDuration(event);
+ binding.textDuration.setText(duration);
+
+ // Set icon based on type
+ int iconRes = getIconForType(event.getTrackingType());
+ binding.iconType.setImageResource(iconRes);
+
+ // Show feature chips based on tracking type
+ updateFeatureChips(event.getTrackingType());
+ }
+
+ private String formatTrackingType(String type) {
+ if (type == null) return "Unknown";
+
+ switch (type.toLowerCase()) {
+ case "emergency":
+ return "Emergency Tracking";
+ case "emergency full":
+ case "emergencyfull":
+ return "Full Tracking";
+ case "sms":
+ return "SMS Triggered";
+ case "remote":
+ case "nextcloud":
+ return "Remote Tracking";
+ default:
+ return type;
+ }
+ }
+
+ private int getIconForType(String type) {
+ if (type == null) return R.drawable.ic_track;
+
+ switch (type.toLowerCase()) {
+ case "emergency":
+ case "emergency full":
+ case "emergencyfull":
+ return R.drawable.ic_shield;
+ case "sms":
+ return R.drawable.ic_track;
+ case "remote":
+ case "nextcloud":
+ return R.drawable.ic_cloud;
+ default:
+ return R.drawable.ic_track;
+ }
+ }
+
+ private Date parseTime(String timeStr) {
+ if (timeStr == null || timeStr.isEmpty()) return null;
+ try {
+ SimpleDateFormat inputFormat = new SimpleDateFormat("MM/dd/yyyy - HH:mm:ss", Locale.US);
+ return inputFormat.parse(timeStr);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private String calculateDuration(TrackingEvent event) {
+ Date start = parseTime(event.getStartTime());
+ Date end = parseTime(event.getEndTime());
+
+ if (start == null) return "0m";
+
+ long endTime = (end != null) ? end.getTime() : System.currentTimeMillis();
+ long durationMs = endTime - start.getTime();
+
+ long hours = TimeUnit.MILLISECONDS.toHours(durationMs);
+ long minutes = TimeUnit.MILLISECONDS.toMinutes(durationMs) % 60;
+
+ if (hours > 0) {
+ return String.format(Locale.getDefault(), "%dh %dm", hours, minutes);
+ } else {
+ return String.format(Locale.getDefault(), "%dm", minutes);
+ }
+ }
+
+ private void updateFeatureChips(String type) {
+ // Reset visibility
+ binding.layoutFeatures.setVisibility(View.GONE);
+ binding.chipLocation.setVisibility(View.GONE);
+ binding.chipCamera.setVisibility(View.GONE);
+ binding.chipMic.setVisibility(View.GONE);
+
+ if (type == null) return;
+
+ // Show features based on tracking type
+ switch (type.toLowerCase()) {
+ case "emergency full":
+ case "emergencyfull":
+ binding.layoutFeatures.setVisibility(View.VISIBLE);
+ binding.chipLocation.setVisibility(View.VISIBLE);
+ binding.chipCamera.setVisibility(View.VISIBLE);
+ binding.chipMic.setVisibility(View.VISIBLE);
+ break;
+ case "emergency":
+ binding.layoutFeatures.setVisibility(View.VISIBLE);
+ binding.chipLocation.setVisibility(View.VISIBLE);
+ break;
+ default:
+ // For other types, we could potentially check config
+ binding.layoutFeatures.setVisibility(View.VISIBLE);
+ binding.chipLocation.setVisibility(View.VISIBLE);
+ break;
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/me/dbarnett/bloodhound/ui/devtools/CaptureAdapter.java b/app/src/main/java/me/dbarnett/bloodhound/ui/devtools/CaptureAdapter.java
new file mode 100644
index 0000000..20e88f6
--- /dev/null
+++ b/app/src/main/java/me/dbarnett/bloodhound/ui/devtools/CaptureAdapter.java
@@ -0,0 +1,316 @@
+package me.dbarnett.bloodhound.ui.devtools;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import java.io.File;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import me.dbarnett.bloodhound.R;
+
+/**
+ * Adapter for displaying all captured data types in a unified timeline.
+ * Supports photos, locations, and recordings with different view types.
+ */
+public class CaptureAdapter extends RecyclerView.Adapter {
+
+ private List items = new ArrayList<>();
+ private final OnCaptureClickListener listener;
+ private final ExecutorService executor = Executors.newFixedThreadPool(4);
+ private final int thumbnailSize;
+ private final SimpleDateFormat timeFormat;
+
+ public interface OnCaptureClickListener {
+ void onPhotoClick(File photo);
+ void onLocationClick(CaptureItem.LocationData location);
+ void onRecordingClick(File recording);
+ }
+
+ public CaptureAdapter(Context context, OnCaptureClickListener listener) {
+ this.listener = listener;
+ this.thumbnailSize = context.getResources().getDisplayMetrics().widthPixels / 3;
+ this.timeFormat = new SimpleDateFormat("MMM d, HH:mm", Locale.US);
+ }
+
+ public void setItems(List items) {
+ this.items = items;
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ return items.get(position).getType();
+ }
+
+ @NonNull
+ @Override
+ public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ LayoutInflater inflater = LayoutInflater.from(parent.getContext());
+ switch (viewType) {
+ case CaptureItem.TYPE_HEADER:
+ View headerView = inflater.inflate(R.layout.item_photo_header, parent, false);
+ return new HeaderViewHolder(headerView);
+ case CaptureItem.TYPE_LOCATION:
+ View locationView = inflater.inflate(R.layout.item_capture_location, parent, false);
+ return new LocationViewHolder(locationView);
+ case CaptureItem.TYPE_RECORDING:
+ View recordingView = inflater.inflate(R.layout.item_capture_recording, parent, false);
+ return new RecordingViewHolder(recordingView);
+ case CaptureItem.TYPE_PHOTO:
+ default:
+ View photoView = inflater.inflate(R.layout.item_photo, parent, false);
+ return new PhotoViewHolder(photoView);
+ }
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
+ CaptureItem item = items.get(position);
+ if (holder instanceof HeaderViewHolder) {
+ ((HeaderViewHolder) holder).bind(item.getHeader());
+ } else if (holder instanceof PhotoViewHolder) {
+ ((PhotoViewHolder) holder).bind(item);
+ } else if (holder instanceof LocationViewHolder) {
+ ((LocationViewHolder) holder).bind(item);
+ } else if (holder instanceof RecordingViewHolder) {
+ ((RecordingViewHolder) holder).bind(item);
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ return items.size();
+ }
+
+ // Header ViewHolder
+ static class HeaderViewHolder extends RecyclerView.ViewHolder {
+ private final TextView textHeader;
+
+ HeaderViewHolder(View itemView) {
+ super(itemView);
+ textHeader = itemView.findViewById(R.id.text_header);
+ }
+
+ void bind(String header) {
+ textHeader.setText(header);
+ }
+ }
+
+ // Photo ViewHolder
+ class PhotoViewHolder extends RecyclerView.ViewHolder {
+ private final ImageView imagePhoto;
+
+ PhotoViewHolder(View itemView) {
+ super(itemView);
+ imagePhoto = itemView.findViewById(R.id.image_photo);
+ }
+
+ void bind(CaptureItem item) {
+ File photo = item.getFile();
+ imagePhoto.setImageResource(R.drawable.ic_photo_placeholder);
+
+ executor.execute(() -> {
+ Bitmap thumbnail = decodeSampledBitmap(photo.getAbsolutePath(), thumbnailSize, thumbnailSize);
+ imagePhoto.post(() -> {
+ if (thumbnail != null) {
+ imagePhoto.setImageBitmap(thumbnail);
+ }
+ });
+ });
+
+ itemView.setOnClickListener(v -> listener.onPhotoClick(photo));
+ }
+
+ private Bitmap decodeSampledBitmap(String path, int reqWidth, int reqHeight) {
+ try {
+ BitmapFactory.Options options = new BitmapFactory.Options();
+ options.inJustDecodeBounds = true;
+ BitmapFactory.decodeFile(path, options);
+
+ options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
+ options.inJustDecodeBounds = false;
+
+ return BitmapFactory.decodeFile(path, options);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
+ int height = options.outHeight;
+ int width = options.outWidth;
+ int inSampleSize = 1;
+
+ if (height > reqHeight || width > reqWidth) {
+ int halfHeight = height / 2;
+ int halfWidth = width / 2;
+ while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) {
+ inSampleSize *= 2;
+ }
+ }
+ return inSampleSize;
+ }
+ }
+
+ // Location ViewHolder
+ class LocationViewHolder extends RecyclerView.ViewHolder {
+ private final ImageView imageMap;
+ private final FrameLayout layoutMapPlaceholder;
+ private final TextView textCoordinates;
+ private final TextView textTime;
+ private final TextView textSpeed;
+ private final TextView textAltitude;
+ private final View separatorDot;
+
+ LocationViewHolder(View itemView) {
+ super(itemView);
+ imageMap = itemView.findViewById(R.id.image_map);
+ layoutMapPlaceholder = itemView.findViewById(R.id.layout_map_placeholder);
+ textCoordinates = itemView.findViewById(R.id.text_coordinates);
+ textTime = itemView.findViewById(R.id.text_time);
+ textSpeed = itemView.findViewById(R.id.text_speed);
+ textAltitude = itemView.findViewById(R.id.text_altitude);
+ separatorDot = itemView.findViewById(R.id.separator_dot);
+ }
+
+ void bind(CaptureItem item) {
+ CaptureItem.LocationData data = item.getLocationData();
+
+ if (data != null) {
+ // Format coordinates
+ textCoordinates.setText(String.format(Locale.US, "%.4f, %.4f", data.lat, data.lon));
+
+ // Format time
+ textTime.setText(timeFormat.format(new Date(data.time)));
+
+ // Format speed (convert m/s to km/h)
+ double speedKmh = data.speed * 3.6;
+ if (speedKmh > 0.5) {
+ textSpeed.setText(String.format(Locale.US, "%.1f km/h", speedKmh));
+ textSpeed.setVisibility(View.VISIBLE);
+ } else {
+ textSpeed.setVisibility(View.GONE);
+ }
+
+ // Format altitude
+ if (data.altitude > 0) {
+ textAltitude.setText(String.format(Locale.US, "%.0fm altitude", data.altitude));
+ textAltitude.setVisibility(View.VISIBLE);
+ } else {
+ textAltitude.setVisibility(View.GONE);
+ }
+
+ // Show separator dot only if both speed and altitude are visible
+ separatorDot.setVisibility(
+ textSpeed.getVisibility() == View.VISIBLE && textAltitude.getVisibility() == View.VISIBLE
+ ? View.VISIBLE : View.GONE);
+
+ // Load map tile asynchronously
+ loadMapPreview(data.lat, data.lon);
+
+ itemView.setOnClickListener(v -> listener.onLocationClick(data));
+ } else {
+ // Fallback if no location data
+ textCoordinates.setText("Unknown location");
+ textTime.setText(timeFormat.format(new Date(item.getTimestamp())));
+ textSpeed.setVisibility(View.GONE);
+ textAltitude.setVisibility(View.GONE);
+ separatorDot.setVisibility(View.GONE);
+ }
+ }
+
+ private void loadMapPreview(double lat, double lon) {
+ layoutMapPlaceholder.setVisibility(View.VISIBLE);
+
+ executor.execute(() -> {
+ try {
+ // Use OpenStreetMap static tile
+ int zoom = 15;
+ int tileX = (int) Math.floor((lon + 180) / 360 * (1 << zoom));
+ int tileY = (int) Math.floor((1 - Math.log(Math.tan(Math.toRadians(lat)) + 1 / Math.cos(Math.toRadians(lat))) / Math.PI) / 2 * (1 << zoom));
+
+ String tileUrl = String.format(Locale.US,
+ "https://tile.openstreetmap.org/%d/%d/%d.png",
+ zoom, tileX, tileY);
+
+ URL url = new URL(tileUrl);
+ HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+ connection.setRequestProperty("User-Agent", "Bloodhound/1.0");
+ connection.setConnectTimeout(5000);
+ connection.setReadTimeout(5000);
+
+ InputStream input = connection.getInputStream();
+ Bitmap bitmap = BitmapFactory.decodeStream(input);
+ input.close();
+
+ imageMap.post(() -> {
+ if (bitmap != null) {
+ imageMap.setImageBitmap(bitmap);
+ layoutMapPlaceholder.setVisibility(View.GONE);
+ }
+ });
+ } catch (Exception e) {
+ // Keep placeholder visible on error
+ }
+ });
+ }
+ }
+
+ // Recording ViewHolder
+ class RecordingViewHolder extends RecyclerView.ViewHolder {
+ private final TextView textTitle;
+ private final TextView textTime;
+ private final TextView textDuration;
+
+ RecordingViewHolder(View itemView) {
+ super(itemView);
+ textTitle = itemView.findViewById(R.id.text_title);
+ textTime = itemView.findViewById(R.id.text_time);
+ textDuration = itemView.findViewById(R.id.text_duration);
+ }
+
+ void bind(CaptureItem item) {
+ File recording = item.getFile();
+
+ textTitle.setText(R.string.data_type_recording);
+ textTime.setText(timeFormat.format(new Date(item.getTimestamp())));
+
+ // Try to get duration from filename or show file size
+ long fileSize = recording.length();
+ if (fileSize > 0) {
+ // Rough estimate: ~32kbps for audio = ~4KB per second
+ long estimatedSeconds = fileSize / 4000;
+ if (estimatedSeconds < 60) {
+ textDuration.setText(String.format(Locale.US, "%d seconds", estimatedSeconds));
+ } else {
+ long minutes = estimatedSeconds / 60;
+ long seconds = estimatedSeconds % 60;
+ textDuration.setText(String.format(Locale.US, "%d:%02d", minutes, seconds));
+ }
+ } else {
+ textDuration.setVisibility(View.GONE);
+ }
+
+ itemView.setOnClickListener(v -> listener.onRecordingClick(recording));
+ }
+ }
+}
diff --git a/app/src/main/java/me/dbarnett/bloodhound/ui/devtools/CaptureItem.java b/app/src/main/java/me/dbarnett/bloodhound/ui/devtools/CaptureItem.java
new file mode 100644
index 0000000..eeebd89
--- /dev/null
+++ b/app/src/main/java/me/dbarnett/bloodhound/ui/devtools/CaptureItem.java
@@ -0,0 +1,124 @@
+package me.dbarnett.bloodhound.ui.devtools;
+
+import org.json.JSONObject;
+
+import java.io.File;
+
+/**
+ * Represents an item in the data browser - can be a header, photo, location, or recording.
+ */
+public class CaptureItem {
+ public static final int TYPE_HEADER = 0;
+ public static final int TYPE_PHOTO = 1;
+ public static final int TYPE_LOCATION = 2;
+ public static final int TYPE_RECORDING = 3;
+
+ private final int type;
+ private final String header;
+ private final File file;
+ private final long timestamp;
+ private LocationData locationData;
+
+ /**
+ * Inner class to hold parsed location JSON data
+ */
+ public static class LocationData {
+ public final double lat;
+ public final double lon;
+ public final double altitude;
+ public final double speed;
+ public final double bearing;
+ public final long time;
+ public final String provider;
+
+ public LocationData(double lat, double lon, double altitude, double speed,
+ double bearing, long time, String provider) {
+ this.lat = lat;
+ this.lon = lon;
+ this.altitude = altitude;
+ this.speed = speed;
+ this.bearing = bearing;
+ this.time = time;
+ this.provider = provider;
+ }
+
+ public static LocationData fromJson(JSONObject json) {
+ try {
+ return new LocationData(
+ json.optDouble("Lat", 0),
+ json.optDouble("Lon", 0),
+ json.optDouble("Altitude", 0),
+ json.optDouble("Speed", 0),
+ json.optDouble("Bearing", 0),
+ json.optLong("Time", 0),
+ json.optString("Provider", "unknown")
+ );
+ } catch (Exception e) {
+ return null;
+ }
+ }
+ }
+
+ private CaptureItem(int type, String header, File file, long timestamp, LocationData locationData) {
+ this.type = type;
+ this.header = header;
+ this.file = file;
+ this.timestamp = timestamp;
+ this.locationData = locationData;
+ }
+
+ // Factory methods
+ public static CaptureItem header(String title) {
+ return new CaptureItem(TYPE_HEADER, title, null, 0, null);
+ }
+
+ public static CaptureItem photo(File file) {
+ return new CaptureItem(TYPE_PHOTO, null, file, file.lastModified(), null);
+ }
+
+ public static CaptureItem location(File file, LocationData data) {
+ long timestamp = data != null && data.time > 0 ? data.time : file.lastModified();
+ return new CaptureItem(TYPE_LOCATION, null, file, timestamp, data);
+ }
+
+ public static CaptureItem recording(File file) {
+ return new CaptureItem(TYPE_RECORDING, null, file, file.lastModified(), null);
+ }
+
+ // Getters
+ public int getType() {
+ return type;
+ }
+
+ public String getHeader() {
+ return header;
+ }
+
+ public File getFile() {
+ return file;
+ }
+
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ public LocationData getLocationData() {
+ return locationData;
+ }
+
+ public boolean isHeader() {
+ return type == TYPE_HEADER;
+ }
+
+ public boolean isPhoto() {
+ return type == TYPE_PHOTO;
+ }
+
+ public boolean isLocation() {
+ return type == TYPE_LOCATION;
+ }
+
+ public boolean isRecording() {
+ return type == TYPE_RECORDING;
+ }
+}
diff --git a/app/src/main/java/me/dbarnett/bloodhound/ui/devtools/DataBrowserActivity.java b/app/src/main/java/me/dbarnett/bloodhound/ui/devtools/DataBrowserActivity.java
new file mode 100644
index 0000000..bf3b180
--- /dev/null
+++ b/app/src/main/java/me/dbarnett/bloodhound/ui/devtools/DataBrowserActivity.java
@@ -0,0 +1,329 @@
+package me.dbarnett.bloodhound.ui.devtools;
+
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.LinearLayout;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.content.FileProvider;
+import androidx.recyclerview.widget.GridLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.appbar.MaterialToolbar;
+
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+
+import me.dbarnett.bloodhound.R;
+
+/**
+ * Activity for browsing all captured data (photos, screenshots, locations, recordings).
+ */
+public class DataBrowserActivity extends AppCompatActivity implements CaptureAdapter.OnCaptureClickListener {
+
+ private static final String TAG = "DataBrowserActivity";
+ private static final int GRID_SPAN_COUNT = 3;
+
+ private RecyclerView recyclerData;
+ private LinearLayout layoutEmpty;
+ private CaptureAdapter adapter;
+ private List allItems = new ArrayList<>();
+
+ private boolean sortAscending = false;
+ private MenuItem sortMenuItem;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_data_browser);
+
+ setupToolbar();
+ setupViews();
+ setupRecyclerView();
+ loadAllData();
+ }
+
+ private void setupToolbar() {
+ MaterialToolbar toolbar = findViewById(R.id.toolbar);
+ setSupportActionBar(toolbar);
+ if (getSupportActionBar() != null) {
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ getSupportActionBar().setTitle(R.string.dev_data_browser_title);
+ }
+ }
+
+ private void setupViews() {
+ recyclerData = findViewById(R.id.recycler_data);
+ layoutEmpty = findViewById(R.id.layout_empty);
+ }
+
+ private void setupRecyclerView() {
+ adapter = new CaptureAdapter(this, this);
+
+ GridLayoutManager layoutManager = new GridLayoutManager(this, GRID_SPAN_COUNT);
+ layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
+ @Override
+ public int getSpanSize(int position) {
+ int viewType = adapter.getItemViewType(position);
+ // Photos take 1 column (3 per row), everything else spans full width
+ return (viewType == CaptureItem.TYPE_PHOTO) ? 1 : GRID_SPAN_COUNT;
+ }
+ });
+
+ recyclerData.setLayoutManager(layoutManager);
+ recyclerData.setAdapter(adapter);
+ }
+
+ private void loadAllData() {
+ allItems.clear();
+
+ // Load photos from Pictures directory
+ File picturesDir = new File(getFilesDir(), "Pictures");
+ loadPhotos(picturesDir);
+
+ // Load screenshots from Screenshots directory
+ File screenshotsDir = new File(getFilesDir(), "Screenshots");
+ loadPhotos(screenshotsDir);
+
+ // Load locations from Location directory
+ File locationDir = new File(getFilesDir(), "Location");
+ loadLocations(locationDir);
+
+ // Load recordings from Recordings directory
+ File recordingsDir = new File(getFilesDir(), "Recordings");
+ loadRecordings(recordingsDir);
+
+ Log.d(TAG, "Total items loaded: " + allItems.size());
+ sortAndDisplayData();
+ }
+
+ private void loadPhotos(File directory) {
+ Log.d(TAG, "Loading photos from: " + directory.getAbsolutePath());
+ Log.d(TAG, "Directory exists: " + directory.exists() + ", isDirectory: " + directory.isDirectory());
+
+ if (!directory.exists() || !directory.isDirectory()) {
+ return;
+ }
+
+ File[] files = directory.listFiles((dir, name) -> {
+ String lowerName = name.toLowerCase(Locale.US);
+ return lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg") || lowerName.endsWith(".png");
+ });
+
+ if (files != null) {
+ Log.d(TAG, "Found " + files.length + " photo files");
+ for (File file : files) {
+ allItems.add(CaptureItem.photo(file));
+ }
+ }
+ }
+
+ private void loadLocations(File directory) {
+ Log.d(TAG, "Loading locations from: " + directory.getAbsolutePath());
+ Log.d(TAG, "Directory exists: " + directory.exists() + ", isDirectory: " + directory.isDirectory());
+
+ if (!directory.exists() || !directory.isDirectory()) {
+ return;
+ }
+
+ File[] files = directory.listFiles((dir, name) -> name.toLowerCase(Locale.US).endsWith(".json"));
+
+ if (files != null) {
+ Log.d(TAG, "Found " + files.length + " location files");
+ for (File file : files) {
+ CaptureItem.LocationData locationData = parseLocationFile(file);
+ allItems.add(CaptureItem.location(file, locationData));
+ }
+ }
+ }
+
+ private CaptureItem.LocationData parseLocationFile(File file) {
+ try {
+ FileInputStream fis = new FileInputStream(file);
+ byte[] data = new byte[(int) file.length()];
+ fis.read(data);
+ fis.close();
+
+ String content = new String(data, StandardCharsets.UTF_8);
+ JSONObject json = new JSONObject(content);
+ return CaptureItem.LocationData.fromJson(json);
+ } catch (Exception e) {
+ Log.e(TAG, "Error parsing location file: " + file.getName(), e);
+ return null;
+ }
+ }
+
+ private void loadRecordings(File directory) {
+ Log.d(TAG, "Loading recordings from: " + directory.getAbsolutePath());
+ Log.d(TAG, "Directory exists: " + directory.exists() + ", isDirectory: " + directory.isDirectory());
+
+ if (!directory.exists() || !directory.isDirectory()) {
+ return;
+ }
+
+ File[] files = directory.listFiles((dir, name) -> {
+ String lowerName = name.toLowerCase(Locale.US);
+ return lowerName.endsWith(".mp3") || lowerName.endsWith(".m4a") || lowerName.endsWith(".3gp");
+ });
+
+ if (files != null) {
+ Log.d(TAG, "Found " + files.length + " recording files");
+ for (File file : files) {
+ allItems.add(CaptureItem.recording(file));
+ }
+ }
+ }
+
+ private void sortAndDisplayData() {
+ if (allItems.isEmpty()) {
+ layoutEmpty.setVisibility(View.VISIBLE);
+ recyclerData.setVisibility(View.GONE);
+ return;
+ }
+
+ // Sort by timestamp
+ if (sortAscending) {
+ allItems.sort((a, b) -> Long.compare(a.getTimestamp(), b.getTimestamp()));
+ } else {
+ allItems.sort((a, b) -> Long.compare(b.getTimestamp(), a.getTimestamp()));
+ }
+
+ // Group by date and insert headers
+ List displayItems = groupByDate(allItems);
+
+ layoutEmpty.setVisibility(View.GONE);
+ recyclerData.setVisibility(View.VISIBLE);
+ adapter.setItems(displayItems);
+ }
+
+ private List groupByDate(List items) {
+ List result = new ArrayList<>();
+ String currentGroup = null;
+
+ for (CaptureItem item : items) {
+ String group = getDateGroup(item.getTimestamp());
+ if (!group.equals(currentGroup)) {
+ result.add(CaptureItem.header(group));
+ currentGroup = group;
+ }
+ result.add(item);
+ }
+
+ return result;
+ }
+
+ private String getDateGroup(long timestamp) {
+ LocalDate itemDate = Instant.ofEpochMilli(timestamp)
+ .atZone(ZoneId.systemDefault())
+ .toLocalDate();
+ LocalDate today = LocalDate.now();
+
+ if (itemDate.equals(today)) {
+ return getString(R.string.date_group_today);
+ }
+ if (itemDate.equals(today.minusDays(1))) {
+ return getString(R.string.date_group_yesterday);
+ }
+ if (itemDate.isAfter(today.minusWeeks(1))) {
+ return getString(R.string.date_group_this_week);
+ }
+ if (itemDate.isAfter(today.minusMonths(1))) {
+ return getString(R.string.date_group_this_month);
+ }
+ return getString(R.string.date_group_older);
+ }
+
+ private void updateSortIcon() {
+ if (sortMenuItem != null) {
+ sortMenuItem.setIcon(sortAscending ?
+ R.drawable.ic_sort_ascending : R.drawable.ic_sort_descending);
+ }
+ }
+
+ // CaptureAdapter.OnCaptureClickListener implementation
+
+ @Override
+ public void onPhotoClick(File photo) {
+ Uri photoUri = FileProvider.getUriForFile(this,
+ getPackageName() + ".provider", photo);
+ Intent intent = new Intent(Intent.ACTION_VIEW);
+ intent.setDataAndType(photoUri, "image/*");
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ startActivity(intent);
+ }
+
+ @Override
+ public void onLocationClick(CaptureItem.LocationData location) {
+ if (location == null) {
+ return;
+ }
+
+ // Open in Google Maps
+ String uri = String.format(Locale.US, "geo:%f,%f?q=%f,%f",
+ location.lat, location.lon, location.lat, location.lon);
+ Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri));
+ intent.setPackage("com.google.android.apps.maps");
+
+ // Fall back to browser if Google Maps not installed
+ if (intent.resolveActivity(getPackageManager()) == null) {
+ String webUri = String.format(Locale.US,
+ "https://www.google.com/maps/search/?api=1&query=%f,%f",
+ location.lat, location.lon);
+ intent = new Intent(Intent.ACTION_VIEW, Uri.parse(webUri));
+ }
+
+ startActivity(intent);
+ }
+
+ @Override
+ public void onRecordingClick(File recording) {
+ Uri recordingUri = FileProvider.getUriForFile(this,
+ getPackageName() + ".provider", recording);
+ Intent intent = new Intent(Intent.ACTION_VIEW);
+ intent.setDataAndType(recordingUri, "audio/*");
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ startActivity(intent);
+ }
+
+ // Menu handling
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ getMenuInflater().inflate(R.menu.menu_photo_browser, menu);
+ sortMenuItem = menu.findItem(R.id.action_sort);
+ updateSortIcon();
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (item.getItemId() == android.R.id.home) {
+ finish();
+ return true;
+ } else if (item.getItemId() == R.id.action_sort) {
+ sortAscending = !sortAscending;
+ updateSortIcon();
+ sortAndDisplayData();
+ return true;
+ } else if (item.getItemId() == R.id.action_refresh) {
+ loadAllData();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+}
diff --git a/app/src/main/java/me/dbarnett/bloodhound/ui/devtools/PhotoAdapter.java b/app/src/main/java/me/dbarnett/bloodhound/ui/devtools/PhotoAdapter.java
new file mode 100644
index 0000000..9e99a71
--- /dev/null
+++ b/app/src/main/java/me/dbarnett/bloodhound/ui/devtools/PhotoAdapter.java
@@ -0,0 +1,151 @@
+package me.dbarnett.bloodhound.ui.devtools;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import me.dbarnett.bloodhound.R;
+
+/**
+ * Adapter for displaying captured photos in a grid with section headers.
+ */
+public class PhotoAdapter extends RecyclerView.Adapter {
+
+ private List items = new ArrayList<>();
+ private final OnPhotoClickListener listener;
+ private final ExecutorService executor = Executors.newFixedThreadPool(4);
+ private final int thumbnailSize;
+
+ public interface OnPhotoClickListener {
+ void onPhotoClick(File photo);
+ }
+
+ public PhotoAdapter(Context context, OnPhotoClickListener listener) {
+ this.listener = listener;
+ // Calculate thumbnail size based on screen width / 3 columns
+ this.thumbnailSize = context.getResources().getDisplayMetrics().widthPixels / 3;
+ }
+
+ public void setItems(List items) {
+ this.items = items;
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ return items.get(position).getType();
+ }
+
+ @NonNull
+ @Override
+ public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ LayoutInflater inflater = LayoutInflater.from(parent.getContext());
+ if (viewType == PhotoListItem.TYPE_HEADER) {
+ View view = inflater.inflate(R.layout.item_photo_header, parent, false);
+ return new HeaderViewHolder(view);
+ } else {
+ View view = inflater.inflate(R.layout.item_photo, parent, false);
+ return new PhotoViewHolder(view);
+ }
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
+ PhotoListItem item = items.get(position);
+ if (holder instanceof HeaderViewHolder) {
+ ((HeaderViewHolder) holder).bind(item.getHeader());
+ } else if (holder instanceof PhotoViewHolder) {
+ ((PhotoViewHolder) holder).bind(item.getFile());
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ return items.size();
+ }
+
+ static class HeaderViewHolder extends RecyclerView.ViewHolder {
+ private final TextView textHeader;
+
+ HeaderViewHolder(View itemView) {
+ super(itemView);
+ textHeader = itemView.findViewById(R.id.text_header);
+ }
+
+ void bind(String header) {
+ textHeader.setText(header);
+ }
+ }
+
+ class PhotoViewHolder extends RecyclerView.ViewHolder {
+ private final ImageView imagePhoto;
+
+ PhotoViewHolder(View itemView) {
+ super(itemView);
+ imagePhoto = itemView.findViewById(R.id.image_photo);
+ }
+
+ void bind(File photo) {
+ // Set placeholder first
+ imagePhoto.setImageResource(R.drawable.ic_photo_placeholder);
+
+ // Load thumbnail asynchronously
+ executor.execute(() -> {
+ Bitmap thumbnail = decodeSampledBitmap(photo.getAbsolutePath(), thumbnailSize, thumbnailSize);
+ imagePhoto.post(() -> {
+ if (thumbnail != null) {
+ imagePhoto.setImageBitmap(thumbnail);
+ }
+ });
+ });
+
+ itemView.setOnClickListener(v -> listener.onPhotoClick(photo));
+ }
+
+ private Bitmap decodeSampledBitmap(String path, int reqWidth, int reqHeight) {
+ try {
+ // First decode with inJustDecodeBounds=true to check dimensions
+ BitmapFactory.Options options = new BitmapFactory.Options();
+ options.inJustDecodeBounds = true;
+ BitmapFactory.decodeFile(path, options);
+
+ // Calculate inSampleSize
+ options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
+ options.inJustDecodeBounds = false;
+
+ return BitmapFactory.decodeFile(path, options);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
+ int height = options.outHeight;
+ int width = options.outWidth;
+ int inSampleSize = 1;
+
+ if (height > reqHeight || width > reqWidth) {
+ int halfHeight = height / 2;
+ int halfWidth = width / 2;
+ while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) {
+ inSampleSize *= 2;
+ }
+ }
+ return inSampleSize;
+ }
+ }
+}
diff --git a/app/src/main/java/me/dbarnett/bloodhound/ui/devtools/PhotoBrowserActivity.java b/app/src/main/java/me/dbarnett/bloodhound/ui/devtools/PhotoBrowserActivity.java
new file mode 100644
index 0000000..02f4909
--- /dev/null
+++ b/app/src/main/java/me/dbarnett/bloodhound/ui/devtools/PhotoBrowserActivity.java
@@ -0,0 +1,228 @@
+package me.dbarnett.bloodhound.ui.devtools;
+
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.LinearLayout;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.content.FileProvider;
+import androidx.recyclerview.widget.GridLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.appbar.MaterialToolbar;
+
+import android.util.Log;
+
+import java.io.File;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import me.dbarnett.bloodhound.R;
+
+/**
+ * Activity for browsing captured photos stored locally.
+ */
+public class PhotoBrowserActivity extends AppCompatActivity implements PhotoAdapter.OnPhotoClickListener {
+
+ private static final String TAG = "PhotoBrowserActivity";
+ private static final int GRID_SPAN_COUNT = 3;
+
+ private RecyclerView recyclerPhotos;
+ private LinearLayout layoutEmpty;
+ private PhotoAdapter adapter;
+ private List photoFiles = new ArrayList<>();
+
+ private boolean sortAscending = false;
+ private MenuItem sortMenuItem;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_photo_browser);
+
+ setupToolbar();
+ setupViews();
+ setupRecyclerView();
+ loadPhotos();
+ }
+
+ private void setupToolbar() {
+ MaterialToolbar toolbar = findViewById(R.id.toolbar);
+ setSupportActionBar(toolbar);
+ if (getSupportActionBar() != null) {
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ getSupportActionBar().setTitle(R.string.dev_photo_browser_title);
+ }
+ }
+
+ private void setupViews() {
+ recyclerPhotos = findViewById(R.id.recycler_photos);
+ layoutEmpty = findViewById(R.id.layout_empty);
+ }
+
+ private void setupRecyclerView() {
+ adapter = new PhotoAdapter(this, this);
+
+ GridLayoutManager layoutManager = new GridLayoutManager(this, GRID_SPAN_COUNT);
+ layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
+ @Override
+ public int getSpanSize(int position) {
+ // Headers span full width, photos take 1 column
+ return adapter.getItemViewType(position) == PhotoListItem.TYPE_HEADER ? GRID_SPAN_COUNT : 1;
+ }
+ });
+
+ recyclerPhotos.setLayoutManager(layoutManager);
+ recyclerPhotos.setAdapter(adapter);
+ }
+
+ private void loadPhotos() {
+ List allFiles = new ArrayList<>();
+
+ // Load photos from Pictures directory
+ File picturesDir = new File(getFilesDir().getAbsolutePath(), "Pictures");
+ Log.d(TAG, "Looking for pictures in: " + picturesDir.getAbsolutePath());
+ Log.d(TAG, "Pictures dir exists: " + picturesDir.exists() + ", isDirectory: " + picturesDir.isDirectory());
+
+ if (picturesDir.exists() && picturesDir.isDirectory()) {
+ File[] files = picturesDir.listFiles((dir, name) -> {
+ String lowerName = name.toLowerCase();
+ return lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg") || lowerName.endsWith(".png");
+ });
+ Log.d(TAG, "Found " + (files != null ? files.length : 0) + " picture files");
+ if (files != null) {
+ allFiles.addAll(Arrays.asList(files));
+ }
+ }
+
+ // Load screenshots from Screenshots directory
+ File screenshotsDir = new File(getFilesDir().getAbsolutePath(), "Screenshots");
+ Log.d(TAG, "Looking for screenshots in: " + screenshotsDir.getAbsolutePath());
+ Log.d(TAG, "Screenshots dir exists: " + screenshotsDir.exists() + ", isDirectory: " + screenshotsDir.isDirectory());
+
+ if (screenshotsDir.exists() && screenshotsDir.isDirectory()) {
+ File[] files = screenshotsDir.listFiles((dir, name) -> {
+ String lowerName = name.toLowerCase();
+ return lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg") || lowerName.endsWith(".png");
+ });
+ Log.d(TAG, "Found " + (files != null ? files.length : 0) + " screenshot files");
+ if (files != null) {
+ allFiles.addAll(Arrays.asList(files));
+ }
+ }
+
+ Log.d(TAG, "Total files found: " + allFiles.size());
+ photoFiles = allFiles;
+ sortAndDisplayPhotos();
+ }
+
+ private void sortAndDisplayPhotos() {
+ if (photoFiles.isEmpty()) {
+ layoutEmpty.setVisibility(View.VISIBLE);
+ recyclerPhotos.setVisibility(View.GONE);
+ return;
+ }
+
+ // Sort files by date
+ if (sortAscending) {
+ photoFiles.sort((f1, f2) -> Long.compare(f1.lastModified(), f2.lastModified()));
+ } else {
+ photoFiles.sort((f1, f2) -> Long.compare(f2.lastModified(), f1.lastModified()));
+ }
+
+ // Group photos by date and create list items
+ List items = groupPhotosByDate(photoFiles);
+
+ layoutEmpty.setVisibility(View.GONE);
+ recyclerPhotos.setVisibility(View.VISIBLE);
+ adapter.setItems(items);
+ }
+
+ private List groupPhotosByDate(List photos) {
+ List items = new ArrayList<>();
+ String currentGroup = null;
+
+ for (File file : photos) {
+ String group = getDateGroup(file.lastModified());
+ if (!group.equals(currentGroup)) {
+ items.add(PhotoListItem.header(group));
+ currentGroup = group;
+ }
+ items.add(PhotoListItem.photo(file));
+ }
+
+ return items;
+ }
+
+ private String getDateGroup(long timestamp) {
+ LocalDate photoDate = Instant.ofEpochMilli(timestamp)
+ .atZone(ZoneId.systemDefault())
+ .toLocalDate();
+ LocalDate today = LocalDate.now();
+
+ if (photoDate.equals(today)) {
+ return getString(R.string.date_group_today);
+ }
+ if (photoDate.equals(today.minusDays(1))) {
+ return getString(R.string.date_group_yesterday);
+ }
+ if (photoDate.isAfter(today.minusWeeks(1))) {
+ return getString(R.string.date_group_this_week);
+ }
+ if (photoDate.isAfter(today.minusMonths(1))) {
+ return getString(R.string.date_group_this_month);
+ }
+ return getString(R.string.date_group_older);
+ }
+
+ private void updateSortIcon() {
+ if (sortMenuItem != null) {
+ sortMenuItem.setIcon(sortAscending ?
+ R.drawable.ic_sort_ascending : R.drawable.ic_sort_descending);
+ }
+ }
+
+ @Override
+ public void onPhotoClick(File photo) {
+ // Open in system gallery using FileProvider
+ Uri photoUri = FileProvider.getUriForFile(this,
+ getPackageName() + ".provider", photo);
+ Intent intent = new Intent(Intent.ACTION_VIEW);
+ intent.setDataAndType(photoUri, "image/*");
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ startActivity(intent);
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ getMenuInflater().inflate(R.menu.menu_photo_browser, menu);
+ sortMenuItem = menu.findItem(R.id.action_sort);
+ updateSortIcon();
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (item.getItemId() == android.R.id.home) {
+ finish();
+ return true;
+ } else if (item.getItemId() == R.id.action_sort) {
+ sortAscending = !sortAscending;
+ updateSortIcon();
+ sortAndDisplayPhotos();
+ return true;
+ } else if (item.getItemId() == R.id.action_refresh) {
+ loadPhotos();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+}
diff --git a/app/src/main/java/me/dbarnett/bloodhound/ui/devtools/PhotoListItem.java b/app/src/main/java/me/dbarnett/bloodhound/ui/devtools/PhotoListItem.java
new file mode 100644
index 0000000..ecb6e92
--- /dev/null
+++ b/app/src/main/java/me/dbarnett/bloodhound/ui/devtools/PhotoListItem.java
@@ -0,0 +1,49 @@
+package me.dbarnett.bloodhound.ui.devtools;
+
+import java.io.File;
+
+/**
+ * Represents an item in the photo browser list - either a section header or a photo.
+ */
+public class PhotoListItem {
+ public static final int TYPE_HEADER = 0;
+ public static final int TYPE_PHOTO = 1;
+
+ private final int type;
+ private final String header;
+ private final File file;
+
+ private PhotoListItem(int type, String header, File file) {
+ this.type = type;
+ this.header = header;
+ this.file = file;
+ }
+
+ public static PhotoListItem header(String title) {
+ return new PhotoListItem(TYPE_HEADER, title, null);
+ }
+
+ public static PhotoListItem photo(File file) {
+ return new PhotoListItem(TYPE_PHOTO, null, file);
+ }
+
+ public int getType() {
+ return type;
+ }
+
+ public String getHeader() {
+ return header;
+ }
+
+ public File getFile() {
+ return file;
+ }
+
+ public boolean isHeader() {
+ return type == TYPE_HEADER;
+ }
+
+ public boolean isPhoto() {
+ return type == TYPE_PHOTO;
+ }
+}
diff --git a/app/src/main/java/me/dbarnett/bloodhound/ui/history/HistoryAdapter.java b/app/src/main/java/me/dbarnett/bloodhound/ui/history/HistoryAdapter.java
new file mode 100644
index 0000000..c3c3ff6
--- /dev/null
+++ b/app/src/main/java/me/dbarnett/bloodhound/ui/history/HistoryAdapter.java
@@ -0,0 +1,185 @@
+package me.dbarnett.bloodhound.ui.history;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+
+import me.dbarnett.bloodhound.DB.TrackingEvent;
+import me.dbarnett.bloodhound.R;
+import me.dbarnett.bloodhound.databinding.ItemHistoryEventBinding;
+
+/**
+ * Adapter for displaying tracking events in the history list.
+ */
+public class HistoryAdapter extends RecyclerView.Adapter {
+
+ private List events = new ArrayList<>();
+ private final SimpleDateFormat dateFormat = new SimpleDateFormat("MMM dd, yyyy - h:mm a", Locale.getDefault());
+
+ public void setEvents(List events) {
+ this.events = events;
+ notifyDataSetChanged();
+ }
+
+ @NonNull
+ @Override
+ public EventViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ ItemHistoryEventBinding binding = ItemHistoryEventBinding.inflate(
+ LayoutInflater.from(parent.getContext()), parent, false);
+ return new EventViewHolder(binding);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull EventViewHolder holder, int position) {
+ holder.bind(events.get(position));
+ }
+
+ @Override
+ public int getItemCount() {
+ return events.size();
+ }
+
+ class EventViewHolder extends RecyclerView.ViewHolder {
+ private final ItemHistoryEventBinding binding;
+
+ EventViewHolder(ItemHistoryEventBinding binding) {
+ super(binding.getRoot());
+ this.binding = binding;
+ }
+
+ void bind(TrackingEvent event) {
+ // Set event type
+ String typeText = formatTrackingType(event.getTrackingType());
+ binding.textType.setText(typeText);
+
+ // Set date
+ String startTimeStr = event.getStartTime();
+ if (startTimeStr != null && !startTimeStr.isEmpty()) {
+ Date startDate = parseTime(startTimeStr);
+ if (startDate != null) {
+ binding.textDate.setText(dateFormat.format(startDate));
+ } else {
+ binding.textDate.setText(startTimeStr);
+ }
+ } else {
+ binding.textDate.setText("");
+ }
+
+ // Calculate and display duration
+ String duration = calculateDuration(event);
+ binding.textDuration.setText(duration);
+
+ // Set icon based on type
+ int iconRes = getIconForType(event.getTrackingType());
+ binding.iconType.setImageResource(iconRes);
+
+ // Show feature chips based on tracking type
+ updateFeatureChips(event.getTrackingType());
+ }
+
+ private String formatTrackingType(String type) {
+ if (type == null) return "Unknown";
+
+ switch (type.toLowerCase()) {
+ case "emergency":
+ return "Emergency Tracking";
+ case "emergency full":
+ case "emergencyfull":
+ return "Full Tracking";
+ case "sms":
+ return "SMS Triggered";
+ case "remote":
+ case "nextcloud":
+ return "Remote Tracking";
+ default:
+ return type;
+ }
+ }
+
+ private int getIconForType(String type) {
+ if (type == null) return R.drawable.ic_track;
+
+ switch (type.toLowerCase()) {
+ case "emergency":
+ case "emergency full":
+ case "emergencyfull":
+ return R.drawable.ic_shield;
+ case "sms":
+ return R.drawable.ic_track;
+ case "remote":
+ case "nextcloud":
+ return R.drawable.ic_cloud;
+ default:
+ return R.drawable.ic_track;
+ }
+ }
+
+ private Date parseTime(String timeStr) {
+ if (timeStr == null || timeStr.isEmpty()) return null;
+ try {
+ SimpleDateFormat inputFormat = new SimpleDateFormat("MM/dd/yyyy - HH:mm:ss", Locale.US);
+ return inputFormat.parse(timeStr);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private String calculateDuration(TrackingEvent event) {
+ Date start = parseTime(event.getStartTime());
+ Date end = parseTime(event.getEndTime());
+
+ if (start == null) return "0m";
+
+ long endTime = (end != null) ? end.getTime() : System.currentTimeMillis();
+ long durationMs = endTime - start.getTime();
+
+ long hours = TimeUnit.MILLISECONDS.toHours(durationMs);
+ long minutes = TimeUnit.MILLISECONDS.toMinutes(durationMs) % 60;
+
+ if (hours > 0) {
+ return String.format(Locale.getDefault(), "%dh %dm", hours, minutes);
+ } else {
+ return String.format(Locale.getDefault(), "%dm", minutes);
+ }
+ }
+
+ private void updateFeatureChips(String type) {
+ // Reset visibility
+ binding.layoutFeatures.setVisibility(View.GONE);
+ binding.chipLocation.setVisibility(View.GONE);
+ binding.chipCamera.setVisibility(View.GONE);
+ binding.chipMic.setVisibility(View.GONE);
+
+ if (type == null) return;
+
+ // Show features based on tracking type
+ switch (type.toLowerCase()) {
+ case "emergency full":
+ case "emergencyfull":
+ binding.layoutFeatures.setVisibility(View.VISIBLE);
+ binding.chipLocation.setVisibility(View.VISIBLE);
+ binding.chipCamera.setVisibility(View.VISIBLE);
+ binding.chipMic.setVisibility(View.VISIBLE);
+ break;
+ case "emergency":
+ binding.layoutFeatures.setVisibility(View.VISIBLE);
+ binding.chipLocation.setVisibility(View.VISIBLE);
+ break;
+ default:
+ binding.layoutFeatures.setVisibility(View.VISIBLE);
+ binding.chipLocation.setVisibility(View.VISIBLE);
+ break;
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/me/dbarnett/bloodhound/ui/history/HistoryFragment.java b/app/src/main/java/me/dbarnett/bloodhound/ui/history/HistoryFragment.java
new file mode 100644
index 0000000..8ec365a
--- /dev/null
+++ b/app/src/main/java/me/dbarnett/bloodhound/ui/history/HistoryFragment.java
@@ -0,0 +1,152 @@
+package me.dbarnett.bloodhound.ui.history;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.recyclerview.widget.LinearLayoutManager;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import me.dbarnett.bloodhound.R;
+import me.dbarnett.bloodhound.databinding.FragmentHistoryBinding;
+import me.dbarnett.bloodhound.DB.TrackingEvent;
+import me.dbarnett.bloodhound.DB.TrackingEventCollection;
+
+/**
+ * Fragment displaying the full tracking history with filtering capabilities.
+ */
+public class HistoryFragment extends Fragment {
+
+ private FragmentHistoryBinding binding;
+ private HistoryAdapter historyAdapter;
+ private List allEvents = new ArrayList<>();
+ private String currentFilter = "all";
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater,
+ @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ binding = FragmentHistoryBinding.inflate(inflater, container, false);
+ return binding.getRoot();
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ setupRecyclerView();
+ setupFilterChips();
+ setupSwipeRefresh();
+ loadEvents();
+ }
+
+ private void setupRecyclerView() {
+ historyAdapter = new HistoryAdapter();
+ binding.recyclerHistory.setLayoutManager(new LinearLayoutManager(requireContext()));
+ binding.recyclerHistory.setAdapter(historyAdapter);
+ }
+
+ private void setupFilterChips() {
+ binding.chipGroupFilter.setOnCheckedStateChangeListener((group, checkedIds) -> {
+ if (checkedIds.isEmpty()) return;
+
+ int checkedId = checkedIds.get(0);
+ if (checkedId == R.id.chip_all) {
+ currentFilter = "all";
+ } else if (checkedId == R.id.chip_emergency) {
+ currentFilter = "emergency";
+ } else if (checkedId == R.id.chip_sms) {
+ currentFilter = "sms";
+ } else if (checkedId == R.id.chip_nextcloud) {
+ currentFilter = "nextcloud";
+ }
+
+ applyFilter();
+ });
+ }
+
+ private void setupSwipeRefresh() {
+ binding.swipeRefresh.setOnRefreshListener(() -> {
+ loadEvents();
+ binding.swipeRefresh.setRefreshing(false);
+ });
+
+ // Set refresh colors to match theme
+ binding.swipeRefresh.setColorSchemeResources(
+ R.color.md_theme_dark_primary,
+ R.color.md_theme_dark_secondary,
+ R.color.md_theme_dark_tertiary
+ );
+ }
+
+ private void loadEvents() {
+ TrackingEventCollection eventCollection = new TrackingEventCollection(requireContext());
+ allEvents = eventCollection.getTrackingEvents();
+ applyFilter();
+ }
+
+ private void applyFilter() {
+ List filteredEvents;
+
+ if ("all".equals(currentFilter)) {
+ filteredEvents = new ArrayList<>(allEvents);
+ } else {
+ filteredEvents = new ArrayList<>();
+ for (TrackingEvent event : allEvents) {
+ String type = event.getTrackingType();
+ if (type == null) continue;
+
+ String typeLower = type.toLowerCase();
+ switch (currentFilter) {
+ case "emergency":
+ if (typeLower.contains("emergency")) {
+ filteredEvents.add(event);
+ }
+ break;
+ case "sms":
+ if (typeLower.contains("sms")) {
+ filteredEvents.add(event);
+ }
+ break;
+ case "nextcloud":
+ if (typeLower.contains("remote") || typeLower.contains("nextcloud")) {
+ filteredEvents.add(event);
+ }
+ break;
+ }
+ }
+ }
+
+ updateUI(filteredEvents);
+ }
+
+ private void updateUI(List events) {
+ if (events.isEmpty()) {
+ binding.layoutEmpty.setVisibility(View.VISIBLE);
+ binding.recyclerHistory.setVisibility(View.GONE);
+ } else {
+ binding.layoutEmpty.setVisibility(View.GONE);
+ binding.recyclerHistory.setVisibility(View.VISIBLE);
+ historyAdapter.setEvents(events);
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ loadEvents();
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ binding = null;
+ }
+}
diff --git a/app/src/main/java/me/dbarnett/bloodhound/ui/settings/SettingsFragment.java b/app/src/main/java/me/dbarnett/bloodhound/ui/settings/SettingsFragment.java
new file mode 100644
index 0000000..bc952dc
--- /dev/null
+++ b/app/src/main/java/me/dbarnett/bloodhound/ui/settings/SettingsFragment.java
@@ -0,0 +1,206 @@
+package me.dbarnett.bloodhound.ui.settings;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.os.Bundle;
+import android.widget.Toast;
+
+import androidx.preference.EditTextPreference;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceCategory;
+import androidx.preference.PreferenceFragmentCompat;
+import androidx.preference.PreferenceManager;
+
+import me.dbarnett.bloodhound.Nextcloud.NextcloudLoginActivity;
+import me.dbarnett.bloodhound.R;
+import me.dbarnett.bloodhound.RequestPermissionsActivity;
+import me.dbarnett.bloodhound.ui.devtools.DataBrowserActivity;
+
+/**
+ * Settings fragment using PreferenceFragmentCompat for modern settings UI.
+ */
+public class SettingsFragment extends PreferenceFragmentCompat {
+
+ private static final String GITHUB_URL = "https://github.com/dandinu/Bloodhound";
+ private static final String PREF_DEV_MODE_ENABLED = "dev_mode_enabled";
+ private static final int TAPS_TO_UNLOCK = 3;
+ private static final long TAP_TIMEOUT_MS = 2000;
+
+ private int versionTapCount = 0;
+ private long lastTapTime = 0;
+
+ @Override
+ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
+ setPreferencesFromResource(R.xml.preferences_root, rootKey);
+
+ setupPreferences();
+ setupDeveloperModeUnlock();
+ setupDevToolsPreferences();
+ updateDeveloperToolsVisibility();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ updateNextcloudPreference();
+ updatePreferenceSummaries();
+ updateDeveloperToolsVisibility();
+ }
+
+ private void setupPreferences() {
+ // Nextcloud preference click handler
+ Preference nextcloudPref = findPreference("pref_nextcloud");
+ if (nextcloudPref != null) {
+ nextcloudPref.setOnPreferenceClickListener(preference -> {
+ Intent intent = new Intent(requireContext(), NextcloudLoginActivity.class);
+ startActivity(intent);
+ return true;
+ });
+ }
+
+ // Permissions preference click handler
+ Preference permissionsPref = findPreference("pref_permissions");
+ if (permissionsPref != null) {
+ permissionsPref.setOnPreferenceClickListener(preference -> {
+ Intent intent = new Intent(requireContext(), RequestPermissionsActivity.class);
+ startActivity(intent);
+ return true;
+ });
+ }
+
+ // GitHub preference click handler
+ Preference githubPref = findPreference("pref_github");
+ if (githubPref != null) {
+ githubPref.setOnPreferenceClickListener(preference -> {
+ Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(GITHUB_URL));
+ startActivity(intent);
+ return true;
+ });
+ }
+
+ // Set up summary providers for EditTextPreferences
+ EditTextPreference triggerPref = findPreference("bloodhound_trigger");
+ if (triggerPref != null) {
+ triggerPref.setSummaryProvider(preference -> {
+ String value = ((EditTextPreference) preference).getText();
+ if (value == null || value.isEmpty()) {
+ return getString(R.string.pref_sms_trigger_summary);
+ }
+ return value;
+ });
+ }
+
+ EditTextPreference numberPref = findPreference("emergency_number");
+ if (numberPref != null) {
+ numberPref.setSummaryProvider(preference -> {
+ String value = ((EditTextPreference) preference).getText();
+ if (value == null || value.isEmpty()) {
+ return getString(R.string.pref_emergency_number_summary);
+ }
+ return value;
+ });
+ }
+ }
+
+ private void updateNextcloudPreference() {
+ Preference nextcloudPref = findPreference("pref_nextcloud");
+ if (nextcloudPref == null) return;
+
+ SharedPreferences prefs = requireContext().getSharedPreferences("Nextcloud", Context.MODE_PRIVATE);
+ String username = prefs.getString("username", "");
+
+ if (!username.isEmpty()) {
+ nextcloudPref.setSummary(getString(R.string.pref_nextcloud_connected, username));
+ } else {
+ nextcloudPref.setSummary(R.string.pref_nextcloud_not_connected);
+ }
+ }
+
+ private void updatePreferenceSummaries() {
+ // Update version
+ Preference versionPref = findPreference("pref_version");
+ if (versionPref != null) {
+ try {
+ String versionName = requireContext().getPackageManager()
+ .getPackageInfo(requireContext().getPackageName(), 0).versionName;
+ versionPref.setSummary(versionName);
+ } catch (Exception e) {
+ versionPref.setSummary("1.0");
+ }
+ }
+ }
+
+ private void setupDeveloperModeUnlock() {
+ Preference versionPref = findPreference("pref_version");
+ if (versionPref != null) {
+ versionPref.setOnPreferenceClickListener(preference -> {
+ long currentTime = System.currentTimeMillis();
+
+ // Reset tap count if timeout exceeded
+ if (currentTime - lastTapTime > TAP_TIMEOUT_MS) {
+ versionTapCount = 0;
+ }
+ lastTapTime = currentTime;
+ versionTapCount++;
+
+ if (versionTapCount >= TAPS_TO_UNLOCK) {
+ toggleDeveloperMode();
+ versionTapCount = 0;
+ } else if (versionTapCount >= 2) {
+ // Show hint toast
+ int remaining = TAPS_TO_UNLOCK - versionTapCount;
+ Toast.makeText(requireContext(),
+ getString(R.string.dev_mode_taps_remaining, remaining),
+ Toast.LENGTH_SHORT).show();
+ }
+ return true;
+ });
+ }
+ }
+
+ private void toggleDeveloperMode() {
+ SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(requireContext());
+ boolean currentlyEnabled = prefs.getBoolean(PREF_DEV_MODE_ENABLED, false);
+ boolean newState = !currentlyEnabled;
+
+ prefs.edit().putBoolean(PREF_DEV_MODE_ENABLED, newState).apply();
+
+ Toast.makeText(requireContext(),
+ newState ? R.string.dev_mode_enabled : R.string.dev_mode_disabled,
+ Toast.LENGTH_SHORT).show();
+
+ updateDeveloperToolsVisibility();
+ }
+
+ private void updateDeveloperToolsVisibility() {
+ SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(requireContext());
+ boolean devModeEnabled = prefs.getBoolean(PREF_DEV_MODE_ENABLED, false);
+
+ PreferenceCategory devToolsCategory = findPreference("category_dev_tools");
+ if (devToolsCategory != null) {
+ devToolsCategory.setVisible(devModeEnabled);
+ }
+ }
+
+ private void setupDevToolsPreferences() {
+ Preference photoBrowserPref = findPreference("pref_photo_browser");
+ if (photoBrowserPref != null) {
+ photoBrowserPref.setOnPreferenceClickListener(preference -> {
+ Intent intent = new Intent(requireContext(), DataBrowserActivity.class);
+ startActivity(intent);
+ return true;
+ });
+ }
+
+ Preference permissionsCheckerPref = findPreference("pref_permissions_checker");
+ if (permissionsCheckerPref != null) {
+ permissionsCheckerPref.setOnPreferenceClickListener(preference -> {
+ Intent intent = new Intent(requireContext(), RequestPermissionsActivity.class);
+ startActivity(intent);
+ return true;
+ });
+ }
+ }
+}
diff --git a/app/src/main/res/anim/pulse.xml b/app/src/main/res/anim/pulse.xml
new file mode 100644
index 0000000..a2deefb
--- /dev/null
+++ b/app/src/main/res/anim/pulse.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_dot.xml b/app/src/main/res/drawable/bg_dot.xml
new file mode 100644
index 0000000..aac7deb
--- /dev/null
+++ b/app/src/main/res/drawable/bg_dot.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_feature_chip.xml b/app/src/main/res/drawable/bg_feature_chip.xml
new file mode 100644
index 0000000..3b492ef
--- /dev/null
+++ b/app/src/main/res/drawable/bg_feature_chip.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_alarm.xml b/app/src/main/res/drawable/ic_alarm.xml
new file mode 100644
index 0000000..4176446
--- /dev/null
+++ b/app/src/main/res/drawable/ic_alarm.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_camera.xml b/app/src/main/res/drawable/ic_camera.xml
new file mode 100644
index 0000000..c517a17
--- /dev/null
+++ b/app/src/main/res/drawable/ic_camera.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_chevron_right.xml b/app/src/main/res/drawable/ic_chevron_right.xml
new file mode 100644
index 0000000..a4121db
--- /dev/null
+++ b/app/src/main/res/drawable/ic_chevron_right.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_cloud.xml b/app/src/main/res/drawable/ic_cloud.xml
new file mode 100644
index 0000000..ae02b80
--- /dev/null
+++ b/app/src/main/res/drawable/ic_cloud.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_dashboard.xml b/app/src/main/res/drawable/ic_dashboard.xml
new file mode 100644
index 0000000..93a6ad1
--- /dev/null
+++ b/app/src/main/res/drawable/ic_dashboard.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_history.xml b/app/src/main/res/drawable/ic_history.xml
new file mode 100644
index 0000000..5bcd09d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_history.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_location.xml b/app/src/main/res/drawable/ic_location.xml
new file mode 100644
index 0000000..f082858
--- /dev/null
+++ b/app/src/main/res/drawable/ic_location.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_mic.xml b/app/src/main/res/drawable/ic_mic.xml
new file mode 100644
index 0000000..8748c0f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_mic.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_photo_placeholder.xml b/app/src/main/res/drawable/ic_photo_placeholder.xml
new file mode 100644
index 0000000..1b4128c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_photo_placeholder.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_play.xml b/app/src/main/res/drawable/ic_play.xml
new file mode 100644
index 0000000..faaf575
--- /dev/null
+++ b/app/src/main/res/drawable/ic_play.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml
new file mode 100644
index 0000000..24e8fd7
--- /dev/null
+++ b/app/src/main/res/drawable/ic_refresh.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml
new file mode 100644
index 0000000..13718f9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_settings.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_shield.xml b/app/src/main/res/drawable/ic_shield.xml
new file mode 100644
index 0000000..b303ccc
--- /dev/null
+++ b/app/src/main/res/drawable/ic_shield.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_sort_ascending.xml b/app/src/main/res/drawable/ic_sort_ascending.xml
new file mode 100644
index 0000000..6bfd40e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_sort_ascending.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_sort_descending.xml b/app/src/main/res/drawable/ic_sort_descending.xml
new file mode 100644
index 0000000..8fe67a3
--- /dev/null
+++ b/app/src/main/res/drawable/ic_sort_descending.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_stop.xml b/app/src/main/res/drawable/ic_stop.xml
new file mode 100644
index 0000000..4cde295
--- /dev/null
+++ b/app/src/main/res/drawable/ic_stop.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_time.xml b/app/src/main/res/drawable/ic_time.xml
new file mode 100644
index 0000000..2b7fd00
--- /dev/null
+++ b/app/src/main/res/drawable/ic_time.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_track.xml b/app/src/main/res/drawable/ic_track.xml
new file mode 100644
index 0000000..a1c9237
--- /dev/null
+++ b/app/src/main/res/drawable/ic_track.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/layout/activity_bloodhound.xml b/app/src/main/res/layout/activity_bloodhound.xml
index e63903d..7759eb4 100644
--- a/app/src/main/res/layout/activity_bloodhound.xml
+++ b/app/src/main/res/layout/activity_bloodhound.xml
@@ -1,28 +1,28 @@
-
-
-
-
+
-
-
+
diff --git a/app/src/main/res/layout/activity_data_browser.xml b/app/src/main/res/layout/activity_data_browser.xml
new file mode 100644
index 0000000..e40d571
--- /dev/null
+++ b/app/src/main/res/layout/activity_data_browser.xml
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..0df0b32
--- /dev/null
+++ b/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_nextcloud_login.xml b/app/src/main/res/layout/activity_nextcloud_login.xml
index a41f278..f0ff772 100644
--- a/app/src/main/res/layout/activity_nextcloud_login.xml
+++ b/app/src/main/res/layout/activity_nextcloud_login.xml
@@ -50,7 +50,7 @@
-
@@ -65,7 +65,7 @@
android:inputType="textPassword"
android:maxLines="1" />
-
+