commit 5b0710672245f00fe8563aa253be2375e0d99da6 Author: Matt Hills Date: Fri Jun 13 16:58:23 2025 -0400 initial commit diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..1876565 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '[BUG] ' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Device Information:** + - Device: [e.g. Pixel 7] + - Android Version: [e.g. Android 13] + - App Version: [e.g. 1.0.0] + +**Additional context** +Add any other context about the problem here. + +**Logs** +If applicable, add relevant logs or crash reports: +``` +paste logs here +``` \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..70afd06 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '[FEATURE] ' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. + +**Implementation suggestions** +If you have specific implementation ideas, please describe them here. \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53c738c --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +/.idea/vcs.xml +/.idea/misc.xml +/.idea/compiler.xml +/.idea/gradle.xml +/.idea/kotlinc.xml +/.idea/deploymentTargetSelector.xml +/.idea/migrations.xml +/.idea/inspectionProfiles/ +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties + +# Claude AI +.claude/ +*.claude + +# Other IDE files +.vscode/ +*.swp +*.swo +*~ +.project +.classpath +.settings/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b8ba067 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,167 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is an Android application project using Gradle with Kotlin DSL. The project uses modern Android development practices with AndroidX libraries and targets Android 12+ (minSdk 32). + +## Build Commands + +```bash +# Clean build artifacts +./gradlew clean + +# Build debug APK +./gradlew assembleDebug + +# Build release APK +./gradlew assembleRelease + +# Run unit tests +./gradlew test + +# Run instrumented tests (requires connected device/emulator) +./gradlew connectedAndroidTest + +# Run all tests +./gradlew test connectedAndroidTest + +# Check for lint issues +./gradlew lint + +# Install debug APK on connected device +./gradlew installDebug + +# Run a specific test class +./gradlew test --tests "com.mattintech.androidtestapp.ExampleUnitTest" + +# Run tests with additional output +./gradlew test --info +``` + +## Project Structure + +- **App Module**: `app/` - Main application module + - Source code: `app/src/main/java/com/mattintech/androidtestapp/` + - Unit tests: `app/src/test/java/com/mattintech/androidtestapp/` + - Instrumented tests: `app/src/androidTest/java/com/mattintech/androidtestapp/` + - Resources: `app/src/main/res/` + - Manifest: `app/src/main/AndroidManifest.xml` + +- **Gradle Configuration**: + - Project-level: `build.gradle.kts` + - App-level: `app/build.gradle.kts` + - Version catalog: `gradle/libs.versions.toml` + - Gradle wrapper: `gradle/wrapper/` (Gradle 8.11.1) + +## Key Configuration + +- **Package name**: `com.mattintech.androidtestapp` +- **Application ID**: `com.mattintech.androidtestapp` +- **Target SDK**: 35 (Android 15) +- **Min SDK**: 32 (Android 12) +- **Compile SDK**: 35 +- **Test runner**: `androidx.test.runner.AndroidJUnitRunner` + +## Development Notes + +This is currently a minimal Android project template. When developing: + +1. The project uses Gradle Version Catalog (`libs.versions.toml`) for dependency management +2. All build configuration uses Kotlin DSL (`.kts` files) +3. The project is set up for both unit tests (JUnit) and instrumented tests (Espresso) +4. No custom lint rules are configured - default Android lint rules apply + +## App Features + +This is a demo app for testing device stress scenarios with three main features: + +### 1. Battery Draining Feature +Tests battery consumption through various hardware components: + +**User-configurable options:** +- Test duration (minutes/hours) +- CPU intensive operations (mathematical calculations, loops) +- Screen (maximum brightness, prevent sleep) +- Flashlight (continuous or strobe) +- GPS (high accuracy location updates) +- BLE scanning (continuous discovery) +- WiFi scanning +- Network activity (continuous data transfers) +- Sensors (accelerometer, gyroscope, magnetometer - continuous reading) +- Camera (preview without recording) +- Audio (silent playback to keep audio subsystem active) +- Vibration (continuous patterns) + +**Presets:** +- "Maximum Drain" - All features enabled +- "CPU & GPU Focus" - CPU calculations + screen brightness +- "Radio Focus" - GPS + BLE + WiFi scanning +- "Sensor Focus" - All sensors + vibration + +### 2. Bad Behavior Feature +Intentionally causes app crashes and ANRs for testing: + +**Crash types (user-selectable):** +- Division by zero +- Null pointer exception +- Out of memory error +- Stack overflow +- Array index out of bounds + +**ANR triggers (user-selectable):** +- UI thread blocking (long-running operation on main thread) +- Broadcast receiver timeout +- Service timeout + +**Settings:** +- Frequency: Manual trigger or random intervals (configurable) +- Auto-restart: App automatically restarts after crash + +### 3. Downloader Feature +Schedules and executes file downloads: + +**User-configurable options:** +- Download URL (with default test URLs) +- File size options (1MB, 10MB, 100MB, custom) +- Schedule type: + - One-time download + - Repeat every X minutes/hours + - Specific times of day +- Network preference (WiFi only, cellular only, any) +- Storage behavior (save or discard after download) +- Background downloads (continue when app in background) +- Concurrent downloads (1-10 simultaneous) + +**UI shows:** +- Download progress +- Download history +- Total data downloaded + +## Architecture + +- **MainActivity**: Launch screen with buttons to three feature activities +- **BatteryDrainActivity**: Configure and run battery drain tests +- **BadBehaviorActivity**: Configure and trigger crashes/ANRs +- **DownloaderActivity**: Configure and manage downloads +- **Services**: Background services for battery drain and downloads +- **Permissions**: Auto-request all required permissions on app launch + +## Required Permissions + +The app will need these permissions (auto-requested on launch): +- CAMERA +- ACCESS_FINE_LOCATION +- ACCESS_COARSE_LOCATION +- BLUETOOTH_SCAN +- BLUETOOTH_CONNECT +- FLASHLIGHT +- VIBRATE +- INTERNET +- ACCESS_NETWORK_STATE +- ACCESS_WIFI_STATE +- WAKE_LOCK +- FOREGROUND_SERVICE +- WRITE_EXTERNAL_STORAGE (for downloads) +- HIGH_SAMPLING_RATE_SENSORS \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..6ca1083 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,67 @@ +plugins { + alias(libs.plugins.android.application) +} + +android { + namespace = "com.mattintech.androidtestapp" + compileSdk = 35 + + defaultConfig { + applicationId = "com.mattintech.androidtestapp" + minSdk = 32 + targetSdk = 35 + versionCode = 1 + versionName = "1.0.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + viewBinding = true + } +} + +dependencies { + + implementation(libs.appcompat) + implementation(libs.material) + implementation("com.google.android.material:material:1.12.0") + implementation(libs.constraintlayout) + implementation(libs.activity) + implementation(libs.fragment) + + // Coroutines for background tasks + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + + // WorkManager for scheduled downloads + implementation("androidx.work:work-runtime:2.9.0") + + // OkHttp for downloads + implementation("com.squareup.okhttp3:okhttp:4.12.0") + + // Location services + implementation("com.google.android.gms:play-services-location:21.0.1") + + // CameraX for camera operations + implementation("androidx.camera:camera-core:1.3.1") + implementation("androidx.camera:camera-camera2:1.3.1") + implementation("androidx.camera:camera-lifecycle:1.3.1") + implementation("androidx.camera:camera-view:1.3.1") + + testImplementation(libs.junit) + androidTestImplementation(libs.ext.junit) + androidTestImplementation(libs.espresso.core) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/mattintech/androidtestapp/ExampleInstrumentedTest.java b/app/src/androidTest/java/com/mattintech/androidtestapp/ExampleInstrumentedTest.java new file mode 100644 index 0000000..b61a12a --- /dev/null +++ b/app/src/androidTest/java/com/mattintech/androidtestapp/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.mattintech.androidtestapp; + +import android.content.Context; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + assertEquals("com.mattintech.androidtestapp", appContext.getPackageName()); + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2f551c8 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/androidtestapp/BadBehaviorActivity.java b/app/src/main/java/com/mattintech/androidtestapp/BadBehaviorActivity.java new file mode 100644 index 0000000..b2a8257 --- /dev/null +++ b/app/src/main/java/com/mattintech/androidtestapp/BadBehaviorActivity.java @@ -0,0 +1,562 @@ +package com.mattintech.androidtestapp; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.widget.ArrayAdapter; +import android.widget.Toast; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import android.app.NotificationManager; +import android.app.PendingIntent; +import androidx.core.app.NotificationCompat; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.core.graphics.Insets; +import com.mattintech.androidtestapp.databinding.ActivityBadBehaviorBinding; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +public class BadBehaviorActivity extends AppCompatActivity { + private static final String TAG = Constants.LOG_TAG + "BadBehavior"; + private ActivityBadBehaviorBinding binding; + private Handler handler = new Handler(Looper.getMainLooper()); + private Random random = new Random(); + private boolean isRandomModeActive = false; + private Runnable randomCrashRunnable; + private boolean isServiceRunning = false; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Set up crash handler to restart app + Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { + @Override + public void uncaughtException(Thread thread, Throwable throwable) { + // Check if we're in random mode + SharedPreferences prefs = getSharedPreferences("BadBehaviorPrefs", Context.MODE_PRIVATE); + boolean isRandomActive = prefs.getBoolean("isRandomActive", false); + + // Restart the app + Intent intent; + if (isRandomActive) { + // Go directly to BadBehaviorActivity to resume random mode + intent = new Intent(getApplicationContext(), BadBehaviorActivity.class); + intent.putExtra("resumeRandomMode", true); + } else { + // Normal restart to MainActivity + intent = new Intent(getApplicationContext(), MainActivity.class); + } + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + startActivity(intent); + android.os.Process.killProcess(android.os.Process.myPid()); + } + }); + + binding = ActivityBadBehaviorBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + // Setup edge-to-edge + setupWindowInsets(); + + setupUI(); + + // Setup toolbar navigation + binding.toolbar.setNavigationOnClickListener(v -> { + // Check if this is the root activity (launched from notification) + if (isTaskRoot()) { + // Create a new back stack with MainActivity + Intent intent = new Intent(this, MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + startActivity(intent); + } + finish(); + }); + + // Check if we need to resume random mode after a crash + checkAndResumeRandomMode(); + } + + private void setupWindowInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.getRoot(), (v, windowInsets) -> { + Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()); + v.setPadding(v.getPaddingLeft(), insets.top, v.getPaddingRight(), insets.bottom); + return WindowInsetsCompat.CONSUMED; + }); + } + + private void setupUI() { + // Setup crash type spinner + List crashTypes = new ArrayList<>(); + crashTypes.add("Division by Zero"); + crashTypes.add("Null Pointer Exception"); + crashTypes.add("Out of Memory"); + crashTypes.add("Stack Overflow"); + crashTypes.add("Array Index Out of Bounds"); + + ArrayAdapter crashAdapter = new ArrayAdapter<>(this, + android.R.layout.simple_spinner_item, crashTypes); + crashAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + binding.spinnerCrashType.setAdapter(crashAdapter); + + // Setup ANR type spinner + List anrTypes = new ArrayList<>(); + anrTypes.add("UI Thread Blocking"); + anrTypes.add("Broadcast Receiver Timeout"); + anrTypes.add("Service Timeout"); + + ArrayAdapter anrAdapter = new ArrayAdapter<>(this, + android.R.layout.simple_spinner_item, anrTypes); + anrAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + binding.spinnerAnrType.setAdapter(anrAdapter); + + // Setup frequency spinner + List frequencies = new ArrayList<>(); + frequencies.add("Manual"); + frequencies.add("Every 10 seconds"); + frequencies.add("Every 30 seconds"); + frequencies.add("Every 1 minute"); + frequencies.add("Every 5 minutes"); + frequencies.add("Random (10-60 seconds)"); + + ArrayAdapter frequencyAdapter = new ArrayAdapter<>(this, + android.R.layout.simple_spinner_item, frequencies); + frequencyAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + binding.spinnerFrequency.setAdapter(frequencyAdapter); + + // Button listeners + binding.btnTriggerCrash.setOnClickListener(v -> triggerCrash()); + binding.btnTriggerAnr.setOnClickListener(v -> triggerANR()); + binding.btnStartRandom.setOnClickListener(v -> startRandomMode()); + binding.btnStopRandom.setOnClickListener(v -> stopRandomMode()); + binding.btnViewScheduled.setOnClickListener(v -> viewScheduledCrashes()); + + // Initially disable stop button + binding.btnStopRandom.setEnabled(false); + } + + private void triggerCrash() { + String crashType = binding.spinnerCrashType.getSelectedItem().toString(); + Log.d(TAG, "Triggering crash: " + crashType); + + switch (crashType) { + case "Division by Zero": + int a = 10; + int b = 0; + int result = a / b; // This will crash + break; + + case "Null Pointer Exception": + String nullString = null; + nullString.length(); // This will crash + break; + + case "Out of Memory": + List list = new ArrayList<>(); + while (true) { + list.add(new byte[1024 * 1024]); // Allocate 1MB until OOM + } + + case "Stack Overflow": + recursiveMethod(); // This will crash + break; + + case "Array Index Out of Bounds": + int[] array = new int[5]; + int value = array[10]; // This will crash + break; + } + } + + private void recursiveMethod() { + recursiveMethod(); // Infinite recursion causes stack overflow + } + + private void triggerANR() { + String anrType = binding.spinnerAnrType.getSelectedItem().toString(); + Log.d(TAG, "Triggering ANR: " + anrType); + + switch (anrType) { + case "UI Thread Blocking": + // Show warning dialog + new AlertDialog.Builder(this) + .setTitle("UI Thread Blocking ANR") + .setMessage("This will block the UI thread for 10 seconds, causing an ANR.\n\n" + + "To clear the ANR:\n" + + "1. Wait for the ANR dialog (appears after ~5 seconds)\n" + + "2. Choose 'Wait' to continue or 'Close app'\n" + + "3. The app will respond again after 10 seconds total\n\n" + + "During this time, the app will be completely frozen.") + .setPositiveButton("Block UI Thread", (dialog, which) -> { + Log.d(TAG, "User confirmed UI Thread Blocking ANR"); + Toast.makeText(this, "Blocking UI thread for 10 seconds...", Toast.LENGTH_SHORT).show(); + handler.postDelayed(() -> { + try { + Log.d(TAG, "Starting UI thread block"); + Thread.sleep(10000); // This will cause ANR + Log.d(TAG, "UI thread block completed"); + } catch (InterruptedException e) { + Log.e(TAG, "UI thread block interrupted", e); + e.printStackTrace(); + } + }, 100); // Small delay to show toast + }) + .setNegativeButton("Cancel", null) + .show(); + break; + + case "Broadcast Receiver Timeout": + // Show warning dialog + new AlertDialog.Builder(this) + .setTitle("Broadcast Receiver Timeout ANR") + .setMessage("This will trigger a broadcast receiver that blocks for 15 seconds, causing an ANR.\n\n" + + "To clear the ANR:\n" + + "1. Wait for the ANR dialog (appears after ~10 seconds)\n" + + "2. Choose 'Wait' or 'Close app'\n" + + "3. The receiver will complete after 15 seconds\n\n" + + "Note: The app may remain partially responsive during this ANR.") + .setPositiveButton("Trigger Broadcast ANR", (dialog, which) -> { + Log.d(TAG, "User confirmed Broadcast Receiver ANR"); + BroadcastReceiver slowReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + try { + Log.d(TAG, "Starting broadcast receiver block"); + Thread.sleep(15000); // This will cause ANR + Log.d(TAG, "Broadcast receiver block completed"); + } catch (InterruptedException e) { + Log.e(TAG, "Broadcast receiver block interrupted", e); + e.printStackTrace(); + } + } + }; + registerReceiver(slowReceiver, new IntentFilter("com.mattintech.SLOW_ACTION")); + sendBroadcast(new Intent("com.mattintech.SLOW_ACTION")); + Toast.makeText(this, "Broadcast sent - ANR will occur soon", Toast.LENGTH_LONG).show(); + }) + .setNegativeButton("Cancel", null) + .show(); + break; + + case "Service Timeout": + if (!isServiceRunning) { + // Show warning dialog + new AlertDialog.Builder(this) + .setTitle("Service Timeout ANR") + .setMessage("This will start a service that blocks for 20 seconds, causing an ANR.\n\n" + + "To clear the ANR:\n" + + "1. Wait for the ANR dialog to appear\n" + + "2. Choose 'Wait' or 'Close app'\n" + + "3. Force stop the app from Settings if needed\n\n" + + "The service will automatically stop after 20 seconds.") + .setPositiveButton("Start Service", (dialog, which) -> { + Log.d(TAG, "User confirmed Service Timeout ANR"); + isServiceRunning = true; + Intent serviceIntent = new Intent(this, SlowService.class); + startService(serviceIntent); + Log.d(TAG, "Starting slow service"); + Toast.makeText(this, "Service started - ANR will occur in ~5 seconds", Toast.LENGTH_LONG).show(); + + // Schedule service status reset + handler.postDelayed(() -> { + isServiceRunning = false; + Log.d(TAG, "Service timeout cleared"); + Toast.makeText(this, "Service timeout cleared", Toast.LENGTH_SHORT).show(); + }, 20000); + }) + .setNegativeButton("Cancel", null) + .show(); + } else { + Toast.makeText(this, "Service is already running. Wait for it to complete.", Toast.LENGTH_LONG).show(); + } + break; + } + } + + private void startRandomMode() { + String frequency = binding.spinnerFrequency.getSelectedItem().toString(); + if (frequency.equals("Manual")) { + Toast.makeText(this, "Please select a time interval", Toast.LENGTH_SHORT).show(); + return; + } + + // Show warning dialog before starting + new AlertDialog.Builder(this) + .setTitle("⚠️ Warning: Random Crash Mode") + .setMessage("IMPORTANT: The app must stay in the foreground!\n" + + "Crashes only occur when this screen is visible.\n\n" + + "Starting random crash mode will:\n" + + "• INTERRUPT all other running tests\n" + + "• STOP battery drain tests\n" + + "• FAIL scheduled downloads\n" + + "• Crash the app " + frequency.toLowerCase() + "\n\n" + + "The app will auto-restart and return to this screen.\n" + + "Keep the app open for continuous testing.\n" + + "This mode will automatically stop after 24 hours.\n\n" + + "Are you sure you want to start random crash mode?") + .setPositiveButton("Start", (dialog, which) -> { + Log.d(TAG, "Starting random mode with frequency: " + frequency); + + // Save random mode configuration + SharedPreferences prefs = getSharedPreferences("BadBehaviorPrefs", Context.MODE_PRIVATE); + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean("isRandomActive", true); + editor.putString("frequency", frequency); + editor.putLong("startTime", System.currentTimeMillis()); + editor.apply(); + + isRandomModeActive = true; + binding.btnStartRandom.setEnabled(false); + binding.btnStopRandom.setEnabled(true); + binding.btnTriggerCrash.setEnabled(false); + binding.btnTriggerAnr.setEnabled(false); + + long delayMillis = getDelayMillis(frequency); + scheduleRandomBehavior(delayMillis); + + // Show notification for random mode + showRandomModeNotification(frequency); + + Toast.makeText(this, "Random mode started - keep app in foreground!", Toast.LENGTH_LONG).show(); + }) + .setNegativeButton("Cancel", null) + .show(); + } + + private void scheduleRandomBehavior(long delayMillis) { + if (!isRandomModeActive) return; + + randomCrashRunnable = () -> { + if (!isRandomModeActive) return; + + // Only trigger crashes in random mode (no ANRs to avoid dialogs) + int crashIndex = random.nextInt(5); + binding.spinnerCrashType.setSelection(crashIndex); + Log.d(TAG, "Random mode triggering crash type: " + binding.spinnerCrashType.getSelectedItem()); + triggerCrash(); + + // Note: The next crash will be scheduled after the app restarts + // via checkAndResumeRandomMode() + }; + + handler.postDelayed(randomCrashRunnable, delayMillis); + } + + private long getDelayMillis(String frequency) { + switch (frequency) { + case "Every 10 seconds": + return 10000; + case "Every 30 seconds": + return 30000; + case "Every 1 minute": + return 60000; + case "Every 5 minutes": + return 300000; + case "Random (10-60 seconds)": + return 10000 + random.nextInt(50000); + default: + return 10000; + } + } + + private void stopRandomMode() { + Log.d(TAG, "Stopping random mode"); + + isRandomModeActive = false; + handler.removeCallbacks(randomCrashRunnable); + + // Clear random mode from preferences + SharedPreferences prefs = getSharedPreferences("BadBehaviorPrefs", Context.MODE_PRIVATE); + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean("isRandomActive", false); + editor.apply(); + + binding.btnStartRandom.setEnabled(true); + binding.btnStopRandom.setEnabled(false); + binding.btnTriggerCrash.setEnabled(true); + binding.btnTriggerAnr.setEnabled(true); + + // Cancel notification + NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.cancel(Constants.NOTIFICATION_ID_BAD_BEHAVIOR); + + Toast.makeText(this, "Random mode stopped", Toast.LENGTH_SHORT).show(); + } + + private void viewScheduledCrashes() { + SharedPreferences prefs = getSharedPreferences("BadBehaviorPrefs", Context.MODE_PRIVATE); + boolean isRandomActive = prefs.getBoolean("isRandomActive", false); + + if (isRandomActive && isRandomModeActive) { + String frequency = prefs.getString("frequency", "Unknown"); + long startTime = prefs.getLong("startTime", 0); + + StringBuilder info = new StringBuilder(); + info.append("Random Crash Mode Active\n\n"); + info.append("Frequency: ").append(frequency).append("\n"); + + if (startTime > 0) { + long elapsedMinutes = (System.currentTimeMillis() - startTime) / 60000; + info.append("Running for: ").append(elapsedMinutes).append(" minutes\n"); + } + + info.append("\nThis mode randomly triggers:\n"); + info.append("• App crashes only (no ANRs)\n"); + info.append("• 5 different crash types\n"); + info.append("\nThe app will auto-restart after crashes.\n"); + info.append("Mode stops automatically after 24 hours."); + + new AlertDialog.Builder(this) + .setTitle("Scheduled Crashes") + .setMessage(info.toString()) + .setPositiveButton("OK", null) + .setNegativeButton("Stop Random Mode", (dialog, which) -> stopRandomMode()) + .show(); + } else { + new AlertDialog.Builder(this) + .setTitle("No Scheduled Crashes") + .setMessage("Random crash mode is not active.\n\nUse 'Start Random' to schedule automatic crashes/ANRs.") + .setPositiveButton("OK", null) + .show(); + } + } + + private void showRandomModeNotification(String frequency) { + NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + + // Intent to open this activity when notification is clicked + Intent intent = new Intent(this, BadBehaviorActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + + // Intent to stop random mode + Intent stopIntent = new Intent(this, BadBehaviorActivity.class); + stopIntent.setAction("STOP_RANDOM_MODE"); + PendingIntent stopPendingIntent = PendingIntent.getActivity(this, 1, stopIntent, + PendingIntent.FLAG_IMMUTABLE); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, Constants.NOTIFICATION_CHANNEL_BAD_BEHAVIOR) + .setContentTitle("⚠️ Random Crash Mode Active") + .setContentText("Keep app in foreground! Crashes " + frequency.toLowerCase()) + .setStyle(new NotificationCompat.BigTextStyle() + .bigText("IMPORTANT: App must stay in foreground for crashes!\n" + + "All other tests are being interrupted!\n" + + "Frequency: " + frequency + "\n" + + "Auto-restarts after crashes\n" + + "Stops after 24 hours")) + .setSmallIcon(android.R.drawable.ic_dialog_alert) + .setContentIntent(pendingIntent) + .setOngoing(true) + .setAutoCancel(false) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", stopPendingIntent); + + notificationManager.notify(Constants.NOTIFICATION_ID_BAD_BEHAVIOR, builder.build()); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + if ("STOP_RANDOM_MODE".equals(intent.getAction())) { + stopRandomMode(); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + stopRandomMode(); + // Stop service if running + if (isServiceRunning) { + stopService(new Intent(this, SlowService.class)); + isServiceRunning = false; + } + } + + private void stopSlowService() { + if (isServiceRunning) { + stopService(new Intent(this, SlowService.class)); + isServiceRunning = false; + Toast.makeText(this, "Service stopped", Toast.LENGTH_SHORT).show(); + } + } + + private void checkAndResumeRandomMode() { + SharedPreferences prefs = getSharedPreferences("BadBehaviorPrefs", Context.MODE_PRIVATE); + boolean wasRandomActive = prefs.getBoolean("isRandomActive", false); + + if (wasRandomActive) { + String frequency = prefs.getString("frequency", ""); + long startTime = prefs.getLong("startTime", 0); + + // Check if we should still be in random mode (e.g., within 24 hours) + long elapsedHours = (System.currentTimeMillis() - startTime) / (1000 * 60 * 60); + if (elapsedHours < 24 && !frequency.isEmpty()) { + Log.d(TAG, "Resuming random mode after restart: " + frequency); + + // Update UI to reflect active state + isRandomModeActive = true; + binding.btnStartRandom.setEnabled(false); + binding.btnStopRandom.setEnabled(true); + binding.btnTriggerCrash.setEnabled(false); + binding.btnTriggerAnr.setEnabled(false); + + // Set the spinner to the saved frequency + ArrayAdapter adapter = (ArrayAdapter) binding.spinnerFrequency.getAdapter(); + int position = adapter.getPosition(frequency); + if (position >= 0) { + binding.spinnerFrequency.setSelection(position); + } + + // Resume scheduling with a shorter initial delay after crash + long delayMillis = Math.min(10000, getDelayMillis(frequency)); // 10 seconds or normal delay, whichever is shorter + scheduleRandomBehavior(delayMillis); + + // Show notification again + showRandomModeNotification(frequency); + + Toast.makeText(this, "Random mode resumed", Toast.LENGTH_SHORT).show(); + } else { + // Clear stale random mode + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean("isRandomActive", false); + editor.apply(); + } + } + } + + // Inner class for slow service + public static class SlowService extends android.app.Service { + private static final String TAG = Constants.LOG_TAG + "SlowService"; + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + Log.d(TAG, "SlowService started - blocking for 20 seconds"); + // Block the service + try { + Thread.sleep(20000); // This will cause ANR + Log.d(TAG, "SlowService block completed"); + } catch (InterruptedException e) { + Log.e(TAG, "SlowService interrupted", e); + e.printStackTrace(); + } + return START_NOT_STICKY; + } + + @Override + public android.os.IBinder onBind(Intent intent) { + return null; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/androidtestapp/BatteryDrainActivity.java b/app/src/main/java/com/mattintech/androidtestapp/BatteryDrainActivity.java new file mode 100644 index 0000000..02df32d --- /dev/null +++ b/app/src/main/java/com/mattintech/androidtestapp/BatteryDrainActivity.java @@ -0,0 +1,362 @@ +package com.mattintech.androidtestapp; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.Bundle; +import android.os.IBinder; +import android.util.Log; +import android.widget.ArrayAdapter; +import android.widget.AutoCompleteTextView; +import android.widget.Toast; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.core.graphics.Insets; +import com.mattintech.androidtestapp.databinding.ActivityBatteryDrainBinding; +import com.mattintech.androidtestapp.services.BatteryDrainService; +import java.util.ArrayList; +import java.util.List; +import android.app.AlertDialog; +import android.app.ActivityManager; +import android.content.SharedPreferences; + +public class BatteryDrainActivity extends AppCompatActivity { + private static final String TAG = Constants.LOG_TAG + "BatteryDrain"; + private ActivityBatteryDrainBinding binding; + private BatteryDrainService.BatteryDrainBinder binder; + private boolean isServiceBound = false; + + private ServiceConnection serviceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + binder = (BatteryDrainService.BatteryDrainBinder) service; + isServiceBound = true; + } + + @Override + public void onServiceDisconnected(ComponentName name) { + isServiceBound = false; + } + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = ActivityBatteryDrainBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + // Setup edge-to-edge + setupWindowInsets(); + + setupUI(); + setupPresets(); + + // Setup toolbar navigation + binding.toolbar.setNavigationOnClickListener(v -> { + // Check if this is the root activity (launched from notification) + if (isTaskRoot()) { + // Create a new back stack with MainActivity + Intent intent = new Intent(this, MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + startActivity(intent); + } + finish(); + }); + } + + private void setupWindowInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.getRoot(), (v, windowInsets) -> { + Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()); + v.setPadding(v.getPaddingLeft(), insets.top, v.getPaddingRight(), insets.bottom); + return WindowInsetsCompat.CONSUMED; + }); + } + + private void setupUI() { + // Setup duration spinner + List durations = new ArrayList<>(); + durations.add("1 minute"); + durations.add("5 minutes"); + durations.add("10 minutes"); + durations.add("30 minutes"); + durations.add("1 hour"); + durations.add("Unlimited"); + + ArrayAdapter adapter = new ArrayAdapter<>(this, + android.R.layout.simple_list_item_1, durations); + ((AutoCompleteTextView)binding.spinnerDuration).setAdapter(adapter); + ((AutoCompleteTextView)binding.spinnerDuration).setText(durations.get(0), false); + + // Start button + binding.btnStart.setOnClickListener(v -> startBatteryDrain()); + + // Stop button + binding.btnStop.setOnClickListener(v -> stopBatteryDrain()); + binding.btnStop.setEnabled(false); + + // View active test button + binding.btnViewActiveTest.setOnClickListener(v -> viewActiveTest()); + } + + private void setupPresets() { + binding.btnPresetMax.setOnCheckedChangeListener((chip, isChecked) -> { + if (isChecked) { + // Select all options + binding.cbCpu.setChecked(true); + binding.cbScreen.setChecked(true); + binding.cbFlashlight.setChecked(true); + binding.cbGps.setChecked(true); + binding.cbBluetooth.setChecked(true); + binding.cbWifi.setChecked(true); + binding.cbNetwork.setChecked(true); + binding.cbSensors.setChecked(true); + binding.cbCamera.setChecked(true); + binding.cbAudio.setChecked(true); + binding.cbVibration.setChecked(true); + } + }); + + binding.btnPresetCpuGpu.setOnCheckedChangeListener((chip, isChecked) -> { + if (isChecked) { + clearAllOptions(); + binding.cbCpu.setChecked(true); + binding.cbScreen.setChecked(true); + } + }); + + binding.btnPresetRadio.setOnCheckedChangeListener((chip, isChecked) -> { + if (isChecked) { + clearAllOptions(); + binding.cbGps.setChecked(true); + binding.cbBluetooth.setChecked(true); + binding.cbWifi.setChecked(true); + } + }); + + binding.btnPresetSensors.setOnCheckedChangeListener((chip, isChecked) -> { + if (isChecked) { + clearAllOptions(); + binding.cbSensors.setChecked(true); + binding.cbVibration.setChecked(true); + } + }); + } + + private void clearAllOptions() { + binding.cbCpu.setChecked(false); + binding.cbScreen.setChecked(false); + binding.cbFlashlight.setChecked(false); + binding.cbGps.setChecked(false); + binding.cbBluetooth.setChecked(false); + binding.cbWifi.setChecked(false); + binding.cbNetwork.setChecked(false); + binding.cbSensors.setChecked(false); + binding.cbCamera.setChecked(false); + binding.cbAudio.setChecked(false); + binding.cbVibration.setChecked(false); + } + + private void startBatteryDrain() { + // Validate at least one option is selected + if (!binding.cbCpu.isChecked() && !binding.cbScreen.isChecked() && + !binding.cbFlashlight.isChecked() && !binding.cbGps.isChecked() && + !binding.cbBluetooth.isChecked() && !binding.cbWifi.isChecked() && + !binding.cbNetwork.isChecked() && !binding.cbSensors.isChecked() && + !binding.cbCamera.isChecked() && !binding.cbAudio.isChecked() && + !binding.cbVibration.isChecked()) { + Toast.makeText(this, "Please select at least one option", Toast.LENGTH_SHORT).show(); + return; + } + + // Get duration + int durationMinutes = getDurationMinutes(); + + // Log test configuration + Log.d(TAG, "Starting battery drain test with configuration:"); + Log.d(TAG, " Duration: " + (durationMinutes == -1 ? "Unlimited" : durationMinutes + " minutes")); + Log.d(TAG, " CPU: " + binding.cbCpu.isChecked()); + Log.d(TAG, " Screen: " + binding.cbScreen.isChecked()); + Log.d(TAG, " Flashlight: " + binding.cbFlashlight.isChecked()); + Log.d(TAG, " GPS: " + binding.cbGps.isChecked()); + Log.d(TAG, " Bluetooth: " + binding.cbBluetooth.isChecked()); + Log.d(TAG, " WiFi: " + binding.cbWifi.isChecked()); + Log.d(TAG, " Network: " + binding.cbNetwork.isChecked()); + Log.d(TAG, " Sensors: " + binding.cbSensors.isChecked()); + Log.d(TAG, " Camera: " + binding.cbCamera.isChecked()); + Log.d(TAG, " Audio: " + binding.cbAudio.isChecked()); + Log.d(TAG, " Vibration: " + binding.cbVibration.isChecked()); + + // Create intent for service + Intent serviceIntent = new Intent(this, BatteryDrainService.class); + serviceIntent.putExtra("cpu", binding.cbCpu.isChecked()); + serviceIntent.putExtra("screen", binding.cbScreen.isChecked()); + serviceIntent.putExtra("flashlight", binding.cbFlashlight.isChecked()); + serviceIntent.putExtra("gps", binding.cbGps.isChecked()); + serviceIntent.putExtra("bluetooth", binding.cbBluetooth.isChecked()); + serviceIntent.putExtra("wifi", binding.cbWifi.isChecked()); + serviceIntent.putExtra("network", binding.cbNetwork.isChecked()); + serviceIntent.putExtra("sensors", binding.cbSensors.isChecked()); + serviceIntent.putExtra("camera", binding.cbCamera.isChecked()); + serviceIntent.putExtra("audio", binding.cbAudio.isChecked()); + serviceIntent.putExtra("vibration", binding.cbVibration.isChecked()); + serviceIntent.putExtra("duration", durationMinutes); + + // Save configuration to SharedPreferences + SharedPreferences prefs = getSharedPreferences("BatteryDrainPrefs", Context.MODE_PRIVATE); + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean("cpu", binding.cbCpu.isChecked()); + editor.putBoolean("screen", binding.cbScreen.isChecked()); + editor.putBoolean("flashlight", binding.cbFlashlight.isChecked()); + editor.putBoolean("gps", binding.cbGps.isChecked()); + editor.putBoolean("bluetooth", binding.cbBluetooth.isChecked()); + editor.putBoolean("wifi", binding.cbWifi.isChecked()); + editor.putBoolean("network", binding.cbNetwork.isChecked()); + editor.putBoolean("sensors", binding.cbSensors.isChecked()); + editor.putBoolean("camera", binding.cbCamera.isChecked()); + editor.putBoolean("audio", binding.cbAudio.isChecked()); + editor.putBoolean("vibration", binding.cbVibration.isChecked()); + editor.putInt("duration", durationMinutes); + editor.putLong("startTime", System.currentTimeMillis()); + editor.apply(); + + // Start service + startService(serviceIntent); + bindService(serviceIntent, serviceConnection, Context.BIND_AUTO_CREATE); + + // Update UI + binding.btnStart.setEnabled(false); + binding.btnStop.setEnabled(true); + disableOptions(); + + Toast.makeText(this, "Battery drain test started", Toast.LENGTH_SHORT).show(); + } + + private void stopBatteryDrain() { + Log.d(TAG, "Stopping battery drain test"); + + if (isServiceBound) { + unbindService(serviceConnection); + isServiceBound = false; + } + + stopService(new Intent(this, BatteryDrainService.class)); + + // Update UI + binding.btnStart.setEnabled(true); + binding.btnStop.setEnabled(false); + enableOptions(); + + Toast.makeText(this, "Battery drain test stopped", Toast.LENGTH_SHORT).show(); + } + + private int getDurationMinutes() { + String selected = ((AutoCompleteTextView)binding.spinnerDuration).getText().toString(); + switch (selected) { + case "1 minute": return 1; + case "5 minutes": return 5; + case "10 minutes": return 10; + case "30 minutes": return 30; + case "1 hour": return 60; + default: return -1; // Unlimited + } + } + + private void disableOptions() { + binding.cbCpu.setEnabled(false); + binding.cbScreen.setEnabled(false); + binding.cbFlashlight.setEnabled(false); + binding.cbGps.setEnabled(false); + binding.cbBluetooth.setEnabled(false); + binding.cbWifi.setEnabled(false); + binding.cbNetwork.setEnabled(false); + binding.cbSensors.setEnabled(false); + binding.cbCamera.setEnabled(false); + binding.cbAudio.setEnabled(false); + binding.cbVibration.setEnabled(false); + binding.spinnerDuration.setEnabled(false); + } + + private void enableOptions() { + binding.cbCpu.setEnabled(true); + binding.cbScreen.setEnabled(true); + binding.cbFlashlight.setEnabled(true); + binding.cbGps.setEnabled(true); + binding.cbBluetooth.setEnabled(true); + binding.cbWifi.setEnabled(true); + binding.cbNetwork.setEnabled(true); + binding.cbSensors.setEnabled(true); + binding.cbCamera.setEnabled(true); + binding.cbAudio.setEnabled(true); + binding.cbVibration.setEnabled(true); + binding.spinnerDuration.setEnabled(true); + } + + private void viewActiveTest() { + // Check if service is running + if (isServiceRunning(BatteryDrainService.class)) { + // Get test configuration from SharedPreferences (we'll store it when starting) + SharedPreferences prefs = getSharedPreferences("BatteryDrainPrefs", Context.MODE_PRIVATE); + + StringBuilder activeTestInfo = new StringBuilder(); + activeTestInfo.append("Battery Drain Test Active\n\n"); + + // Duration + int duration = prefs.getInt("duration", -1); + activeTestInfo.append("Duration: ").append(duration == -1 ? "Unlimited" : duration + " minutes").append("\n\n"); + + // Active components + activeTestInfo.append("Active Components:\n"); + if (prefs.getBoolean("cpu", false)) activeTestInfo.append("• CPU Intensive Operations\n"); + if (prefs.getBoolean("screen", false)) activeTestInfo.append("• Screen (Max Brightness)\n"); + if (prefs.getBoolean("flashlight", false)) activeTestInfo.append("• Flashlight\n"); + if (prefs.getBoolean("gps", false)) activeTestInfo.append("• GPS Location\n"); + if (prefs.getBoolean("bluetooth", false)) activeTestInfo.append("• Bluetooth Scanning\n"); + if (prefs.getBoolean("wifi", false)) activeTestInfo.append("• WiFi Scanning\n"); + if (prefs.getBoolean("network", false)) activeTestInfo.append("• Network Activity\n"); + if (prefs.getBoolean("sensors", false)) activeTestInfo.append("• Sensors\n"); + if (prefs.getBoolean("camera", false)) activeTestInfo.append("• Camera Preview\n"); + if (prefs.getBoolean("audio", false)) activeTestInfo.append("• Audio Playback\n"); + if (prefs.getBoolean("vibration", false)) activeTestInfo.append("• Vibration\n"); + + // Start time + long startTime = prefs.getLong("startTime", 0); + if (startTime > 0) { + long elapsedMinutes = (System.currentTimeMillis() - startTime) / 60000; + activeTestInfo.append("\nElapsed Time: ").append(elapsedMinutes).append(" minutes"); + } + + new AlertDialog.Builder(this) + .setTitle("Active Battery Drain Test") + .setMessage(activeTestInfo.toString()) + .setPositiveButton("OK", null) + .setNegativeButton("Stop Test", (dialog, which) -> stopBatteryDrain()) + .show(); + } else { + new AlertDialog.Builder(this) + .setTitle("No Active Test") + .setMessage("No battery drain test is currently running") + .setPositiveButton("OK", null) + .show(); + } + } + + private boolean isServiceRunning(Class serviceClass) { + ActivityManager manager = (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE); + for (ActivityManager.RunningServiceInfo service : manager.getRunningServices(Integer.MAX_VALUE)) { + if (serviceClass.getName().equals(service.service.getClassName())) { + return true; + } + } + return false; + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (isServiceBound) { + unbindService(serviceConnection); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/androidtestapp/Constants.java b/app/src/main/java/com/mattintech/androidtestapp/Constants.java new file mode 100644 index 0000000..d769846 --- /dev/null +++ b/app/src/main/java/com/mattintech/androidtestapp/Constants.java @@ -0,0 +1,15 @@ +package com.mattintech.androidtestapp; + +public class Constants { + public static final String LOG_TAG = "AndroidTestApp_"; + + // Notification channels + public static final String NOTIFICATION_CHANNEL_BATTERY = "battery_drain_channel"; + public static final String NOTIFICATION_CHANNEL_DOWNLOAD = "download_channel"; + public static final String NOTIFICATION_CHANNEL_BAD_BEHAVIOR = "bad_behavior_channel"; + + // Notification IDs + public static final int NOTIFICATION_ID_BATTERY = 1001; + public static final int NOTIFICATION_ID_DOWNLOAD = 1002; + public static final int NOTIFICATION_ID_BAD_BEHAVIOR = 1003; +} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/androidtestapp/DownloaderActivity.java b/app/src/main/java/com/mattintech/androidtestapp/DownloaderActivity.java new file mode 100644 index 0000000..742e9c6 --- /dev/null +++ b/app/src/main/java/com/mattintech/androidtestapp/DownloaderActivity.java @@ -0,0 +1,570 @@ +package com.mattintech.androidtestapp; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ArrayAdapter; +import android.widget.LinearLayout; +import android.widget.ProgressBar; +import android.widget.TextView; +import android.widget.Toast; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.core.graphics.Insets; +import androidx.work.Constraints; +import androidx.work.Data; +import androidx.work.ExistingPeriodicWorkPolicy; +import androidx.work.NetworkType; +import androidx.work.OneTimeWorkRequest; +import androidx.work.PeriodicWorkRequest; +import androidx.work.WorkManager; +import androidx.work.WorkInfo; +import com.mattintech.androidtestapp.databinding.ActivityDownloaderBinding; +import com.mattintech.androidtestapp.services.DownloadService; +import com.mattintech.androidtestapp.workers.DownloadWorker; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import android.app.AlertDialog; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import android.app.NotificationManager; +import android.app.PendingIntent; +import androidx.core.app.NotificationCompat; + +public class DownloaderActivity extends AppCompatActivity { + private static final String TAG = Constants.LOG_TAG + "DownloaderActivity"; + private static final String PREFS_NAME = "DownloaderPrefs"; + private static final String KEY_TOTAL_BYTES = "totalBytesDownloaded"; + private static final String KEY_DOWNLOAD_COUNT = "downloadCount"; + + private ActivityDownloaderBinding binding; + private DownloadProgressReceiver progressReceiver; + private SharedPreferences sharedPrefs; + private long totalBytesDownloaded = 0; + private int downloadCount = 0; + private Map activeDownloads = new HashMap<>(); + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = ActivityDownloaderBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + // Initialize SharedPreferences + sharedPrefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + loadStats(); + + // Setup edge-to-edge + setupWindowInsets(); + + setupUI(); + registerProgressReceiver(); + + // Setup toolbar navigation + binding.toolbar.setNavigationOnClickListener(v -> { + // Check if this is the root activity (launched from notification) + if (isTaskRoot()) { + // Create a new back stack with MainActivity + Intent intent = new Intent(this, MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + startActivity(intent); + } + finish(); + }); + } + + private void setupWindowInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.getRoot(), (v, windowInsets) -> { + Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()); + v.setPadding(v.getPaddingLeft(), insets.top, v.getPaddingRight(), insets.bottom); + return WindowInsetsCompat.CONSUMED; + }); + } + + private void setupUI() { + // Default URLs + binding.etUrl.setText("https://test1.bigstam.net/download/1mb.bin"); + + // File size spinner + List fileSizes = new ArrayList<>(); + fileSizes.add("1 MB"); + fileSizes.add("10 MB"); + fileSizes.add("100 MB"); + fileSizes.add("Custom URL"); + + ArrayAdapter sizeAdapter = new ArrayAdapter<>(this, + android.R.layout.simple_spinner_item, fileSizes); + sizeAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + binding.spinnerFileSize.setAdapter(sizeAdapter); + + // Schedule type spinner + List scheduleTypes = new ArrayList<>(); + scheduleTypes.add("One-time"); + scheduleTypes.add("Every 15 minutes"); + scheduleTypes.add("Every 30 minutes"); + scheduleTypes.add("Every 1 hour"); + scheduleTypes.add("Every 6 hours"); + scheduleTypes.add("Every 24 hours"); + + ArrayAdapter scheduleAdapter = new ArrayAdapter<>(this, + android.R.layout.simple_spinner_item, scheduleTypes); + scheduleAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + binding.spinnerSchedule.setAdapter(scheduleAdapter); + + // Network preference spinner + List networkTypes = new ArrayList<>(); + networkTypes.add("Any"); + networkTypes.add("WiFi Only"); + networkTypes.add("Cellular Only"); + + ArrayAdapter networkAdapter = new ArrayAdapter<>(this, + android.R.layout.simple_spinner_item, networkTypes); + networkAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + binding.spinnerNetwork.setAdapter(networkAdapter); + + // Concurrent downloads + binding.seekBarConcurrent.setMax(9); + binding.seekBarConcurrent.setProgress(0); + binding.tvConcurrentValue.setText("1"); + + binding.seekBarConcurrent.setOnSeekBarChangeListener(new android.widget.SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(android.widget.SeekBar seekBar, int progress, boolean fromUser) { + binding.tvConcurrentValue.setText(String.valueOf(progress + 1)); + } + + @Override + public void onStartTrackingTouch(android.widget.SeekBar seekBar) {} + + @Override + public void onStopTrackingTouch(android.widget.SeekBar seekBar) {} + }); + + // Update URL based on file size selection + binding.spinnerFileSize.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(android.widget.AdapterView parent, android.view.View view, int position, long id) { + switch (position) { + case 0: // 1 MB + binding.etUrl.setText("https://test1.bigstam.net/download/1mb.bin"); + break; + case 1: // 10 MB + binding.etUrl.setText("https://test1.bigstam.net/download/10mb.bin"); + break; + case 2: // 100 MB + binding.etUrl.setText("https://test1.bigstam.net/download/100mb.bin"); + break; + case 3: // Custom + binding.etUrl.setText(""); + binding.etUrl.setEnabled(true); + binding.etUrl.requestFocus(); + return; + } + binding.etUrl.setEnabled(false); + } + + @Override + public void onNothingSelected(android.widget.AdapterView parent) {} + }); + + // Buttons + binding.btnStartDownload.setOnClickListener(v -> startDownload()); + binding.btnStopAll.setOnClickListener(v -> stopAllDownloads()); + binding.btnClearHistory.setOnClickListener(v -> clearHistory()); + binding.btnViewScheduled.setOnClickListener(v -> viewScheduledDownloads()); + + updateStats(); + } + + private void registerProgressReceiver() { + progressReceiver = new DownloadProgressReceiver(); + IntentFilter filter = new IntentFilter(); + filter.addAction(DownloadService.ACTION_DOWNLOAD_PROGRESS); + filter.addAction(DownloadService.ACTION_DOWNLOAD_COMPLETE); + registerReceiver(progressReceiver, filter, Context.RECEIVER_NOT_EXPORTED); + Log.d(TAG, "Registered broadcast receiver for download progress"); + } + + private void startDownload() { + String url = binding.etUrl.getText().toString().trim(); + if (url.isEmpty()) { + Toast.makeText(this, "Please enter a URL", Toast.LENGTH_SHORT).show(); + return; + } + + boolean saveFile = binding.cbSaveFile.isChecked(); + boolean backgroundDownload = binding.cbBackground.isChecked(); + int concurrentDownloads = binding.seekBarConcurrent.getProgress() + 1; + String scheduleType = binding.spinnerSchedule.getSelectedItem().toString(); + String networkPref = binding.spinnerNetwork.getSelectedItem().toString(); + + // Create network constraints + NetworkType networkType; + switch (networkPref) { + case "WiFi Only": + networkType = NetworkType.UNMETERED; + break; + case "Cellular Only": + networkType = NetworkType.METERED; + break; + default: + networkType = NetworkType.CONNECTED; + break; + } + + Constraints constraints = new Constraints.Builder() + .setRequiredNetworkType(networkType) + .build(); + + // Create work data + Data inputData = new Data.Builder() + .putString("url", url) + .putBoolean("saveFile", saveFile) + .putBoolean("background", backgroundDownload) + .build(); + + if (scheduleType.equals("One-time")) { + // Start immediate downloads using service + for (int i = 0; i < concurrentDownloads; i++) { + Intent intent = new Intent(this, DownloadService.class); + intent.putExtra("url", url); + intent.putExtra("saveFile", saveFile); + intent.putExtra("downloadId", System.currentTimeMillis() + i); + Log.d(TAG, "Starting download service for URL: " + url); + startService(intent); + } + Toast.makeText(this, "Started " + concurrentDownloads + " download(s)", Toast.LENGTH_SHORT).show(); + } else { + // Schedule periodic downloads using WorkManager + long intervalMinutes = getIntervalMinutes(scheduleType); + + PeriodicWorkRequest periodicWork = new PeriodicWorkRequest.Builder( + DownloadWorker.class, intervalMinutes, TimeUnit.MINUTES) + .setConstraints(constraints) + .setInputData(inputData) + .addTag("scheduled_download") + .addTag("url:" + url) + .addTag("interval:" + intervalMinutes) + .build(); + + WorkManager.getInstance(this).enqueueUniquePeriodicWork( + "download_work", + ExistingPeriodicWorkPolicy.REPLACE, + periodicWork + ); + + // Show notification for scheduled downloads + showScheduledDownloadNotification(url, scheduleType); + + Toast.makeText(this, "Scheduled downloads " + scheduleType.toLowerCase(), Toast.LENGTH_SHORT).show(); + } + } + + private long getIntervalMinutes(String scheduleType) { + switch (scheduleType) { + case "Every 15 minutes": return 15; + case "Every 30 minutes": return 30; + case "Every 1 hour": return 60; + case "Every 6 hours": return 360; + case "Every 24 hours": return 1440; + default: return 15; + } + } + + private void stopAllDownloads() { + // Stop service + stopService(new Intent(this, DownloadService.class)); + + // Cancel scheduled work + WorkManager.getInstance(this).cancelUniqueWork("download_work"); + + // Cancel notification + NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.cancel(Constants.NOTIFICATION_ID_DOWNLOAD); + + // Clear all progress views + clearAllProgressViews(); + + Toast.makeText(this, "Stopped all downloads", Toast.LENGTH_SHORT).show(); + } + + private void addProgressView(long downloadId, String url) { + // Remove existing view if any + if (activeDownloads.containsKey(downloadId)) { + binding.progressContainer.removeView(activeDownloads.get(downloadId)); + } + + // Inflate new progress view + View progressView = LayoutInflater.from(this).inflate(R.layout.item_download_progress, binding.progressContainer, false); + + TextView tvDownloadId = progressView.findViewById(R.id.tvDownloadId); + TextView tvProgress = progressView.findViewById(R.id.tvDownloadProgress); + ProgressBar progressBar = progressView.findViewById(R.id.progressBarDownload); + + // Extract filename from URL + String filename = url.substring(url.lastIndexOf('/') + 1); + tvDownloadId.setText("Download: " + filename); + tvProgress.setText("0%"); + progressBar.setProgress(0); + + // Add to container + binding.progressContainer.addView(progressView); + activeDownloads.put(downloadId, progressView); + + // Hide "no downloads" text + binding.tvNoDownloads.setVisibility(View.GONE); + + Log.d(TAG, "Added progress view for download " + downloadId); + } + + private void updateProgressView(long downloadId, int progress) { + View progressView = activeDownloads.get(downloadId); + if (progressView != null) { + TextView tvProgress = progressView.findViewById(R.id.tvDownloadProgress); + ProgressBar progressBar = progressView.findViewById(R.id.progressBarDownload); + + tvProgress.setText(progress + "%"); + progressBar.setProgress(progress); + } + } + + private void removeProgressView(long downloadId) { + View progressView = activeDownloads.remove(downloadId); + if (progressView != null) { + binding.progressContainer.removeView(progressView); + + // Show "no downloads" text if empty + if (activeDownloads.isEmpty()) { + binding.tvNoDownloads.setVisibility(View.VISIBLE); + } + } + } + + private void clearAllProgressViews() { + binding.progressContainer.removeAllViews(); + activeDownloads.clear(); + binding.tvNoDownloads.setVisibility(View.VISIBLE); + } + + private void clearHistory() { + totalBytesDownloaded = 0; + downloadCount = 0; + saveStats(); + updateStats(); + Toast.makeText(this, "History cleared", Toast.LENGTH_SHORT).show(); + } + + private void updateStats() { + String stats = String.format("Downloads: %d | Total: %s", + downloadCount, formatBytes(totalBytesDownloaded)); + binding.tvStats.setText(stats); + Log.d(TAG, "Updated stats: " + stats); + } + + private void loadStats() { + totalBytesDownloaded = sharedPrefs.getLong(KEY_TOTAL_BYTES, 0); + downloadCount = sharedPrefs.getInt(KEY_DOWNLOAD_COUNT, 0); + Log.d(TAG, "Loaded stats - Downloads: " + downloadCount + ", Bytes: " + totalBytesDownloaded); + } + + private void saveStats() { + SharedPreferences.Editor editor = sharedPrefs.edit(); + editor.putLong(KEY_TOTAL_BYTES, totalBytesDownloaded); + editor.putInt(KEY_DOWNLOAD_COUNT, downloadCount); + editor.apply(); + Log.d(TAG, "Saved stats - Downloads: " + downloadCount + ", Bytes: " + totalBytesDownloaded); + } + + private String formatBytes(long bytes) { + if (bytes < 1024) return bytes + " B"; + else if (bytes < 1024 * 1024) return String.format("%.2f KB", bytes / 1024.0); + else if (bytes < 1024 * 1024 * 1024) return String.format("%.2f MB", bytes / (1024.0 * 1024)); + else return String.format("%.2f GB", bytes / (1024.0 * 1024 * 1024)); + } + + private void viewScheduledDownloads() { + WorkManager workManager = WorkManager.getInstance(this); + + try { + List workInfos = workManager.getWorkInfosForUniqueWork("download_work").get(); + + if (workInfos == null || workInfos.isEmpty()) { + showNoScheduledDownloadsDialog(); + return; + } + + StringBuilder scheduledInfo = new StringBuilder(); + SimpleDateFormat dateFormat = new SimpleDateFormat("MMM dd, yyyy HH:mm:ss", Locale.getDefault()); + + for (WorkInfo workInfo : workInfos) { + scheduledInfo.append("Status: ").append(workInfo.getState().name()).append("\n"); + + if (workInfo.getState() == WorkInfo.State.ENQUEUED || + workInfo.getState() == WorkInfo.State.RUNNING || + workInfo.getState() == WorkInfo.State.SUCCEEDED) { + + // Extract URL from tags + String url = workInfo.getTags().stream() + .filter(tag -> tag.startsWith("url:")) + .map(tag -> tag.substring(4)) + .findFirst() + .orElse("Unknown URL"); + + // Extract interval from tags + String interval = workInfo.getTags().stream() + .filter(tag -> tag.startsWith("interval:")) + .map(tag -> tag.substring(9)) + .findFirst() + .orElse("Unknown"); + + scheduledInfo.append("URL: ").append(url).append("\n"); + scheduledInfo.append("Interval: Every ").append(interval).append(" minutes\n"); + + // Show run status + if (workInfo.getState() == WorkInfo.State.RUNNING) { + scheduledInfo.append("Status: Currently running\n"); + } else if (workInfo.getState() == WorkInfo.State.ENQUEUED) { + scheduledInfo.append("Status: Waiting for next run\n"); + } else if (workInfo.getState() == WorkInfo.State.SUCCEEDED) { + scheduledInfo.append("Status: Last run succeeded\n"); + } + } + + scheduledInfo.append("\n"); + } + + if (scheduledInfo.length() == 0) { + showNoScheduledDownloadsDialog(); + } else { + showScheduledDownloadsDialog(scheduledInfo.toString()); + } + } catch (Exception e) { + Log.e(TAG, "Error getting scheduled downloads", e); + Toast.makeText(this, "Error retrieving scheduled downloads", Toast.LENGTH_SHORT).show(); + } + } + + private void showScheduledDownloadsDialog(String info) { + new AlertDialog.Builder(this) + .setTitle("Scheduled Downloads") + .setMessage(info) + .setPositiveButton("OK", null) + .setNegativeButton("Cancel All", (dialog, which) -> { + WorkManager.getInstance(this).cancelUniqueWork("download_work"); + Toast.makeText(this, "All scheduled downloads cancelled", Toast.LENGTH_SHORT).show(); + }) + .show(); + } + + private void showNoScheduledDownloadsDialog() { + new AlertDialog.Builder(this) + .setTitle("Scheduled Downloads") + .setMessage("No scheduled downloads found") + .setPositiveButton("OK", null) + .show(); + } + + private void showScheduledDownloadNotification(String url, String scheduleType) { + NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + + // Intent to open this activity when notification is clicked + Intent intent = new Intent(this, DownloaderActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + + // Intent to cancel downloads + Intent cancelIntent = new Intent(this, DownloaderActivity.class); + cancelIntent.setAction("CANCEL_DOWNLOADS"); + PendingIntent cancelPendingIntent = PendingIntent.getActivity(this, 1, cancelIntent, + PendingIntent.FLAG_IMMUTABLE); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, Constants.NOTIFICATION_CHANNEL_DOWNLOAD) + .setContentTitle("Scheduled Downloads Active") + .setContentText("Downloading " + scheduleType.toLowerCase() + ": " + url) + .setStyle(new NotificationCompat.BigTextStyle() + .bigText("URL: " + url + "\nSchedule: " + scheduleType)) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setContentIntent(pendingIntent) + .setOngoing(true) + .setAutoCancel(false) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", cancelPendingIntent); + + notificationManager.notify(Constants.NOTIFICATION_ID_DOWNLOAD, builder.build()); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + if ("CANCEL_DOWNLOADS".equals(intent.getAction())) { + stopAllDownloads(); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (progressReceiver != null) { + unregisterReceiver(progressReceiver); + } + } + + private class DownloadProgressReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + Log.d(TAG, "Received broadcast: " + intent.getAction()); + + long downloadId = intent.getLongExtra("downloadId", -1); + String url = intent.getStringExtra("url"); + + if (DownloadService.ACTION_DOWNLOAD_PROGRESS.equals(intent.getAction())) { + int progress = intent.getIntExtra("progress", 0); + Log.d(TAG, "Progress update received for download " + downloadId + ": " + progress + "%"); + + // Ensure UI updates happen on main thread + runOnUiThread(() -> { + // Add progress view if not exists + if (!activeDownloads.containsKey(downloadId) && url != null) { + addProgressView(downloadId, url); + } + updateProgressView(downloadId, progress); + }); + } else if (DownloadService.ACTION_DOWNLOAD_COMPLETE.equals(intent.getAction())) { + String error = intent.getStringExtra("error"); + if (error != null) { + Log.e(TAG, "Download " + downloadId + " error: " + error); + runOnUiThread(() -> { + Toast.makeText(context, "Download failed: " + error, Toast.LENGTH_SHORT).show(); + removeProgressView(downloadId); + }); + } else { + long bytesDownloaded = intent.getLongExtra("bytesDownloaded", 0); + Log.d(TAG, "Download " + downloadId + " complete, bytes: " + bytesDownloaded); + totalBytesDownloaded += bytesDownloaded; + downloadCount++; + saveStats(); // Save stats after update + + runOnUiThread(() -> { + updateStats(); + // Remove progress view after a short delay to show 100% + binding.progressContainer.postDelayed(() -> removeProgressView(downloadId), 1000); + }); + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/androidtestapp/IntroActivity.java b/app/src/main/java/com/mattintech/androidtestapp/IntroActivity.java new file mode 100644 index 0000000..1cc212c --- /dev/null +++ b/app/src/main/java/com/mattintech/androidtestapp/IntroActivity.java @@ -0,0 +1,93 @@ +package com.mattintech.androidtestapp; + +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.view.View; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.core.graphics.Insets; +import androidx.viewpager2.widget.ViewPager2; +import com.mattintech.androidtestapp.databinding.ActivityIntroBinding; +import com.google.android.material.tabs.TabLayoutMediator; + +public class IntroActivity extends AppCompatActivity { + private ActivityIntroBinding binding; + private IntroPagerAdapter adapter; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = ActivityIntroBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + // Setup edge-to-edge + setupWindowInsets(); + + setupViewPager(); + setupButtons(); + } + + private void setupWindowInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.getRoot(), (v, windowInsets) -> { + Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()); + v.setPadding(v.getPaddingLeft(), insets.top, v.getPaddingRight(), insets.bottom); + return WindowInsetsCompat.CONSUMED; + }); + } + + private void setupViewPager() { + adapter = new IntroPagerAdapter(this); + binding.viewPager.setAdapter(adapter); + + // Connect TabLayout with ViewPager2 + new TabLayoutMediator(binding.tabLayout, binding.viewPager, + (tab, position) -> { + // Empty - we just want the dots + }).attach(); + + // Update button visibility based on page + binding.viewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { + @Override + public void onPageSelected(int position) { + updateButtonVisibility(position); + } + }); + } + + private void setupButtons() { + binding.btnNext.setOnClickListener(v -> { + int currentItem = binding.viewPager.getCurrentItem(); + if (currentItem < adapter.getItemCount() - 1) { + binding.viewPager.setCurrentItem(currentItem + 1); + } + }); + + binding.btnSkip.setOnClickListener(v -> finishIntro()); + + binding.btnGetStarted.setOnClickListener(v -> finishIntro()); + } + + private void updateButtonVisibility(int position) { + boolean isLastPage = position == adapter.getItemCount() - 1; + + binding.btnNext.setVisibility(isLastPage ? View.GONE : View.VISIBLE); + binding.btnSkip.setVisibility(isLastPage ? View.GONE : View.VISIBLE); + binding.btnGetStarted.setVisibility(isLastPage ? View.VISIBLE : View.GONE); + + // Hide tab indicators on the last page + binding.tabLayout.setVisibility(isLastPage ? View.GONE : View.VISIBLE); + } + + private void finishIntro() { + // Mark intro as seen + SharedPreferences prefs = getSharedPreferences("AppPrefs", MODE_PRIVATE); + prefs.edit().putBoolean("intro_completed", true).apply(); + + // Navigate to MainActivity + Intent intent = new Intent(this, MainActivity.class); + startActivity(intent); + finish(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/androidtestapp/IntroPagerAdapter.java b/app/src/main/java/com/mattintech/androidtestapp/IntroPagerAdapter.java new file mode 100644 index 0000000..1ee09b1 --- /dev/null +++ b/app/src/main/java/com/mattintech/androidtestapp/IntroPagerAdapter.java @@ -0,0 +1,95 @@ +package com.mattintech.androidtestapp; + +import android.content.Context; +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; + +public class IntroPagerAdapter extends RecyclerView.Adapter { + private final Context context; + + private final IntroSlide[] slides = { + new IntroSlide( + R.drawable.ic_launcher_foreground, + "Welcome to Android Test App", + "A comprehensive testing tool designed to push your Android device to its limits" + ), + new IntroSlide( + R.drawable.ic_launcher_foreground, + "Battery Drain Testing", + "Test battery consumption with CPU, GPS, screen brightness, sensors, and more running simultaneously" + ), + new IntroSlide( + R.drawable.ic_launcher_foreground, + "Crash & ANR Simulation", + "Trigger various types of crashes and Application Not Responding scenarios for testing" + ), + new IntroSlide( + R.drawable.ic_launcher_foreground, + "Network Stress Testing", + "Schedule and execute concurrent file downloads to test network performance and reliability" + ), + new IntroSlide( + R.drawable.ic_launcher_foreground, + "Ready to Test?", + "All tests can drain battery and may cause temporary device issues. Use responsibly!" + ) + }; + + public IntroPagerAdapter(Context context) { + this.context = context; + } + + @NonNull + @Override + public IntroViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(context).inflate(R.layout.item_intro_slide, parent, false); + return new IntroViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull IntroViewHolder holder, int position) { + IntroSlide slide = slides[position]; + holder.bind(slide); + } + + @Override + public int getItemCount() { + return slides.length; + } + + static class IntroViewHolder extends RecyclerView.ViewHolder { + private final ImageView imageView; + private final TextView titleView; + private final TextView descriptionView; + + public IntroViewHolder(@NonNull View itemView) { + super(itemView); + imageView = itemView.findViewById(R.id.ivSlideImage); + titleView = itemView.findViewById(R.id.tvSlideTitle); + descriptionView = itemView.findViewById(R.id.tvSlideDescription); + } + + public void bind(IntroSlide slide) { + imageView.setImageResource(slide.imageResId); + titleView.setText(slide.title); + descriptionView.setText(slide.description); + } + } + + static class IntroSlide { + final int imageResId; + final String title; + final String description; + + IntroSlide(int imageResId, String title, String description) { + this.imageResId = imageResId; + this.title = title; + this.description = description; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/androidtestapp/MainActivity.java b/app/src/main/java/com/mattintech/androidtestapp/MainActivity.java new file mode 100644 index 0000000..b9492eb --- /dev/null +++ b/app/src/main/java/com/mattintech/androidtestapp/MainActivity.java @@ -0,0 +1,245 @@ +package com.mattintech.androidtestapp; + +import android.Manifest; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.PowerManager; +import android.provider.Settings; +import android.widget.Toast; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.core.graphics.Insets; +import com.mattintech.androidtestapp.databinding.ActivityMainBinding; +import java.util.ArrayList; +import java.util.List; + +public class MainActivity extends AppCompatActivity { + private ActivityMainBinding binding; + private static final int PERMISSION_REQUEST_CODE = 100; + + private final String[] requiredPermissions = { + Manifest.permission.CAMERA, + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.VIBRATE, + Manifest.permission.INTERNET, + Manifest.permission.ACCESS_NETWORK_STATE, + Manifest.permission.ACCESS_WIFI_STATE, + Manifest.permission.WAKE_LOCK, + Manifest.permission.HIGH_SAMPLING_RATE_SENSORS, + Manifest.permission.CHANGE_WIFI_STATE, + Manifest.permission.RECORD_AUDIO + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = ActivityMainBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + // Setup edge-to-edge + setupWindowInsets(); + + setupButtons(); + setVersionText(); + checkAndRequestPermissions(); + createNotificationChannels(); + checkBatteryOptimization(); + } + + private void setupWindowInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.getRoot(), (v, windowInsets) -> { + Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()); + v.setPadding(v.getPaddingLeft(), insets.top, v.getPaddingRight(), insets.bottom); + return WindowInsetsCompat.CONSUMED; + }); + } + + private void setupButtons() { + binding.btnBatteryDrain.setOnClickListener(v -> { + Intent intent = new Intent(this, BatteryDrainActivity.class); + startActivity(intent); + }); + + binding.btnBadBehavior.setOnClickListener(v -> { + Intent intent = new Intent(this, BadBehaviorActivity.class); + startActivity(intent); + }); + + binding.btnDownloader.setOnClickListener(v -> { + Intent intent = new Intent(this, DownloaderActivity.class); + startActivity(intent); + }); + + binding.btnInfo.setOnClickListener(v -> showInfoDialog()); + } + + private void setVersionText() { + try { + String version = "v" + getPackageManager().getPackageInfo(getPackageName(), 0).versionName; + binding.tvVersion.setText(version); + } catch (PackageManager.NameNotFoundException e) { + binding.tvVersion.setText("v1.0"); + } + } + + private void checkAndRequestPermissions() { + List permissionsNeeded = new ArrayList<>(); + + // Add runtime permissions based on Android version + for (String permission : requiredPermissions) { + if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) { + permissionsNeeded.add(permission); + } + } + + // Add Bluetooth permissions for Android 12+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) { + permissionsNeeded.add(Manifest.permission.BLUETOOTH_SCAN); + } + if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) { + permissionsNeeded.add(Manifest.permission.BLUETOOTH_CONNECT); + } + } + + // Add notification permission for Android 13+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + permissionsNeeded.add(Manifest.permission.POST_NOTIFICATIONS); + } + } + + if (!permissionsNeeded.isEmpty()) { + ActivityCompat.requestPermissions(this, + permissionsNeeded.toArray(new String[0]), PERMISSION_REQUEST_CODE); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + + if (requestCode == PERMISSION_REQUEST_CODE) { + boolean allGranted = true; + for (int result : grantResults) { + if (result != PackageManager.PERMISSION_GRANTED) { + allGranted = false; + break; + } + } + + if (!allGranted) { + Toast.makeText(this, "Some permissions were denied. App may not function properly.", + Toast.LENGTH_LONG).show(); + } + } + } + + private void showInfoDialog() { + String version = ""; + try { + version = getPackageManager().getPackageInfo(getPackageName(), 0).versionName; + } catch (PackageManager.NameNotFoundException e) { + version = "1.0"; + } + + new AlertDialog.Builder(this) + .setTitle("Android Test App") + .setMessage("A comprehensive testing tool for Android devices that simulates battery drain, crashes, ANRs, and network downloads to help developers test device behavior under stress conditions.") + .setPositiveButton("Report Issue / Request Feature", (dialog, which) -> { + String url = "https://github.com/mattintech/AndroidTestApp/issues"; + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + startActivity(intent); + }) + .setNegativeButton("Close", null) + .show(); + } + + private void createNotificationChannels() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationManager notificationManager = getSystemService(NotificationManager.class); + + // Battery Drain channel + NotificationChannel batteryChannel = new NotificationChannel( + Constants.NOTIFICATION_CHANNEL_BATTERY, + "Battery Drain Test", + NotificationManager.IMPORTANCE_HIGH + ); + batteryChannel.setDescription("Notifications for active battery drain tests"); + batteryChannel.setShowBadge(false); + batteryChannel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC); + notificationManager.createNotificationChannel(batteryChannel); + + // Downloads channel + NotificationChannel downloadChannel = new NotificationChannel( + Constants.NOTIFICATION_CHANNEL_DOWNLOAD, + "Downloads", + NotificationManager.IMPORTANCE_HIGH + ); + downloadChannel.setDescription("Notifications for scheduled downloads"); + downloadChannel.setShowBadge(false); + downloadChannel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC); + notificationManager.createNotificationChannel(downloadChannel); + + // Bad Behavior channel + NotificationChannel badBehaviorChannel = new NotificationChannel( + Constants.NOTIFICATION_CHANNEL_BAD_BEHAVIOR, + "Bad Behavior", + NotificationManager.IMPORTANCE_HIGH + ); + badBehaviorChannel.setDescription("Notifications for scheduled crashes/ANRs"); + badBehaviorChannel.setShowBadge(false); + badBehaviorChannel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC); + notificationManager.createNotificationChannel(badBehaviorChannel); + } + } + + private void checkBatteryOptimization() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PowerManager powerManager = (PowerManager) getSystemService(POWER_SERVICE); + String packageName = getPackageName(); + + if (!powerManager.isIgnoringBatteryOptimizations(packageName)) { + // Show dialog explaining why battery optimization should be disabled + new AlertDialog.Builder(this) + .setTitle("Disable Battery Optimization") + .setMessage("This app needs to run tests in the background without interruption.\n\n" + + "Battery optimization may stop or limit:\n" + + "• Long-running battery drain tests\n" + + "• Scheduled downloads\n" + + "• Background test services\n\n" + + "Please disable battery optimization for this app to ensure tests run properly.") + .setPositiveButton("Go to Settings", (dialog, which) -> { + try { + Intent intent = new Intent(); + intent.setAction(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); + intent.setData(Uri.parse("package:" + packageName)); + startActivity(intent); + } catch (Exception e) { + // Fallback to battery optimization settings + Intent intent = new Intent(); + intent.setAction(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS); + startActivity(intent); + } + }) + .setNegativeButton("Not Now", (dialog, which) -> { + Toast.makeText(this, "Tests may be interrupted by battery optimization", + Toast.LENGTH_LONG).show(); + }) + .setCancelable(false) + .show(); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/androidtestapp/SplashActivity.java b/app/src/main/java/com/mattintech/androidtestapp/SplashActivity.java new file mode 100644 index 0000000..a722920 --- /dev/null +++ b/app/src/main/java/com/mattintech/androidtestapp/SplashActivity.java @@ -0,0 +1,40 @@ +package com.mattintech.androidtestapp; + +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.core.graphics.Insets; +import com.mattintech.androidtestapp.databinding.ActivitySplashBinding; + +public class SplashActivity extends AppCompatActivity { + private static final long SPLASH_DELAY = 2000; // 2 seconds + private ActivitySplashBinding binding; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = ActivitySplashBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + // Check if this is first launch + SharedPreferences prefs = getSharedPreferences("AppPrefs", MODE_PRIVATE); + boolean introCompleted = prefs.getBoolean("intro_completed", false); + + // Navigate to appropriate activity after delay + new Handler(Looper.getMainLooper()).postDelayed(() -> { + Intent intent; + if (introCompleted) { + intent = new Intent(SplashActivity.this, MainActivity.class); + } else { + intent = new Intent(SplashActivity.this, IntroActivity.class); + } + startActivity(intent); + finish(); + }, SPLASH_DELAY); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/androidtestapp/services/BatteryDrainService.java b/app/src/main/java/com/mattintech/androidtestapp/services/BatteryDrainService.java new file mode 100644 index 0000000..7c52e0d --- /dev/null +++ b/app/src/main/java/com/mattintech/androidtestapp/services/BatteryDrainService.java @@ -0,0 +1,518 @@ +package com.mattintech.androidtestapp.services; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.le.BluetoothLeScanner; +import android.bluetooth.le.ScanCallback; +import android.bluetooth.le.ScanResult; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraManager; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.media.AudioAttributes; +import android.media.AudioFormat; +import android.media.AudioTrack; +import android.net.wifi.WifiManager; +import android.os.Binder; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.IBinder; +import android.os.Looper; +import android.os.PowerManager; +import android.os.VibrationEffect; +import android.os.Vibrator; +import android.util.Log; +import android.view.WindowManager; +import androidx.core.app.NotificationCompat; +import androidx.core.app.TaskStackBuilder; +import com.mattintech.androidtestapp.Constants; +import com.mattintech.androidtestapp.MainActivity; +import com.mattintech.androidtestapp.BatteryDrainActivity; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class BatteryDrainService extends Service implements SensorEventListener { + private static final String TAG = Constants.LOG_TAG + "BatteryDrainSvc"; + private static final String CHANNEL_ID = Constants.NOTIFICATION_CHANNEL_BATTERY; + private static final int NOTIFICATION_ID = Constants.NOTIFICATION_ID_BATTERY; + + private final IBinder binder = new BatteryDrainBinder(); + private ExecutorService executorService; + private Handler handler; + private HandlerThread handlerThread; + + // Components + private PowerManager.WakeLock wakeLock; + private CameraManager cameraManager; + private LocationManager locationManager; + private SensorManager sensorManager; + private Vibrator vibrator; + private AudioTrack audioTrack; + private WifiManager wifiManager; + private BluetoothAdapter bluetoothAdapter; + private BluetoothLeScanner bluetoothLeScanner; + + // State + private boolean isRunning = false; + private long endTime = 0; + private boolean cpuEnabled, screenEnabled, flashlightEnabled, gpsEnabled; + private boolean bluetoothEnabled, wifiEnabled, networkEnabled, sensorsEnabled; + private boolean cameraEnabled, audioEnabled, vibrationEnabled; + + // Threads + private Thread cpuThread; + private Thread networkThread; + + public class BatteryDrainBinder extends Binder { + public BatteryDrainService getService() { + return BatteryDrainService.this; + } + } + + @Override + public void onCreate() { + super.onCreate(); + executorService = Executors.newFixedThreadPool(4); + handlerThread = new HandlerThread("BatteryDrainHandler"); + handlerThread.start(); + handler = new Handler(handlerThread.getLooper()); + + // Initialize managers + PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE); + wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "BatteryDrain:WakeLock"); + + cameraManager = (CameraManager) getSystemService(Context.CAMERA_SERVICE); + locationManager = (LocationManager) getSystemService(Context.LOCATION_SERVICE); + sensorManager = (SensorManager) getSystemService(Context.SENSOR_SERVICE); + vibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE); + wifiManager = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE); + bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); + if (bluetoothAdapter != null) { + bluetoothLeScanner = bluetoothAdapter.getBluetoothLeScanner(); + } + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent != null) { + // Check if this is a stop action + if ("STOP".equals(intent.getAction())) { + Log.d(TAG, "Received stop action from notification"); + stopSelf(); + return START_NOT_STICKY; + } + + cpuEnabled = intent.getBooleanExtra("cpu", false); + screenEnabled = intent.getBooleanExtra("screen", false); + flashlightEnabled = intent.getBooleanExtra("flashlight", false); + gpsEnabled = intent.getBooleanExtra("gps", false); + bluetoothEnabled = intent.getBooleanExtra("bluetooth", false); + wifiEnabled = intent.getBooleanExtra("wifi", false); + networkEnabled = intent.getBooleanExtra("network", false); + sensorsEnabled = intent.getBooleanExtra("sensors", false); + cameraEnabled = intent.getBooleanExtra("camera", false); + audioEnabled = intent.getBooleanExtra("audio", false); + vibrationEnabled = intent.getBooleanExtra("vibration", false); + + int durationMinutes = intent.getIntExtra("duration", -1); + if (durationMinutes > 0) { + endTime = System.currentTimeMillis() + (durationMinutes * 60 * 1000); + } else { + endTime = 0; // Unlimited + } + + Log.d(TAG, "Battery drain service starting with features:"); + Log.d(TAG, " CPU: " + cpuEnabled); + Log.d(TAG, " Screen: " + screenEnabled); + Log.d(TAG, " Flashlight: " + flashlightEnabled); + Log.d(TAG, " GPS: " + gpsEnabled); + Log.d(TAG, " Bluetooth: " + bluetoothEnabled); + Log.d(TAG, " WiFi: " + wifiEnabled); + Log.d(TAG, " Network: " + networkEnabled); + Log.d(TAG, " Sensors: " + sensorsEnabled); + Log.d(TAG, " Camera: " + cameraEnabled); + Log.d(TAG, " Audio: " + audioEnabled); + Log.d(TAG, " Vibration: " + vibrationEnabled); + Log.d(TAG, " Duration: " + (durationMinutes == -1 ? "Unlimited" : durationMinutes + " minutes")); + + startForeground(NOTIFICATION_ID, createNotification()); + startDraining(); + } + + return START_STICKY; + } + + private Notification createNotification() { + // Create proper back stack for notification + Intent mainIntent = new Intent(this, MainActivity.class); + Intent batteryIntent = new Intent(this, BatteryDrainActivity.class); + + TaskStackBuilder stackBuilder = TaskStackBuilder.create(this); + stackBuilder.addNextIntent(mainIntent); + stackBuilder.addNextIntent(batteryIntent); + + PendingIntent pendingIntent = stackBuilder.getPendingIntent(0, + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + + // Build active components list + StringBuilder activeComponents = new StringBuilder(); + if (cpuEnabled) activeComponents.append("CPU, "); + if (screenEnabled) activeComponents.append("Screen, "); + if (flashlightEnabled) activeComponents.append("Flashlight, "); + if (gpsEnabled) activeComponents.append("GPS, "); + if (bluetoothEnabled) activeComponents.append("Bluetooth, "); + if (wifiEnabled) activeComponents.append("WiFi, "); + if (networkEnabled) activeComponents.append("Network, "); + if (sensorsEnabled) activeComponents.append("Sensors, "); + if (cameraEnabled) activeComponents.append("Camera, "); + if (audioEnabled) activeComponents.append("Audio, "); + if (vibrationEnabled) activeComponents.append("Vibration, "); + + // Remove trailing comma and space + if (activeComponents.length() > 2) { + activeComponents.setLength(activeComponents.length() - 2); + } + + String durationText; + if (endTime > 0) { + long remainingMillis = endTime - System.currentTimeMillis(); + if (remainingMillis <= 0) { + durationText = "Duration: Completing..."; + } else { + long minutes = remainingMillis / 60000; + long hours = minutes / 60; + minutes = minutes % 60; + if (hours > 0) { + durationText = String.format("Duration: %dh %dm remaining", hours, minutes); + } else { + durationText = String.format("Duration: %d min remaining", minutes); + } + } + } else { + durationText = "Duration: Unlimited"; + } + + return new NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Battery Drain Test Active") + .setContentText(activeComponents.toString()) + .setStyle(new NotificationCompat.BigTextStyle() + .bigText(activeComponents.toString() + "\n" + durationText)) + .setSmallIcon(android.R.drawable.ic_dialog_alert) + .setContentIntent(pendingIntent) + .setOngoing(true) + .setAutoCancel(false) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop Test", + PendingIntent.getService(this, 0, + new Intent(this, BatteryDrainService.class).setAction("STOP"), + PendingIntent.FLAG_IMMUTABLE)) + .build(); + } + + private void startDraining() { + isRunning = true; + wakeLock.acquire(); + Log.d(TAG, "Starting battery drain features"); + + if (cpuEnabled) { + Log.d(TAG, "Starting CPU drain"); + startCpuDrain(); + } + if (screenEnabled) { + Log.d(TAG, "Starting screen drain"); + startScreenDrain(); + } + if (flashlightEnabled) { + Log.d(TAG, "Starting flashlight drain"); + startFlashlightDrain(); + } + if (gpsEnabled) { + Log.d(TAG, "Starting GPS drain"); + startGpsDrain(); + } + if (bluetoothEnabled) { + Log.d(TAG, "Starting Bluetooth drain"); + startBluetoothDrain(); + } + if (wifiEnabled) { + Log.d(TAG, "Starting WiFi drain"); + startWifiDrain(); + } + if (networkEnabled) { + Log.d(TAG, "Starting network drain"); + startNetworkDrain(); + } + if (sensorsEnabled) { + Log.d(TAG, "Starting sensors drain"); + startSensorsDrain(); + } + if (cameraEnabled) { + Log.d(TAG, "Starting camera drain"); + startCameraDrain(); + } + if (audioEnabled) { + Log.d(TAG, "Starting audio drain"); + startAudioDrain(); + } + if (vibrationEnabled) { + Log.d(TAG, "Starting vibration drain"); + startVibrationDrain(); + } + + // Schedule check for duration and notification updates + if (endTime > 0) { + handler.postDelayed(this::checkDuration, 10000); // Check every 10 seconds + } + } + + private void checkDuration() { + if (System.currentTimeMillis() >= endTime) { + Log.d(TAG, "Battery drain duration reached, stopping service"); + stopSelf(); + } else { + // Update notification every 10 seconds + updateNotification(); + handler.postDelayed(this::checkDuration, 10000); // Check every 10 seconds + } + } + + private void updateNotification() { + NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.notify(NOTIFICATION_ID, createNotification()); + } + + private void startCpuDrain() { + cpuThread = new Thread(() -> { + while (isRunning) { + // Intensive mathematical calculations + double result = 0; + for (int i = 0; i < 1000000; i++) { + result += Math.sqrt(i) * Math.sin(i) * Math.cos(i); + } + } + }); + cpuThread.start(); + } + + private void startScreenDrain() { + // Note: Screen brightness control requires WRITE_SETTINGS permission + // which requires user to manually grant in settings + // For now, we'll just keep the wake lock + wakeLock.acquire(); + } + + private void startFlashlightDrain() { + try { + String cameraId = cameraManager.getCameraIdList()[0]; + cameraManager.setTorchMode(cameraId, true); + } catch (CameraAccessException e) { + e.printStackTrace(); + } + } + + private void startGpsDrain() { + if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) + == PackageManager.PERMISSION_GRANTED) { + locationManager.requestLocationUpdates( + LocationManager.GPS_PROVIDER, + 0, // minimum time interval + 0, // minimum distance + location -> {}, // Empty listener + handler.getLooper() + ); + } + } + + private void startBluetoothDrain() { + if (bluetoothLeScanner != null && + checkSelfPermission(android.Manifest.permission.BLUETOOTH_SCAN) + == PackageManager.PERMISSION_GRANTED) { + bluetoothLeScanner.startScan(new ScanCallback() { + @Override + public void onScanResult(int callbackType, ScanResult result) { + // Do nothing with results + } + }); + } + } + + private void startWifiDrain() { + if (wifiManager != null) { + handler.post(new Runnable() { + @Override + public void run() { + if (isRunning) { + wifiManager.startScan(); + handler.postDelayed(this, 1000); + } + } + }); + } + } + + private void startNetworkDrain() { + networkThread = new Thread(() -> { + while (isRunning) { + try { + URL url = new URL("https://www.google.com"); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.getResponseCode(); + connection.disconnect(); + Thread.sleep(100); + } catch (Exception e) { + e.printStackTrace(); + } + } + }); + networkThread.start(); + } + + private void startSensorsDrain() { + List sensors = sensorManager.getSensorList(Sensor.TYPE_ALL); + for (Sensor sensor : sensors) { + sensorManager.registerListener(this, sensor, SensorManager.SENSOR_DELAY_FASTEST); + } + } + + private void startCameraDrain() { + // Camera preview would require a surface view + // For battery drain, just opening camera is enough + try { + String[] cameraIds = cameraManager.getCameraIdList(); + if (cameraIds.length > 0) { + // Camera opening would be done here with proper surface + } + } catch (CameraAccessException e) { + e.printStackTrace(); + } + } + + private void startAudioDrain() { + int sampleRate = 44100; + int channelConfig = AudioFormat.CHANNEL_OUT_MONO; + int audioFormat = AudioFormat.ENCODING_PCM_16BIT; + int bufferSize = AudioTrack.getMinBufferSize(sampleRate, channelConfig, audioFormat); + + audioTrack = new AudioTrack.Builder() + .setAudioAttributes(new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build()) + .setAudioFormat(new AudioFormat.Builder() + .setEncoding(audioFormat) + .setSampleRate(sampleRate) + .setChannelMask(channelConfig) + .build()) + .setBufferSizeInBytes(bufferSize) + .build(); + + audioTrack.play(); + + // Generate silent audio + short[] buffer = new short[bufferSize]; + new Thread(() -> { + while (isRunning) { + audioTrack.write(buffer, 0, buffer.length); + } + }).start(); + } + + private void startVibrationDrain() { + if (vibrator != null && vibrator.hasVibrator()) { + handler.post(new Runnable() { + @Override + public void run() { + if (isRunning) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createOneShot(500, + VibrationEffect.DEFAULT_AMPLITUDE)); + } else { + vibrator.vibrate(500); + } + handler.postDelayed(this, 600); + } + } + }); + } + } + + @Override + public void onSensorChanged(SensorEvent event) { + // Do nothing - just drain battery + } + + @Override + public void onAccuracyChanged(Sensor sensor, int accuracy) { + // Do nothing + } + + @Override + public void onDestroy() { + super.onDestroy(); + Log.d(TAG, "Battery drain service stopping"); + isRunning = false; + + // Stop all draining activities + if (cpuThread != null) cpuThread.interrupt(); + if (networkThread != null) networkThread.interrupt(); + + if (flashlightEnabled) { + try { + String cameraId = cameraManager.getCameraIdList()[0]; + cameraManager.setTorchMode(cameraId, false); + } catch (CameraAccessException e) { + e.printStackTrace(); + } + } + + if (gpsEnabled) { + locationManager.removeUpdates(location -> {}); + } + + if (bluetoothLeScanner != null && bluetoothEnabled) { + bluetoothLeScanner.stopScan(new ScanCallback() {}); + } + + if (sensorsEnabled) { + sensorManager.unregisterListener(this); + } + + if (audioTrack != null) { + audioTrack.stop(); + audioTrack.release(); + } + + if (wakeLock.isHeld()) { + wakeLock.release(); + } + + handler.removeCallbacksAndMessages(null); + handlerThread.quit(); + executorService.shutdown(); + } + + @Override + public IBinder onBind(Intent intent) { + return binder; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/androidtestapp/services/DownloadService.java b/app/src/main/java/com/mattintech/androidtestapp/services/DownloadService.java new file mode 100644 index 0000000..a1d9d95 --- /dev/null +++ b/app/src/main/java/com/mattintech/androidtestapp/services/DownloadService.java @@ -0,0 +1,176 @@ +package com.mattintech.androidtestapp.services; + +import android.app.Service; +import android.content.Intent; +import android.os.Environment; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.IBinder; +import android.os.Looper; +import android.util.Log; +import com.mattintech.androidtestapp.Constants; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class DownloadService extends Service { + private static final String TAG = Constants.LOG_TAG + "DownloadService"; + public static final String ACTION_DOWNLOAD_PROGRESS = "com.mattintech.DOWNLOAD_PROGRESS"; + public static final String ACTION_DOWNLOAD_COMPLETE = "com.mattintech.DOWNLOAD_COMPLETE"; + + private ExecutorService executorService; + private Handler mainHandler; + + @Override + public void onCreate() { + super.onCreate(); + executorService = Executors.newFixedThreadPool(10); + mainHandler = new Handler(Looper.getMainLooper()); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent != null) { + String url = intent.getStringExtra("url"); + boolean saveFile = intent.getBooleanExtra("saveFile", false); + long downloadId = intent.getLongExtra("downloadId", System.currentTimeMillis()); + + if (url != null) { + executorService.execute(() -> downloadFile(url, saveFile, downloadId)); + } + } + return START_NOT_STICKY; + } + + private void downloadFile(String urlString, boolean saveFile, long downloadId) { + HttpURLConnection connection = null; + InputStream inputStream = null; + FileOutputStream outputStream = null; + + Log.d(TAG, "Starting download: " + urlString); + + try { + URL url = new URL(urlString); + connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(10000); + connection.setReadTimeout(10000); + connection.connect(); + + int responseCode = connection.getResponseCode(); + if (responseCode != HttpURLConnection.HTTP_OK) { + Log.e(TAG, "HTTP error code: " + responseCode); + sendDownloadError(downloadId, "HTTP error: " + responseCode, urlString); + return; + } + + int fileLength = connection.getContentLength(); + Log.d(TAG, "File length: " + fileLength + " bytes"); + inputStream = connection.getInputStream(); + + File outputFile = null; + if (saveFile) { + File downloadsDir = Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOWNLOADS); + if (!downloadsDir.exists()) { + downloadsDir.mkdirs(); + } + outputFile = new File(downloadsDir, "download_" + downloadId + ".tmp"); + outputStream = new FileOutputStream(outputFile); + } + + byte[] buffer = new byte[4096]; + long total = 0; + int count; + long lastProgressUpdate = 0; + + while ((count = inputStream.read(buffer)) != -1) { + total += count; + + if (saveFile && outputStream != null) { + outputStream.write(buffer, 0, count); + } + + // Update progress more frequently - every 10KB for smaller files, 100KB for larger + long updateInterval = fileLength < 1048576 ? 10240 : 102400; // 10KB if < 1MB, else 100KB + if (fileLength > 0 && (total - lastProgressUpdate) > updateInterval) { + lastProgressUpdate = total; + int progress = (int) ((total * 100) / fileLength); + Log.d(TAG, "Progress: " + progress + "% (" + total + "/" + fileLength + ")"); + sendProgressUpdate(downloadId, progress, urlString); + } + } + + // Send final 100% progress update + if (fileLength > 0) { + Log.d(TAG, "Download complete, sending 100% progress"); + sendProgressUpdate(downloadId, 100, urlString); + } + + // Send completion + Log.d(TAG, "Download finished, total bytes: " + total); + sendDownloadComplete(downloadId, total, urlString); + + } catch (IOException e) { + Log.e(TAG, "Download failed", e); + sendDownloadError(downloadId, "Download failed: " + e.getMessage(), urlString); + } finally { + try { + if (outputStream != null) outputStream.close(); + if (inputStream != null) inputStream.close(); + if (connection != null) connection.disconnect(); + } catch (IOException e) { + Log.e(TAG, "Error closing streams", e); + } + } + } + + private void sendProgressUpdate(long downloadId, int progress, String url) { + Intent intent = new Intent(ACTION_DOWNLOAD_PROGRESS); + intent.setPackage(getPackageName()); // Restrict to this app + intent.putExtra("downloadId", downloadId); + intent.putExtra("progress", progress); + intent.putExtra("url", url); + Log.d(TAG, "Sending progress broadcast for download " + downloadId + ": " + progress + "%"); + sendBroadcast(intent); + } + + private void sendDownloadComplete(long downloadId, long bytesDownloaded, String url) { + Intent intent = new Intent(ACTION_DOWNLOAD_COMPLETE); + intent.setPackage(getPackageName()); // Restrict to this app + intent.putExtra("downloadId", downloadId); + intent.putExtra("bytesDownloaded", bytesDownloaded); + intent.putExtra("url", url); + Log.d(TAG, "Sending download complete broadcast for download " + downloadId + ": " + bytesDownloaded + " bytes"); + sendBroadcast(intent); + } + + private void sendDownloadError(long downloadId, String error, String url) { + Intent intent = new Intent(ACTION_DOWNLOAD_COMPLETE); + intent.setPackage(getPackageName()); // Restrict to this app + intent.putExtra("downloadId", downloadId); + intent.putExtra("error", error); + intent.putExtra("bytesDownloaded", 0L); + intent.putExtra("url", url); + Log.d(TAG, "Sending download error broadcast for download " + downloadId + ": " + error); + sendBroadcast(intent); + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (executorService != null) { + executorService.shutdownNow(); + } + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/androidtestapp/workers/DownloadWorker.java b/app/src/main/java/com/mattintech/androidtestapp/workers/DownloadWorker.java new file mode 100644 index 0000000..b31ce91 --- /dev/null +++ b/app/src/main/java/com/mattintech/androidtestapp/workers/DownloadWorker.java @@ -0,0 +1,36 @@ +package com.mattintech.androidtestapp.workers; + +import android.content.Context; +import android.content.Intent; +import androidx.annotation.NonNull; +import androidx.work.Worker; +import androidx.work.WorkerParameters; +import com.mattintech.androidtestapp.services.DownloadService; + +public class DownloadWorker extends Worker { + + public DownloadWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) { + super(context, workerParams); + } + + @NonNull + @Override + public Result doWork() { + String url = getInputData().getString("url"); + boolean saveFile = getInputData().getBoolean("saveFile", false); + boolean background = getInputData().getBoolean("background", false); + + if (url == null || url.isEmpty()) { + return Result.failure(); + } + + // Start download service + Intent intent = new Intent(getApplicationContext(), DownloadService.class); + intent.putExtra("url", url); + intent.putExtra("saveFile", saveFile); + intent.putExtra("downloadId", System.currentTimeMillis()); + getApplicationContext().startService(intent); + + return Result.success(); + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/dot_active.xml b/app/src/main/res/drawable/dot_active.xml new file mode 100644 index 0000000..8f9c7e4 --- /dev/null +++ b/app/src/main/res/drawable/dot_active.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dot_inactive.xml b/app/src/main/res/drawable/dot_inactive.xml new file mode 100644 index 0000000..21d3674 --- /dev/null +++ b/app/src/main/res/drawable/dot_inactive.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..e06bd32 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,20 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..984b44a --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/tab_selector.xml b/app/src/main/res/drawable/tab_selector.xml new file mode 100644 index 0000000..1dc5bd9 --- /dev/null +++ b/app/src/main/res/drawable/tab_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_bad_behavior.xml b/app/src/main/res/layout/activity_bad_behavior.xml new file mode 100644 index 0000000..980b516 --- /dev/null +++ b/app/src/main/res/layout/activity_bad_behavior.xml @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + +