diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 345ce2e6c..0a6466406 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,6 +28,7 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3.2.2 + - name: Cache Gradle packages uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: @@ -43,6 +44,28 @@ jobs: key: ${{ runner.os }}-sonar restore-keys: ${{ runner.os }}-sonar + - name: Run static analysis (Detekt) + run: ./gradlew detekt + + - name: Run code formatting check (ktlint) + run: ./gradlew ktlintCheck + + - name: Upload Detekt reports + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: detekt-reports + path: | + build/reports/detekt/ + app/build/reports/detekt/ + + - name: Upload ktlint reports + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ktlint-reports + path: app/build/reports/ktlint/ + - name: Build and analyze if: ${{ github.secret_source != 'Actions' }} env: diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..d27ad4f75 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,299 @@ +# AGENTS.md + +This file provides guidance to AI coding agents when working with code in this repository. + +## Project Overview + +This is the **Blitzortung Lightning Monitor** Android application - a real-time lightning visualization app built in Kotlin that displays lightning strike data from the blitzortung.org network. The app features map-based strike visualization, proximity alerts, background monitoring, and localization support for 10+ languages. + +Project documentation: https://blitzortung.tryb.de + +## Build Commands + +### Building the app +```bash +./gradlew build +``` + +### Running tests +```bash +# Run all unit tests +./gradlew testDebugUnitTest + +# Run specific test class +./gradlew testDebugUnitTest --tests "org.blitzortung.android.alert.AlertResultTest" + +# Run tests with coverage report +./gradlew testDebugUnitTest jacocoTestReport +# Coverage report: app/build/reports/jacoco/jacocoTestReport/html/index.html +``` + +### Linting and code quality +```bash +# Run lint +./gradlew lint + +# Run SonarQube analysis (requires SONAR_TOKEN) +./gradlew sonar +``` + +### Building release artifacts +```bash +# Build APK +./gradlew assembleRelease + +# Build Android App Bundle (AAB) +./gradlew bundleRelease +``` + +### Installing on device +```bash +# Install debug build +./gradlew installDebug + +# Install and launch +adb install -r app/build/outputs/apk/debug/app-debug.apk +adb shell am start -n org.blitzortung.android.app/.app.Main +``` + +## Technology Stack + +- **Language**: Kotlin 2.2.10, JVM 17 +- **Min SDK**: 21, Target SDK: 35 +- **Build System**: Gradle 8.13 +- **DI Framework**: Dagger 2.57.1 +- **Testing**: JUnit 4, AssertJ, MockK, Robolectric +- **Map Library**: OSMDroid 6.1.20 +- **UI**: ViewBinding, Material Components, AndroidX libraries + +## Code Architecture + +### High-Level Structure + +The app follows a **component-based architecture** with Dagger 2 dependency injection coordinating the major subsystems: + +``` +BOApplication (Dagger root) + ├── Main Activity (foreground UI) + │ ├── MapFragment with overlays + │ ├── UI components (status, alerts, histogram, etc.) + │ └── Controllers (history, notifications, buttons) + └── AppService (background monitoring) + └── Minimal UI (notifications only) + +Shared Singletons: + ├── MainDataHandler / ServiceDataHandler (data flow) + ├── LocationHandler (GPS tracking) + ├── AlertHandler (proximity alerts) + └── DataProviderFactory (API clients) +``` + +### Dagger 2 Dependency Injection + +**Component**: `AppComponent` (singleton scope) + +**Modules**: +- `AppModule`: Application context, SharedPreferences, PackageInfo, NotificationManager, Vibrator, WakeLock +- `ServiceModule`: Handler (main thread), AlarmManager, Period (timing utilities) +- `ActivityBindingModule`: Auto-injects Main activity and AppService +- `SettingsModule`: Auto-injects SettingsFragment + +All major components (`@Inject` annotated) are wired through Dagger. The `BOApplication` class initializes the component graph on startup. + +### Event-Driven Communication (ConsumerContainer Pattern) + +The app uses a **publish-subscribe pattern** via `ConsumerContainer` for component communication: + +**Event Types**: +- `DataEvent`: Strike data updates (ResultEvent, RequestStartedEvent, StatusEvent) +- `AlertEvent`: Alert state changes (AlertResultEvent, AlertCancelEvent) +- `LocationEvent`: GPS location updates + +**Key Producers**: +- `MainDataHandler` / `ServiceDataHandler`: Broadcasts `DataEvent` when new strike data arrives +- `AlertHandler`: Broadcasts `AlertEvent` when proximity alerts trigger/cancel +- `LocationHandler`: Broadcasts `LocationEvent` when device location changes + +**Consumers** register via `requestUpdates(consumer: (Event) -> Unit)` and receive callbacks when events are broadcast. The last event is cached and immediately sent to new consumers. + +### Data Flow Architecture + +#### Data Providers (Strategy Pattern) + +Two interchangeable data provider implementations: + +1. **JsonRpcDataProvider** (default): JSON-RPC over HTTP with incremental updates +2. **BlitzortungHttpDataProvider**: HTTP GET with GZIP compression, requires auth + +Factory: `DataProviderFactory` selects provider based on user preference + +#### MainDataHandler (Data Orchestrator) + +- Manages data fetch lifecycle and coordinates updates +- **Caching**: `DataCache` with 5-minute TTL to avoid redundant API calls +- **Sequencing**: `SequenceValidator` prevents stale data from overwriting fresh data +- **Animation mode**: Special handling for historical playback +- Responds to location changes by updating grid parameters +- Publishes `DataEvent` to all registered consumers + +**Typical flow**: Timer triggers → updateData() → check cache → FetchDataTask (background) → API call → cache result → broadcast ResultEvent → consumers update UI/alerts + +### Alert System Architecture + +**AlertHandler**: Coordinates alert monitoring + +- Subscribes to `DataEvent` (strike updates) and `LocationEvent` (position changes) +- Delegates computation to `AlertDataHandler` +- Broadcasts `AlertEvent` to UI components and `AlertSignal` (vibration/sound) + +**AlertDataHandler**: Core alert logic + +- Divides 360° into sectors (default 8: N, NE, E, SE, S, SW, W, NW) +- Each sector has range buckets (10km, 25km, 50km, 100km, 250km, 500km) +- For each strike: calculates bearing + distance, assigns to sector/range +- Tracks closest strike in configurable time window (default 10 minutes) +- Returns `AlertResult` with sector data and closest strike distance/bearing + +**AlertResult**: Contains sector-wise strike counts and closest strike metadata for UI display + +### Map Overlay System + +**Overlays** (drawn in order): +1. `FadeOverlay`: Applies alpha transparency based on strike age +2. `StrikeListOverlay`: Renders individual strikes with age-based coloring (red→yellow→blue) +3. `OwnLocationOverlay`: Shows user's current position + +**Color Handlers**: +- `StrikeColorHandler`: Maps strike timestamp to color gradient +- Grid boundary rendering when in grid mode + +Overlays subscribe to events for automatic updates: +- `DataEvent` → add/expire strikes +- `LocationEvent` → update position marker +- `ZoomEvent` → adjust rendering detail + +### Component Lifecycle + +**Main Activity (foreground)**: +- `onResume()`: Registers all consumers, starts location updates, enables automatic data refresh +- `onPause()`: Unregisters consumers, stops location updates, optionally starts AppService for background alerts + +**AppService (background)**: +- Runs as foreground service with notification +- Minimal UI updates (no map rendering) +- Scheduled via AlarmManager for periodic data fetches +- Only active when background alerts are enabled + +## Package Structure + +``` +org.blitzortung.android/ +├── alert/ Alert computation, sector handling, alert events +├── app/ Main activity, AppService, boot receiver, UI controllers +│ ├── components/ Version checking, changelog +│ ├── controller/ History, notifications, button column +│ ├── permission/ Runtime permission requesters +│ └── view/ Custom views (AlertView, HistogramView, LegendView, etc.) +├── dagger/ DI component and modules +├── data/ Data models, caching, data handlers, fetch tasks +│ ├── beans/ Strike, Station, GridElement, Location +│ ├── cache/ DataCache implementation +│ └── provider/ DataProvider interface, factory, implementations +├── dialogs/ Info, log, alert, quick settings dialogs +├── jsonrpc/ JSON-RPC client for API communication +├── location/ LocationHandler, location providers +├── map/ MapFragment, overlays, color handlers +├── protocol/ Event interfaces, ConsumerContainer +├── settings/ SettingsFragment, preference handling +├── util/ Time formatting, measurement systems, period utilities +└── widget/ App widget provider +``` + +## Testing Strategy + +**Test Framework**: JUnit 4 + Robolectric for Android components + +**Key Test Utilities**: +- `MockK`: Mocking framework (preferred over Mockito for Kotlin) +- `AssertJ`: Fluent assertions +- `Robolectric`: Android framework emulation for unit tests + +**Test Patterns**: +- Mock external dependencies (SharedPreferences, Context, API clients) +- Use `@Before` for common setup +- Test public interface behavior, not implementation details + +**Running Single Tests**: Use `--tests` flag with fully qualified test name: +```bash +./gradlew testDebugUnitTest --tests "org.blitzortung.android.data.ParametersTest" +``` + +## Important Patterns and Conventions + +### SharedPreferences Integration + +Many components implement `OnSharedPreferenceChangeListener` to react to settings changes. Register in `onResume()`, unregister in `onPause()`. + +### ViewBinding + +All activities/fragments use ViewBinding (enabled in build.gradle). Access views via `binding.viewId` instead of `findViewById()`. + +### Threading Model + +- **Main thread**: UI updates, Handler callbacks +- **Background threads**: Network I/O (FetchDataTask), WorkManager tasks +- Use `Handler.post()` to marshal callbacks to main thread + +### Location Handling + +Location providers are abstracted via `LocationProvider` interface. Implementations: +- `GPSLocationProvider`: Device GPS +- `ManualLocationProvider`: User-specified coordinates +- `NetworkLocationProvider`: Network-based location + +### Preference Keys + +Preference keys are defined in `PreferenceKey` enum. Use these constants instead of hardcoded strings. + +## CI/CD + +GitHub Actions workflows: +- **build.yml**: Runs on push/PR, executes tests + coverage + SonarQube analysis +- **dependabot.yml**: Automated dependency updates +- **scorecard.yml**: OpenSSF security scorecard +- **dependency-review.yml**: Dependency vulnerability scanning + +## Development Notes + +- **Code Coverage**: Generated by JaCoCo, viewable at `app/build/reports/jacoco/jacocoTestReport/html/index.html` +- **Lint Reports**: Generated at `app/build/reports/lint-results-debug.xml` +- **minSdkVersion 21**: Minimum Android 5.0 (Lollipop) +- **Translations**: Strings in `res/values-{locale}/strings.xml` (10+ languages supported) +- **ChangeLog**: Parsed from `ChangeLog` file and displayed in app on first run after update + +## Common Tasks + +### Adding a new data provider +1. Implement `DataProvider` interface in `data.provider.data/` +2. Register in `DataProviderFactory` +3. Add corresponding `DataProviderType` enum value +4. Update SharedPreferences to allow selection + +### Adding a new alert feature +1. Modify `AlertParameters` to include new configuration +2. Update `AlertDataHandler.checkStrikes()` logic +3. Extend `AlertResult` if new metadata needed +4. Update `AlertView` to display new information + +### Adding a new map overlay +1. Extend `Overlay` class and implement `LayerOverlay` marker interface +2. Implement `draw()` method for canvas rendering +3. Optionally implement `MapListener` for zoom/scroll events +4. Register overlay in `Main.onStart()` and add to `mapView.overlays` +5. Subscribe to relevant events for data updates + +### Adding a new preference +1. Add entry to `PreferenceKey` enum +2. Add XML definition in `res/xml/preferences.xml` +3. Implement `OnSharedPreferenceChangeListener` in affected components +4. Update default values in `PreferenceManager.setDefaultValues()` call diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md new file mode 100644 index 000000000..8a39f8606 --- /dev/null +++ b/IMPROVEMENTS.md @@ -0,0 +1,236 @@ + +🟢 Low Effort (Quick Wins - Hours to Days) + +1. Enable ProGuard/R8 for Release Builds + +Location: app/build.gradle.kts:29-34 +release { +isMinifyEnabled = true // Change from false +isShrinkResources = true +} +Impact: Reduces APK size by 30-50%, improves performance + +2. Add Gradle Build Optimizations + +File: gradle.properties (add these) +org.gradle.parallel=true +org.gradle.caching=true +kotlin.incremental=true +Impact: Faster build times + +3. Fix Deprecated Handler Constructor + +Location: dagger/module/ServiceModule.kt:17 +// Replace +Handler() +// With +Handler(Looper.getMainLooper()) + +4. Add Static Analysis Tools + +Add to: app/build.gradle.kts +plugins { +id("io.gitlab.arturbosch.detekt") version "1.23.4" +} +Impact: Catch code quality issues early + +5. Add Missing Test Libraries + +testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0") +testImplementation("app.cash.turbine:turbine:1.0.0") + + --- +🟡 Medium Effort (Days to Weeks) + +6. Migrate Deprecated Activity Result APIs + +Location: settings/SettingsFragment.kt:231-253 +- Replace startActivityForResult() with Activity Result API +- ~3 locations affected + Impact: Future-proof for Android API changes + +7. Fix Deprecated Vibrator API + +Location: alert/handler/AlertSignal.kt:71 +// Replace +vibrator.vibrate(duration) +// With +if (Build.VERSION.SDK_INT >= 26) { +vibrator.vibrate(VibrationEffect.createOneShot(duration, amplitude)) +} else { +@Suppress("DEPRECATION") +vibrator.vibrate(duration) +} + +8. Replace HttpURLConnection with Retrofit/OkHttp + +Location: jsonrpc/HttpServiceClientDefault.kt +- Modernizes networking layer +- Better error handling, interceptors, logging + Impact: Cleaner code, better performance + +9. Add ViewModel Layer + +Create: app/viewmodels/MainViewModel.kt +- Add ViewModel for Main activity +- Separates UI logic from business logic + Files affected: app/Main.kt (refactor 100+ lines) + +10. Increase Test Coverage + +- Add tests for AppService.kt (currently untested) +- Add UI tests for major screens +- Target: 40-50% coverage (currently ~20%) + Effort: Write ~20-30 new tests + +11. Implement LRU Cache with Size Limits + +Location: data/cache/DataCache.kt +private val cache = object : LinkedHashMap( +initialCapacity = 16, +loadFactor = 0.75f, +accessOrder = true +) { +override fun removeEldestEntry(eldest: Map.Entry<*, *>) = size > 50 +} +Impact: Prevents memory leaks + +12. Fix Fragment Lifecycle Deprecation + +Location: map/MapFragment.kt:49 +- Move logic from onActivityCreated() to onViewCreated() + + --- +🟠 High Effort (Weeks to Months) + +13. Migrate KAPT to KSP + +Impact: 25-50% faster build times +Requires: +- Update to Hilt (or wait for Dagger KSP support) +- Update app/build.gradle.kts +- Test all DI thoroughly + +14. Replace Handler-based Threading with Coroutines + +Locations: +- data/MainDataHandler.kt:354-386 +- dagger/module/ServiceModule.kt +- All Handler.post() calls throughout codebase + +Before: +handler.postDelayed({ updateData() }, interval) + +After: +viewModelScope.launch { +while(isActive) { +updateData() +delay(interval) +} +} +Impact: Modern, cancellable, easier to test + +15. Migrate Dagger 2 to Hilt + +Affected: All files in dagger/ package +- Reduces boilerplate by ~50% +- Better AndroidX integration +- Enables KSP migration + Effort: 2-3 weeks (all DI needs rewriting) + +16. Replace ConsumerContainer with StateFlow + +Location: protocol/ConsumerContainer.kt +Affected: 15+ files using ConsumerContainer +// Replace +val dataConsumerContainer = object : ConsumerContainer() {} + +// With +private val _dataEvents = MutableStateFlow(null) +val dataEvents: StateFlow = _dataEvents.asStateFlow() +Impact: Better lifecycle awareness, built-in backpressure + +17. Add Repository Layer + +Create: data/repository/ package +- Abstracts data sources +- Enables offline-first architecture +- Easier testing with fakes + Files to refactor: MainDataHandler.kt, ServiceDataHandler.kt + + --- +🔴 Very High Effort (Months) + +18. Modularize Codebase + +Structure: +:app +:core +:feature-main +:feature-alerts +:feature-settings +:data +Impact: Better build times, clearer boundaries, parallel development +Effort: 2-3 months + +19. Add Jetpack Compose for New UI + +- Incrementally migrate screens to Compose +- Keep existing views for now +- Start with simple screens (Settings, Dialogs) + Effort: 3-6 months for full migration + +20. Implement Offline-First Architecture with Room + +- Add Room database for persistent caching +- Implement proper repository pattern +- Add WorkManager for background sync + Effort: 1-2 months + +21. Refactor Main.kt (694 lines) + +Split into: +- MainViewModel (state management) +- MainCoordinator (navigation) +- Multiple smaller components +- Extract overlays to separate classes + Effort: 3-4 weeks of careful refactoring + + --- +📊 Summary by Impact vs Effort + +High Impact, Low Effort ⭐ (Do First) + +1. Enable ProGuard/R8 +2. Add Gradle optimizations +3. Fix deprecated Handler +4. Add static analysis (detekt) + +High Impact, Medium Effort 🎯 + +6. Migrate Activity Result API +7. Add ViewModel layer +8. Replace HttpURLConnection with Retrofit +9. Increase test coverage to 40%+ + +High Impact, High Effort 🚀 (Strategic) + +14. Migrate to Coroutines (replace Handler) +15. Migrate to Hilt +16. Replace ConsumerContainer with StateFlow +17. Add Repository layer + +Lower Priority 📋 + +11. LRU cache implementation +12. Full modularization +13. Jetpack Compose migration +14. Offline-first with Room + + --- +Recommended Roadmap: +1. Sprint 1: Items #1-5 (quick wins) +2. Sprint 2-3: Items #6-9 (deprecation fixes + ViewModel) +3. Sprint 4-6: Items #14, #16 (Coroutines migration) +4. Quarter 2: Items #15, #17 (Hilt + Repository) +5. Long-term: Items #18-21 (major refactors) diff --git a/VIEWMODEL_MIGRATION_EXAMPLE.md b/VIEWMODEL_MIGRATION_EXAMPLE.md new file mode 100644 index 000000000..8eb87da83 --- /dev/null +++ b/VIEWMODEL_MIGRATION_EXAMPLE.md @@ -0,0 +1,655 @@ +# ViewModel Migration Example + +This guide shows how to migrate from direct handler injection to using ViewModels in your activities and fragments. + +## Before & After Comparison + +### BEFORE: Direct Handler Injection +```kotlin +class Main : FragmentActivity() { + // Direct injection of handlers + @set:Inject + internal lateinit var locationHandler: LocationHandler + + @set:Inject + internal lateinit var alertHandler: AlertHandler + + @set:Inject + internal lateinit var dataHandler: MainDataHandler + + // Callback-based data updates + private val dataEventConsumer: (DataEvent) -> Unit = { event -> + when (event) { + is RequestStartedEvent -> { + statusComponent.startProgress() + } + is ResultEvent -> { + currentResult = event + updateUI(event) + } + } + } + + override fun onStart() { + super.onStart() + // Manual registration + dataHandler.requestUpdates(dataEventConsumer) + locationHandler.requestUpdates(locationEventConsumer) + } + + override fun onStop() { + super.onStop() + // Manual cleanup + dataHandler.removeUpdates(dataEventConsumer) + locationHandler.removeUpdates(locationEventConsumer) + } +} +``` + +### AFTER: ViewModel-Based Approach +```kotlin +class Main : FragmentActivity() { + // Inject ViewModelFactory (only once) + @set:Inject + internal lateinit var viewModelFactory: ViewModelProvider.Factory + + // Keep other non-data dependencies + @set:Inject + internal lateinit var preferences: SharedPreferences + + @set:Inject + internal lateinit var versionComponent: VersionComponent + + // ViewModel is lifecycle-aware + private val viewModel: MainViewModel by viewModels { viewModelFactory } + + override fun onCreate(savedInstanceState: Bundle?) { + AndroidInjection.inject(this) + super.onCreate(savedInstanceState) + + // Set up UI + binding = MainBinding.inflate(layoutInflater) + setContentView(binding.root) + + // Observe data reactively + observeViewModel() + + // Start data updates + viewModel.start() + } + + private fun observeViewModel() { + // Observe data events using lifecycleScope + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + // Observe loading state + launch { + viewModel.isLoading.collect { isLoading -> + if (isLoading) { + statusComponent.startProgress() + } else { + statusComponent.stopProgress() + } + } + } + + // Observe error state + launch { + viewModel.hasError.collect { hasError -> + statusComponent.indicateError(hasError) + } + } + + // Observe result data + launch { + viewModel.currentResult.collect { result -> + result?.let { updateUI(it) } + } + } + + // Observe location updates + launch { + viewModel.locationEvents.collect { locationEvent -> + locationEvent?.let { + updateLocation(it.location) + } + } + } + + // Observe alert events + launch { + viewModel.alertEvents.collect { alertEvent -> + alertEvent?.let { handleAlert(it) } + } + } + } + } + } + + private fun updateUI(result: ResultEvent) { + // Same UI update logic as before + val resultParameters = result.parameters + + clearDataIfRequested() + + val initializeOverlay = strikeListOverlay.parameters != resultParameters + with(strikeListOverlay) { + parameters = resultParameters + gridParameters = result.gridParameters + referenceTime = result.referenceTime + } + + if (result.updated >= 0 && !initializeOverlay) { + strikeListOverlay.expireStrikes() + } else { + strikeListOverlay.clear() + } + + result.strikes?.let { strikes -> + val strikesToAdd = if (result.updated > 0 && !initializeOverlay) { + strikes.takeLast(result.updated) + } else { + strikes + } + strikeListOverlay.addStrikes(strikesToAdd) + } + + strikeListOverlay.refresh() + mapFragment.mapView.invalidate() + + binding.legendView.requestLayout() + binding.timeSlider.update(result.parameters, result.history!!) + } + + // No need for onStop cleanup - ViewModel handles it! +} +``` + +## Step-by-Step Migration Guide + +### Step 1: Update Activity to Use ViewModels + +Add required imports: +```kotlin +import androidx.activity.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.launch +import org.blitzortung.android.app.viewmodel.MainViewModel +``` + +### Step 2: Replace Handler Injections + +**Remove:** +```kotlin +@set:Inject +internal lateinit var locationHandler: LocationHandler + +@set:Inject +internal lateinit var alertHandler: AlertHandler + +@set:Inject +internal lateinit var dataHandler: MainDataHandler +``` + +**Add:** +```kotlin +@set:Inject +internal lateinit var viewModelFactory: ViewModelProvider.Factory + +private val viewModel: MainViewModel by viewModels { viewModelFactory } +``` + +### Step 3: Replace Callback Consumers with Flow Collectors + +**Remove:** +```kotlin +private val dataEventConsumer: (DataEvent) -> Unit = { event -> + when (event) { + is RequestStartedEvent -> { /* ... */ } + is ResultEvent -> { /* ... */ } + is StatusEvent -> { /* ... */ } + } +} + +override fun onStart() { + super.onStart() + dataHandler.requestUpdates(dataEventConsumer) +} + +override fun onStop() { + super.onStop() + dataHandler.removeUpdates(dataEventConsumer) +} +``` + +**Add:** +```kotlin +override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // ... setup code ... + observeViewModel() +} + +private fun observeViewModel() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + // Multiple parallel collectors + launch { + viewModel.dataEvents.collect { event -> + when (event) { + is RequestStartedEvent -> { /* ... */ } + is ResultEvent -> { /* ... */ } + is StatusEvent -> { /* ... */ } + null -> { /* initial state */ } + } + } + } + } + } +} +``` + +### Step 4: Update Data Operations + +**Before:** +```kotlin +dataHandler.updateData() +dataHandler.goRealtime() +dataHandler.setPosition(position) +dataHandler.start() +dataHandler.stop() +``` + +**After:** +```kotlin +viewModel.updateData() +viewModel.goRealtime() +viewModel.setPosition(position) +viewModel.start() +viewModel.stop() +``` + +### Step 5: Update HistoryController (if needed) + +The `HistoryController` currently takes `MainDataHandler` as a parameter. You have two options: + +**Option A: Keep direct handler access (temporary)** +```kotlin +// Keep the handler injection temporarily for HistoryController +@set:Inject +internal lateinit var dataHandler: MainDataHandler + +historyController = HistoryController(binding, buttonColumnHandler, dataHandler) +``` + +**Option B: Update HistoryController to use ViewModel** +```kotlin +// Modify HistoryController constructor +class HistoryController( + binding: MainBinding, + buttonColumnHandler: ButtonColumnHandler, + private val viewModel: MainViewModel // Changed from MainDataHandler +) { + fun goRealtime() { + if (viewModel.goRealtime()) { // Changed from dataHandler + viewModel.restart() + } + } + + // ... other methods updated similarly +} + +// In Main activity: +historyController = HistoryController(binding, buttonColumnHandler, viewModel) +``` + +## Fragment Example: MapFragment Migration + +### BEFORE: Direct Preference Access +```kotlin +class MapFragment : Fragment() { + private lateinit var mPrefs: SharedPreferences + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + preferences.registerOnSharedPreferenceChangeListener(this) + + mPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + // Restore map state from SharedPreferences + val zoomLevel = mPrefs.getFloat(PREFS_ZOOM_LEVEL_DOUBLE, 3.0f) + mapView.controller.setZoom(zoomLevel.toDouble()) + + val latitudeString = mPrefs.getString(PREFS_LATITUDE_STRING, null) + val longitudeString = mPrefs.getString(PREFS_LONGITUDE_STRING, null) + if (latitudeString != null && longitudeString != null) { + val latitude = latitudeString.toDouble() + val longitude = longitudeString.toDouble() + mapView.setExpectedCenter(GeoPoint(latitude, longitude)) + } + } + + override fun onPause() { + super.onPause() + // Save map state to SharedPreferences + mPrefs.edit { + putFloat(PREFS_ZOOM_LEVEL_DOUBLE, mapView.zoomLevelDouble.toFloat()) + putString(PREFS_LATITUDE_STRING, mapView.mapCenter.latitude.toString()) + putString(PREFS_LONGITUDE_STRING, mapView.mapCenter.longitude.toString()) + } + } +} +``` + +### AFTER: ViewModel-Based Approach +```kotlin +class MapFragment : Fragment() { + @set:Inject + internal lateinit var viewModelFactory: ViewModelProvider.Factory + + private val viewModel: MapViewModel by viewModels { viewModelFactory } + + lateinit var mapView: OwnMapView + private set + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Inject dependencies + AndroidSupportInjection.inject(this) + + // Set up map listeners + setupMapListeners() + + // Observe ViewModel + observeViewModel() + } + + private fun observeViewModel() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + // Restore zoom level + launch { + viewModel.zoomLevel.collect { zoom -> + if (mapView.zoomLevelDouble != zoom) { + mapView.controller.setZoom(zoom) + } + } + } + + // Restore center position + launch { + viewModel.centerPosition.collect { position -> + position?.let { + if (mapView.mapCenter != it) { + mapView.setExpectedCenter(it) + } + } + } + } + } + } + } + + private fun setupMapListeners() { + // Save zoom changes to ViewModel + mapView.addMapListener(object : org.osmdroid.events.MapListener { + override fun onZoom(event: ZoomEvent?): Boolean { + event?.let { + viewModel.updateZoomLevel(it.zoomLevel) + } + return true + } + + override fun onScroll(event: org.osmdroid.events.ScrollEvent?): Boolean { + // Save center position periodically + viewModel.updateCenterPosition(mapView.mapCenter as GeoPoint) + return true + } + }) + } + + override fun onPause() { + super.onPause() + // Save final state to ViewModel (will persist across config changes) + viewModel.saveMapState( + mapView.zoomLevelDouble, + mapView.mapCenter as GeoPoint + ) + } +} +``` + +## Settings Example: Using SettingsViewModel + +### BEFORE: Direct SharedPreferences Access +```kotlin +class SettingsFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.preferences, rootKey) + + // Direct preference access + val dataSourcePref = findPreference("data_source") + dataSourcePref?.setOnPreferenceChangeListener { _, newValue -> + // Handle change + true + } + } +} +``` + +### AFTER: ViewModel-Based Approach +```kotlin +class SettingsFragment : PreferenceFragmentCompat() { + @set:Inject + internal lateinit var viewModelFactory: ViewModelProvider.Factory + + private val viewModel: SettingsViewModel by viewModels { viewModelFactory } + + override fun onCreate(savedInstanceState: Bundle?) { + AndroidSupportInjection.inject(this) + super.onCreate(savedInstanceState) + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.preferences, rootKey) + + observeViewModel() + } + + private fun observeViewModel() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.preferenceChanged.collect { key -> + key?.let { handlePreferenceChange(it) } + } + } + } + } + + private fun handlePreferenceChange(key: PreferenceKey) { + when (key) { + PreferenceKey.DATA_SOURCE -> { + // React to data source change + val newValue = viewModel.getStringPreference(key, "RPC") + // Update UI or trigger actions + } + PreferenceKey.QUERY_PERIOD -> { + // React to period change + val newPeriod = viewModel.getIntPreference(key, 60) + // Update UI or trigger actions + } + else -> { + // Handle other preferences + } + } + + // Clear the event after handling + viewModel.clearPreferenceChange() + } +} +``` + +## Benefits of Migration + +### 1. **Lifecycle Awareness** +- No manual registration/cleanup needed +- Survives configuration changes (rotation, etc.) +- Automatic cleanup when Activity/Fragment is destroyed + +### 2. **Testability** +```kotlin +class MainViewModelTest { + @Test + fun `updateData triggers loading state`() = runTest { + val viewModel = MainViewModel(repository, locationRepo, alertRepo) + + viewModel.updateData() + + viewModel.isLoading.test { + assertThat(awaitItem()).isTrue() + } + } +} +``` + +### 3. **State Preservation** +```kotlin +// Before: Lost on rotation +private var currentResult: ResultEvent? = null + +// After: Survives rotation automatically +viewModel.currentResult.value // Still available after rotation! +``` + +### 4. **Reactive UI Updates** +```kotlin +// Before: Imperative callbacks +dataHandler.requestUpdates { event -> + when (event) { + is ResultEvent -> updateUI(event) + } +} + +// After: Declarative Flow collection +viewModel.currentResult.collect { result -> + result?.let { updateUI(it) } +} +``` + +### 5. **Centralized State** +```kotlin +// All UI state in one place +viewModel.isLoading.value +viewModel.hasError.value +viewModel.currentResult.value +viewModel.clearDataRequested.value +``` + +## Common Patterns + +### Pattern 1: One-Shot Events +```kotlin +// In ViewModel +private val _showToast = Channel() +val showToast = _showToast.receiveAsFlow() + +fun triggerToast(message: String) { + viewModelScope.launch { + _showToast.send(message) + } +} + +// In Activity +lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.showToast.collect { message -> + Toast.makeText(this@Main, message, Toast.LENGTH_SHORT).show() + } + } +} +``` + +### Pattern 2: Combining Multiple Flows +```kotlin +// In ViewModel +val uiState: StateFlow = combine( + isLoading, + hasError, + currentResult +) { loading, error, result -> + UiState(loading, error, result) +}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UiState()) + +// In Activity +lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect { state -> + updateUI(state) + } + } +} +``` + +### Pattern 3: Conditional Collection +```kotlin +lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.dataEvents + .filterNotNull() + .filterIsInstance() + .filter { !it.failed } + .collect { result -> + updateUI(result) + } + } +} +``` + +## Migration Checklist + +- [ ] Add ViewModelFactory injection to Activity/Fragment +- [ ] Create ViewModel instance using `by viewModels { viewModelFactory }` +- [ ] Move handler injections to ViewModel (if not needed elsewhere) +- [ ] Replace callback consumers with Flow collectors in `observeViewModel()` +- [ ] Update all data operations to use ViewModel methods +- [ ] Remove manual registration/cleanup in onStart/onStop +- [ ] Test configuration changes (rotation) +- [ ] Write unit tests for ViewModel +- [ ] Update dependent classes (e.g., HistoryController) +- [ ] Test with existing functionality + +## Gradual Migration Strategy + +You can migrate gradually without breaking existing code: + +1. **Keep both approaches temporarily:** +```kotlin +class Main : FragmentActivity() { + // Old approach (keep temporarily) + @set:Inject + internal lateinit var dataHandler: MainDataHandler + + // New approach + @set:Inject + internal lateinit var viewModelFactory: ViewModelProvider.Factory + private val viewModel: MainViewModel by viewModels { viewModelFactory } + + // Use ViewModel for new features + // Use handlers for existing code that depends on them +} +``` + +2. **Migrate screen by screen:** + - Start with Settings (simplest) + - Then Map (medium complexity) + - Finally Main Activity (most complex) + +3. **Remove handler injections only when all dependencies are updated** + +This allows you to migrate incrementally while keeping the app functional! diff --git a/VIEWMODEL_MIGRATION_QUICK_REFERENCE.md b/VIEWMODEL_MIGRATION_QUICK_REFERENCE.md new file mode 100644 index 000000000..4a7f05b19 --- /dev/null +++ b/VIEWMODEL_MIGRATION_QUICK_REFERENCE.md @@ -0,0 +1,538 @@ +# ViewModel Migration Quick Reference + +## Quick Comparison Chart + +| **Aspect** | **Before (Handlers)** | **After (ViewModels)** | +|------------|----------------------|------------------------| +| **Dependency Injection** | `@set:Inject lateinit var dataHandler: MainDataHandler` | `@set:Inject lateinit var viewModelFactory: ViewModelProvider.Factory`
`private val viewModel: MainViewModel by viewModels { viewModelFactory }` | +| **Data Updates** | Callback-based: `dataHandler.requestUpdates(consumer)` | Flow-based: `viewModel.dataEvents.collect { }` | +| **Lifecycle Management** | Manual: `onStart()` → register, `onStop()` → unregister | Automatic: Lifecycle-aware collection | +| **Configuration Changes** | Data lost on rotation (unless saved) | Data preserved automatically | +| **State Access** | Direct: `dataHandler.parameters` | Reactive: `viewModel.getParameters()` | +| **Testing** | Hard to mock callbacks | Easy to test with coroutine test helpers | + +## Essential Code Snippets + +### 1. Activity Setup + +```kotlin +class Main : FragmentActivity() { + // Inject ViewModelFactory + @set:Inject + internal lateinit var viewModelFactory: ViewModelProvider.Factory + + // Create ViewModel + private val viewModel: MainViewModel by viewModels { viewModelFactory } + + override fun onCreate(savedInstanceState: Bundle?) { + AndroidInjection.inject(this) + super.onCreate(savedInstanceState) + + binding = MainBinding.inflate(layoutInflater) + setContentView(binding.root) + + observeViewModel() + viewModel.start() + } +} +``` + +### 2. Observing Data (The Core Pattern) + +```kotlin +private fun observeViewModel() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + // Loading state + launch { + viewModel.isLoading.collect { isLoading -> + if (isLoading) { + statusComponent.startProgress() + } else { + statusComponent.stopProgress() + } + } + } + + // Error state + launch { + viewModel.hasError.collect { hasError -> + statusComponent.indicateError(hasError) + } + } + + // Data results + launch { + viewModel.currentResult.collect { result -> + result?.let { updateStrikeOverlay(it) } + } + } + + // Location updates + launch { + viewModel.locationEvents.collect { event -> + event?.let { updateLocationOverlay(it.location) } + } + } + + // Alert events + launch { + viewModel.alertEvents.collect { event -> + event?.let { handleAlert(it) } + } + } + } + } +} +``` + +### 3. Fragment Setup + +```kotlin +class MapFragment : Fragment() { + @set:Inject + internal lateinit var viewModelFactory: ViewModelProvider.Factory + + private val viewModel: MapViewModel by viewModels { viewModelFactory } + + override fun onCreate(savedInstanceState: Bundle?) { + AndroidSupportInjection.inject(this) + super.onCreate(savedInstanceState) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + observeViewModel() + } + + private fun observeViewModel() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.zoomLevel.collect { zoom -> + mapView.controller.setZoom(zoom) + } + } + + launch { + viewModel.centerPosition.collect { position -> + position?.let { mapView.setExpectedCenter(it) } + } + } + } + } + } + + override fun onPause() { + super.onPause() + viewModel.saveMapState( + mapView.zoomLevelDouble, + mapView.mapCenter as GeoPoint + ) + } +} +``` + +## Common Operations Translation Table + +### Data Operations + +| **Before** | **After** | +|------------|-----------| +| `dataHandler.updateData()` | `viewModel.updateData()` | +| `dataHandler.goRealtime()` | `viewModel.goRealtime()` | +| `dataHandler.setPosition(pos)` | `viewModel.setPosition(pos)` | +| `dataHandler.start()` | `viewModel.start()` | +| `dataHandler.stop()` | `viewModel.stop()` | +| `dataHandler.restart()` | `viewModel.restart()` | +| `dataHandler.startAnimation()` | `viewModel.startAnimation()` | +| `dataHandler.toggleExtendedMode()` | `viewModel.toggleExtendedMode()` | +| `dataHandler.parameters` | `viewModel.getParameters()` | +| `dataHandler.intervalDuration` | `viewModel.getIntervalDuration()` | +| `dataHandler.isRealtime` | `viewModel.isRealtime()` | +| `dataHandler.historySteps()` | `viewModel.historySteps()` | + +### Location Operations + +| **Before** | **After** | +|------------|-----------| +| `locationHandler.location` | `viewModel.locationEvents.value?.location` or `locationRepository.getCurrentLocation()` | +| `locationHandler.enableBackgroundMode()` | `viewModel.enableBackgroundLocation()` | +| `locationHandler.disableBackgroundMode()` | `viewModel.disableBackgroundLocation()` | + +### Alert Operations + +| **Before** | **After** | +|------------|-----------| +| `alertHandler.alertParameters` | `viewModel.alertEvents.value` or via AlertRepository | +| `alertHandler.requestUpdates(consumer)` | `viewModel.alertEvents.collect { }` | + +## Callback → Flow Conversion Examples + +### Example 1: Simple Event Handling + +**Before:** +```kotlin +private val dataEventConsumer: (DataEvent) -> Unit = { event -> + when (event) { + is RequestStartedEvent -> handleRequestStarted() + is ResultEvent -> handleResult(event) + is StatusEvent -> handleStatus(event) + } +} + +override fun onStart() { + super.onStart() + dataHandler.requestUpdates(dataEventConsumer) +} + +override fun onStop() { + super.onStop() + dataHandler.removeUpdates(dataEventConsumer) +} +``` + +**After:** +```kotlin +private fun observeViewModel() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.dataEvents.collect { event -> + when (event) { + is RequestStartedEvent -> handleRequestStarted() + is ResultEvent -> handleResult(event) + is StatusEvent -> handleStatus(event) + null -> {} // Initial state + } + } + } + } +} + +override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + observeViewModel() +} +``` + +### Example 2: Multiple Parallel Observers + +**Before:** +```kotlin +private val dataConsumer: (DataEvent) -> Unit = { /* ... */ } +private val locationConsumer: (LocationEvent) -> Unit = { /* ... */ } +private val alertConsumer: (AlertEvent) -> Unit = { /* ... */ } + +override fun onStart() { + super.onStart() + dataHandler.requestUpdates(dataConsumer) + locationHandler.requestUpdates(locationConsumer) + alertHandler.requestUpdates(alertConsumer) +} + +override fun onStop() { + super.onStop() + dataHandler.removeUpdates(dataConsumer) + locationHandler.removeUpdates(locationConsumer) + alertHandler.removeUpdates(alertConsumer) +} +``` + +**After:** +```kotlin +private fun observeViewModel() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + // All run in parallel automatically! + launch { viewModel.dataEvents.collect { /* ... */ } } + launch { viewModel.locationEvents.collect { /* ... */ } } + launch { viewModel.alertEvents.collect { /* ... */ } } + } + } +} + +override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + observeViewModel() +} +// No onStop cleanup needed! +``` + +### Example 3: Filtering Events + +**Before:** +```kotlin +private val dataConsumer: (DataEvent) -> Unit = { event -> + if (event is ResultEvent && !event.failed) { + processSuccessfulResult(event) + } +} +``` + +**After:** +```kotlin +lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.dataEvents + .filterNotNull() + .filterIsInstance() + .filter { !it.failed } + .collect { result -> + processSuccessfulResult(result) + } + } +} +``` + +## UI State Patterns + +### Pattern 1: Show/Hide Progress + +**Before:** +```kotlin +private val dataConsumer: (DataEvent) -> Unit = { event -> + when (event) { + is RequestStartedEvent -> progressBar.visibility = View.VISIBLE + is ResultEvent -> progressBar.visibility = View.GONE + } +} +``` + +**After:** +```kotlin +lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.isLoading.collect { isLoading -> + progressBar.isVisible = isLoading + } + } +} +``` + +### Pattern 2: Update UI from Multiple States + +**Before:** +```kotlin +private var isLoading = false +private var hasError = false +private var data: ResultEvent? = null + +private fun updateUI() { + when { + isLoading -> showLoading() + hasError -> showError() + data != null -> showData(data!!) + else -> showEmpty() + } +} +``` + +**After:** +```kotlin +// In ViewModel +data class UiState( + val isLoading: Boolean = false, + val hasError: Boolean = false, + val data: ResultEvent? = null +) + +val uiState: StateFlow = combine( + isLoading, + hasError, + currentResult +) { loading, error, result -> + UiState(loading, error, result) +}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UiState()) + +// In Activity +lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect { state -> + when { + state.isLoading -> showLoading() + state.hasError -> showError() + state.data != null -> showData(state.data) + else -> showEmpty() + } + } + } +} +``` + +### Pattern 3: One-Time Events (like Toasts or Navigation) + +```kotlin +// In ViewModel +private val _navigationEvent = Channel() +val navigationEvent = _navigationEvent.receiveAsFlow() + +fun navigateToSettings() { + viewModelScope.launch { + _navigationEvent.send(NavigationDestination.Settings) + } +} + +// In Activity +lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.navigationEvent.collect { destination -> + when (destination) { + NavigationDestination.Settings -> { + startActivity(Intent(this@Main, SettingsActivity::class.java)) + } + } + } + } +} +``` + +## Testing Examples + +### Testing with ViewModels + +**Before (Testing with Handlers):** +```kotlin +@Test +fun `data update shows loading`() { + val handler = mock() + val consumer = slot<(DataEvent) -> Unit>() + + every { handler.requestUpdates(capture(consumer)) } returns Unit + + val activity = createActivity() + + // Trigger callback + consumer.captured(RequestStartedEvent()) + + // Assert UI state + assertThat(activity.isLoading).isTrue() +} +``` + +**After (Testing ViewModels):** +```kotlin +@Test +fun `data update shows loading`() = runTest { + val repository = mock() + every { repository.observeDataEvents() } returns flowOf(RequestStartedEvent()) + + val viewModel = MainViewModel(repository, locationRepo, alertRepo) + + viewModel.isLoading.test { + assertThat(awaitItem()).isTrue() + } +} +``` + +## Troubleshooting + +### Problem: StateFlow not updating + +**Solution:** Make sure you're collecting in the right lifecycle state: +```kotlin +// ✅ Correct - only collects when STARTED or RESUMED +repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.dataEvents.collect { } +} + +// ❌ Wrong - collects even when app is in background +lifecycleScope.launch { + viewModel.dataEvents.collect { } +} +``` + +### Problem: Multiple collections triggering + +**Solution:** Use `StateFlow` for state, `SharedFlow` or `Channel` for events: +```kotlin +// For state (UI always shows latest) +val isLoading: StateFlow + +// For one-time events (like navigation or toasts) +val showToast: SharedFlow +// or +val navigationEvent: Flow = channel.receiveAsFlow() +``` + +### Problem: Memory leaks + +**Solution:** Always use `viewLifecycleOwner` in Fragments: +```kotlin +// ✅ Correct in Fragment +viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { } +} + +// ❌ Wrong in Fragment - may leak +lifecycleScope.launch { } +``` + +## Required Imports + +Add these imports for ViewModel migration: + +```kotlin +// ViewModel +import androidx.activity.viewModels +import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModel + +// Lifecycle +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.viewLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle + +// Coroutines & Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.launch + +// Your ViewModels +import org.blitzortung.android.app.viewmodel.MainViewModel +import org.blitzortung.android.app.viewmodel.MapViewModel +import org.blitzortung.android.app.viewmodel.SettingsViewModel +``` + +## Migration Priority + +Suggested order for migrating screens: + +1. **SettingsViewModel** (Easiest) + - Minimal UI updates + - Simple preference management + - Good learning opportunity + +2. **MapViewModel** (Medium) + - State preservation + - Map listener integration + - Configuration change handling + +3. **MainViewModel** (Complex) + - Multiple data streams + - Complex UI updates + - Coordination between multiple repositories + +## Final Tips + +✅ **DO:** +- Migrate one screen at a time +- Test thoroughly after each migration +- Keep both approaches temporarily during transition +- Write tests for ViewModels +- Use `repeatOnLifecycle` for collections + +❌ **DON'T:** +- Try to migrate everything at once +- Forget to inject ViewModelFactory +- Collect flows without lifecycle awareness +- Remove handler injections before updating all dependencies +- Mix callbacks and Flow collections for the same data + +--- + +**Need help?** Check the full migration guide in `VIEWMODEL_MIGRATION_EXAMPLE.md` diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100644 index a91de1b6d..000000000 --- a/app/build.gradle +++ /dev/null @@ -1,137 +0,0 @@ -plugins { - id 'com.android.application' - id 'org.jetbrains.kotlin.android' - id 'kotlin-kapt' - id 'jacoco' -} - -android { - compileSdkVersion 35 - - defaultConfig { - applicationId "org.blitzortung.android.app" - minSdkVersion 21 - targetSdkVersion 35 - versionCode 345 - versionName '2.4.4' - multiDexEnabled false - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } - buildFeatures { - viewBinding true - buildConfig true - } - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - debug { - enableUnitTestCoverage true - enableAndroidTestCoverage true - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 - } - kotlin { - jvmToolchain(17) - compilerOptions { - apiVersion = KotlinVersion.KOTLIN_2_2 - } - } - testOptions { - unitTests { - returnDefaultValues = true - includeAndroidResources = true - all { - jvmArgs '-noverify' - } - } - } - - useLibrary 'android.test.base' - lint { - abortOnError false - checkReleaseBuilds false - } - testCoverage { - jacocoVersion '0.8.12' - } - namespace 'org.blitzortung.android.app' - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - } - buildToolsVersion '35.0.0' - -} - -def dagger_version = '2.57.1' - -dependencies { - implementation 'androidx.appcompat:appcompat:1.7.1' - implementation 'androidx.media:media:1.7.0' - implementation 'androidx.core:core-ktx:1.16.0' - implementation 'androidx.recyclerview:recyclerview:1.4.0' - implementation 'androidx.work:work-runtime-ktx:2.10.3' - implementation 'org.osmdroid:osmdroid-android:6.1.20' - implementation 'androidx.preference:preference-ktx:1.2.1' - implementation 'com.google.android.material:material:1.12.0' - - // Dagger2 - implementation "com.google.dagger:dagger:$dagger_version" - implementation "com.google.dagger:dagger-android:$dagger_version" - implementation "com.google.dagger:dagger-android-support:$dagger_version" - implementation 'androidx.test.ext:junit-ktx:1.3.0' - kapt "com.google.dagger:dagger-android-processor:$dagger_version" - kapt "com.google.dagger:dagger-compiler:$dagger_version" - compileOnly 'javax.annotation:jsr250-api:1.0' - - testImplementation 'junit:junit:4.13.2' - testImplementation 'org.assertj:assertj-core:3.27.4' - testImplementation 'io.mockk:mockk:1.14.5' - testImplementation 'org.robolectric:robolectric:4.15.1' - testImplementation 'androidx.test:core:1.7.0' - androidTestImplementation 'androidx.test:runner:1.7.0' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0' - androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.3.0' - androidTestImplementation('androidx.compose.ui:ui-test-junit4:1.9.0') -} - -kapt.includeCompileClasspath = false - - -import org.jetbrains.kotlin.gradle.dsl.KotlinVersion - -tasks.withType(Test) { - jacoco.includeNoLocationClasses = true - jacoco.excludes = ['jdk.internal.*'] -} - -task jacocoTestReport(type: JacocoReport, dependsOn: ['testDebugUnitTest']) { - reports { - xml.required.set(true) - html.required.set(true) - } - - // Add files that should not be listed in the report (e.g. generated Files from dagger) - def fileFilter = ['**/*Dagger.*'] - - def kotlinDebugTree = fileTree(dir: "${buildDir}/tmp/kotlin-classes/debug", excludes: fileFilter) - - def mainSrc = "$projectDir/src/main/java" - sourceDirectories.from = files([mainSrc]) - classDirectories.from = files([kotlinDebugTree]) - - // Make sure the path is correct (if not run the unit tests and try find the .exec file that is generated after the unit tests are finished should be similar to that one) - executionData.from = fileTree(dir: "$buildDir", includes: ["outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec"]) - -} - -sonar { - properties { - property "sonar.junit.reportPaths", "build/test-results/testDebugUnitTest/" - property "sonar.coverage.jacoco.xmlReportPaths", "build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml" - } -} diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 000000000..b3a90c240 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,194 @@ +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("kotlin-kapt") + id("jacoco") +} + +android { + compileSdk = 35 + + defaultConfig { + applicationId = "org.blitzortung.android.app" + minSdk = 21 + targetSdk = 35 + versionCode = 345 + versionName = "2.4.4" + multiDexEnabled = false + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { + viewBinding = true + buildConfig = true + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + debug { + enableUnitTestCoverage = true + enableAndroidTestCoverage = true + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlin { + jvmToolchain(17) + compilerOptions { + apiVersion.set(KotlinVersion.KOTLIN_2_2) + } + } + + testOptions { + unitTests { + isReturnDefaultValues = true + isIncludeAndroidResources = true + all { + it.jvmArgs("-noverify") + } + } + } + + @Suppress("UnstableApiUsage") + testCoverage { + jacocoVersion = "0.8.12" + } + + lint { + abortOnError = false + checkReleaseBuilds = false + } + + namespace = "org.blitzortung.android.app" + + buildToolsVersion = "35.0.0" +} + +val daggerVersion = "2.57.1" + +dependencies { + implementation("androidx.appcompat:appcompat:1.7.1") + implementation("androidx.media:media:1.7.0") + implementation("androidx.core:core-ktx:1.16.0") + implementation("androidx.recyclerview:recyclerview:1.4.0") + implementation("androidx.work:work-runtime-ktx:2.10.3") + implementation("org.osmdroid:osmdroid-android:6.1.20") + implementation("androidx.preference:preference-ktx:1.2.1") + implementation("com.google.android.material:material:1.12.0") + + // Lifecycle and ViewModel + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7") + implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.8.7") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("androidx.activity:activity-ktx:1.9.3") + implementation("androidx.fragment:fragment-ktx:1.8.5") + + // Dagger2 + implementation("com.google.dagger:dagger:$daggerVersion") + implementation("com.google.dagger:dagger-android:$daggerVersion") + implementation("com.google.dagger:dagger-android-support:$daggerVersion") + implementation("androidx.test.ext:junit-ktx:1.3.0") + kapt("com.google.dagger:dagger-android-processor:$daggerVersion") + kapt("com.google.dagger:dagger-compiler:$daggerVersion") + compileOnly("javax.annotation:jsr250-api:1.0") + + // Unit Testing + testImplementation("junit:junit:4.13.2") + testImplementation("org.assertj:assertj-core:3.27.4") + testImplementation("io.mockk:mockk:1.14.5") + testImplementation("org.robolectric:robolectric:4.15.1") + testImplementation("androidx.test:core:1.7.0") + testImplementation("androidx.test:core-ktx:1.7.0") + testImplementation("androidx.test.ext:junit:1.2.1") + testImplementation("androidx.test.ext:junit-ktx:1.2.1") + + // Kotlin Coroutines Testing + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") + + // Turbine - Flow Testing + testImplementation("app.cash.turbine:turbine:1.2.0") + + // AndroidX Arch Core Testing (LiveData/ViewModel testing) + testImplementation("androidx.arch.core:core-testing:2.2.0") + + // AndroidX Test Rules + testImplementation("androidx.test:rules:1.6.1") + + // Fragment Testing + debugImplementation("androidx.fragment:fragment-testing:1.8.5") + + // Instrumented Testing + androidTestImplementation("androidx.test:runner:1.7.0") + androidTestImplementation("androidx.test:rules:1.6.1") + androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0") + androidTestImplementation("androidx.test.espresso:espresso-intents:3.7.0") + androidTestImplementation("androidx.test.espresso:espresso-contrib:3.7.0") + androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test.ext:junit-ktx:1.2.1") + androidTestImplementation("io.mockk:mockk-android:1.14.5") + androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") + + // Compose Testing (if needed in future) + androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.9.0") + debugImplementation("androidx.compose.ui:ui-test-manifest:1.9.0") +} + +kapt { + includeCompileClasspath = false +} + +tasks.withType { + configure { + isIncludeNoLocationClasses = true + excludes = listOf("jdk.internal.*") + } +} + +tasks.register("jacocoTestReport") { + dependsOn("testDebugUnitTest") + + reports { + xml.required.set(true) + html.required.set(true) + } + + // Add files that should not be listed in the report (e.g. generated Files from dagger) + val fileFilter = listOf("**/*Dagger.*") + + val kotlinDebugTree = + fileTree("${layout.buildDirectory.get()}/tmp/kotlin-classes/debug") { + exclude(fileFilter) + } + + val mainSrc = "$projectDir/src/main/java" + sourceDirectories.setFrom(files(mainSrc)) + classDirectories.setFrom(files(kotlinDebugTree)) + + // Make sure the path is correct (if not run the unit tests and try find the .exec file that is generated after the unit tests are finished should be similar to that one) + executionData.setFrom( + fileTree(layout.buildDirectory.get()) { + include("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec") + }, + ) +} + +sonar { + properties { + property("sonar.junit.reportPaths", "build/test-results/testDebugUnitTest/") + property("sonar.coverage.jacoco.xmlReportPaths", "build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml") + } +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index f1b424510..eb19b6d1f 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,21 +1,187 @@ -# 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 +# Blitzortung Lightning Monitor - ProGuard Rules +# This file contains R8/ProGuard rules to prevent code shrinking issues + +# ===== Debugging Support ===== +# Keep line numbers for better crash reports +-keepattributes SourceFile,LineNumberTable +-renamesourcefileattribute SourceFile + +# Keep annotations for reflection +-keepattributes *Annotation* + +# ===== Data Classes (JSON Parsing) ===== +# These classes are serialized/deserialized from JSON via org.json APIs +# Must keep all fields and their names for JSON parsing to work +-keep class org.blitzortung.android.data.beans.** { *; } +-keepclassmembers class org.blitzortung.android.data.beans.** { *; } + +# Keep Serializable classes +-keepclassmembers class * implements java.io.Serializable { + static final long serialVersionUID; + private static final java.io.ObjectStreamField[] serialPersistentFields; + private void writeObject(java.io.ObjectOutputStream); + private void readObject(java.io.ObjectInputStream); + java.lang.Object writeReplace(); + java.lang.Object readResolve(); +} + +# ===== Dagger 2 Dependency Injection ===== +# Keep all Dagger generated classes +-keep class **Dagger*.* { *; } +-keep class **_Factory { *; } +-keep class **_MembersInjector { *; } +-keep class * extends dagger.internal.Binding +-keep class * extends dagger.internal.ModuleAdapter + +# Keep Dagger annotations +-keepclassmembers,allowobfuscation class * { + @javax.inject.* *; + @dagger.* *; +} + +# Keep Dagger modules and components +-keep @dagger.Module class * { *; } +-keep @dagger.Component class * { *; } +-keep @dagger.Subcomponent class * { *; } + +# ===== AndroidX and Android Components ===== +# Keep AndroidX classes +-keep class androidx.** { *; } +-keep interface androidx.** { *; } +-dontwarn androidx.** + +# Keep native methods (JNI) +-keepclasseswithmembernames class * { + native ; +} + +# Keep view constructors for XML inflation +-keepclasseswithmembers class * { + public (android.content.Context, android.util.AttributeSet); +} +-keepclasseswithmembers class * { + public (android.content.Context, android.util.AttributeSet, int); +} + +# Keep custom views +-keep public class * extends android.view.View { + public (android.content.Context); + public (android.content.Context, android.util.AttributeSet); + public (android.content.Context, android.util.AttributeSet, int); + public void set*(***); + *** get*(); +} + +# ===== OSMDroid Maps Library ===== +# Keep OSMDroid classes for map functionality +-keep class org.osmdroid.** { *; } +-dontwarn org.osmdroid.** + +# Keep map overlay classes +-keep class * extends org.osmdroid.views.overlay.Overlay { *; } + +# ===== Enum Support ===== +# Keep enum methods that are accessed via reflection +-keepclassmembers enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); + **[] $VALUES; + public *; +} + +# ===== Work Manager ===== +# Keep Worker classes +-keep class * extends androidx.work.Worker +-keep class * extends androidx.work.ListenableWorker { + public (android.content.Context,androidx.work.WorkerParameters); +} + +# ===== Services and Receivers ===== +# Keep Service and BroadcastReceiver classes +-keep public class * extends android.app.Service +-keep public class * extends android.content.BroadcastReceiver + +# Keep AppService and BootReceiver +-keep class org.blitzortung.android.app.AppService { *; } +-keep class org.blitzortung.android.app.BootReceiver { *; } + +# ===== Preferences ===== +# Keep SharedPreferences related classes +-keep class * extends android.preference.Preference { + public (android.content.Context, android.util.AttributeSet); +} +-keep class * extends androidx.preference.Preference { + public (android.content.Context, android.util.AttributeSet); +} + +# ===== Fragment Support ===== +-keep class * extends androidx.fragment.app.Fragment { *; } +-keep class * extends android.app.Fragment { *; } + +# ===== Event/Protocol Classes ===== +# Keep event classes that are passed around via ConsumerContainer +-keep class org.blitzortung.android.protocol.Event { *; } +-keep class * implements org.blitzortung.android.protocol.Event { *; } +-keep class org.blitzortung.android.data.provider.result.** { *; } +-keep class org.blitzortung.android.alert.event.** { *; } +-keep class org.blitzortung.android.location.LocationEvent { *; } + +# ===== Location Providers ===== +# Keep location provider classes +-keep class org.blitzortung.android.location.provider.** { *; } + +# ===== Alert Classes ===== +# Keep alert-related classes that use reflection or serialization +-keep class org.blitzortung.android.alert.AlertResult { *; } +-keep class org.blitzortung.android.alert.AlertParameters { *; } +-keep class org.blitzortung.android.alert.data.** { *; } + +# ===== Widget Provider ===== +# Keep widget provider for home screen widgets +-keep class org.blitzortung.android.app.WidgetProvider { *; } + +# ===== Kotlin Specific ===== +# Keep Kotlin metadata for reflection +-keep class kotlin.Metadata { *; } + +# Keep Kotlin coroutines +-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} +-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} +-keepclassmembers class kotlinx.coroutines.** { + volatile ; +} + +# ===== Remove Logging in Release ===== +# Strip out logging calls for smaller APK (optional - comment out if you want logs) +-assumenosideeffects class android.util.Log { + public static *** d(...); + public static *** v(...); + public static *** i(...); +} + +# ===== General Android Rules ===== +# Keep Activity onCreate methods +-keepclassmembers class * extends android.app.Activity { + public void *(android.view.View); +} + +# Keep R class and fields +-keepclassmembers class **.R$* { + public static ; +} + +# Keep application class +-keep class org.blitzortung.android.app.BOApplication { *; } + +# ===== Warnings to Ignore ===== +# Suppress warnings for missing classes from optional dependencies +-dontwarn javax.annotation.** +-dontwarn org.jetbrains.annotations.** + +# ===== Optimization ===== +# Allow R8 to optimize (already enabled with proguard-android-optimize.txt) +-optimizationpasses 5 +-allowaccessmodification + +# Don't obfuscate for easier debugging (comment out for more obfuscation) +# -dontobfuscate diff --git a/app/src/androidTest/java/org/blitzortung/android/app/MainTest.kt b/app/src/androidTest/java/org/blitzortung/android/app/MainTest.kt index 6a65023e4..ee35c497f 100644 --- a/app/src/androidTest/java/org/blitzortung/android/app/MainTest.kt +++ b/app/src/androidTest/java/org/blitzortung/android/app/MainTest.kt @@ -12,17 +12,16 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.Until +import java.util.regex.Pattern import org.junit.Assert import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import java.util.regex.Pattern @RunWith(AndroidJUnit4::class) @LargeTest class MainTest { - @get:Rule val activityRule = ActivityScenarioRule(Main::class.java) @@ -51,47 +50,50 @@ class MainTest { onView(withId(R.id.menu)).perform(click()) - uiDevice.wait( Until.findObject(By.text("Preferences")), - timeout + timeout, )?.click() uiDevice.wait( - Until.hasObject(By.res(InstrumentationRegistry.getInstrumentation().targetContext.packageName, "activity_settings")), - timeout) + Until.hasObject( + By.res(InstrumentationRegistry.getInstrumentation().targetContext.packageName, "activity_settings"), + ), + timeout, + ) // Check if the settings fragment is displayed onView(withId(R.id.activity_settings)).check(matches(isDisplayed())) } - private fun waitForAndAcceptQuickSettingsDialog() { uiDevice.wait( Until.findObject(By.text("OK")), - timeout + timeout, )?.click() } private fun waitForAndAcceptLocationPermissionDialog() { // Define the text pattern for the "allow" button on the location permission dialog. // This covers "While using the app" and "Allow only while using the app". - val allowButtonTextPattern = Pattern.compile( - "while using the app|allow only while using the app", - Pattern.CASE_INSENSITIVE - ) + val allowButtonTextPattern = + Pattern.compile( + "while using the app|allow only while using the app", + Pattern.CASE_INSENSITIVE, + ) // Wait for the permission dialog's "allow" button to appear and find it. - val allowButton = uiDevice.wait( - Until.findObject(By.text(allowButtonTextPattern)), - timeout - ) + val allowButton = + uiDevice.wait( + Until.findObject(By.text(allowButtonTextPattern)), + timeout, + ) // Verify that the button was found Assert.assertNotNull( "Location permission dialog with 'Allow' button not found within ${timeout}ms. " + - "Permissions might have been already granted or the dialog has different text.", - allowButton + "Permissions might have been already granted or the dialog has different text.", + allowButton, ) // Click the "allow" button diff --git a/app/src/main/java/org/blitzortung/android/alert/AlertLabelHandler.kt b/app/src/main/java/org/blitzortung/android/alert/AlertLabelHandler.kt index bef5b3961..5108a384b 100644 --- a/app/src/main/java/org/blitzortung/android/alert/AlertLabelHandler.kt +++ b/app/src/main/java/org/blitzortung/android/alert/AlertLabelHandler.kt @@ -19,12 +19,11 @@ package org.blitzortung.android.alert import android.content.res.Resources - import org.blitzortung.android.app.R class AlertLabelHandler( private val alertLabel: AlertLabel, - private val resources: Resources + private val resources: Resources, ) { fun apply(result: AlertResult?) { var warningText = "" @@ -32,11 +31,12 @@ class AlertLabelHandler( var textColorResource = R.color.Green if (result != null && result.closestStrikeDistance < Float.POSITIVE_INFINITY) { - textColorResource = when (result.closestStrikeDistance) { - in 0.0..20.0 -> R.color.RedWarn - in 20.0..50.0 -> R.color.Yellow - else -> R.color.Green - } + textColorResource = + when (result.closestStrikeDistance) { + in 0.0..20.0 -> R.color.RedWarn + in 20.0..50.0 -> R.color.Yellow + else -> R.color.Green + } val distanceUnit = resources.getString(result.parameters.measurementSystem.unitNameString) warningText = "%.0f$distanceUnit".format(result.closestStrikeDistance) if (result.closestStrikeDistance > 0.1) { diff --git a/app/src/main/java/org/blitzortung/android/alert/AlertParameters.kt b/app/src/main/java/org/blitzortung/android/alert/AlertParameters.kt index f23ef8f2d..4e043dcba 100644 --- a/app/src/main/java/org/blitzortung/android/alert/AlertParameters.kt +++ b/app/src/main/java/org/blitzortung/android/alert/AlertParameters.kt @@ -24,6 +24,5 @@ data class AlertParameters( val alarmInterval: Long, val rangeSteps: List, val sectorLabels: List, - val measurementSystem: MeasurementSystem + val measurementSystem: MeasurementSystem, ) - diff --git a/app/src/main/java/org/blitzortung/android/alert/AlertRepository.kt b/app/src/main/java/org/blitzortung/android/alert/AlertRepository.kt new file mode 100644 index 000000000..84b86f4dd --- /dev/null +++ b/app/src/main/java/org/blitzortung/android/alert/AlertRepository.kt @@ -0,0 +1,59 @@ +/* + + Copyright 2015 Andreas Würl + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ + +package org.blitzortung.android.alert + +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import org.blitzortung.android.alert.event.AlertEvent +import org.blitzortung.android.alert.handler.AlertHandler + +/** + * Repository layer that wraps AlertHandler and provides Flow-based API + * for reactive alert access in ViewModels. + */ +@Singleton +class AlertRepository + @Inject + constructor( + private val alertHandler: AlertHandler, + ) { + /** + * Observe alert events as a Flow + */ + fun observeAlertEvents(): Flow = + callbackFlow { + val consumer: (AlertEvent) -> Unit = { event -> + trySend(event) + } + + alertHandler.requestUpdates(consumer) + + awaitClose { + alertHandler.removeUpdates(consumer) + } + } + + /** + * Get current alert parameters + */ + fun getAlertParameters(): AlertParameters = alertHandler.alertParameters + } diff --git a/app/src/main/java/org/blitzortung/android/alert/AlertResult.kt b/app/src/main/java/org/blitzortung/android/alert/AlertResult.kt index a15db0e45..909984afe 100644 --- a/app/src/main/java/org/blitzortung/android/alert/AlertResult.kt +++ b/app/src/main/java/org/blitzortung/android/alert/AlertResult.kt @@ -23,7 +23,7 @@ import org.blitzortung.android.alert.data.AlertSector data class AlertResult( val sectors: List, val parameters: AlertParameters, - val referenceTime: Long + val referenceTime: Long, ) { val sectorsByDistance: Map by lazy { sectors.filter { it.closestStrikeDistance < Float.POSITIVE_INFINITY } diff --git a/app/src/main/java/org/blitzortung/android/alert/data/AlertSector.kt b/app/src/main/java/org/blitzortung/android/alert/data/AlertSector.kt index e69fbb0e3..859ce0f0d 100644 --- a/app/src/main/java/org/blitzortung/android/alert/data/AlertSector.kt +++ b/app/src/main/java/org/blitzortung/android/alert/data/AlertSector.kt @@ -23,5 +23,5 @@ data class AlertSector( val minimumSectorBearing: Float, val maximumSectorBearing: Float, val ranges: List, - val closestStrikeDistance: Float -) \ No newline at end of file + val closestStrikeDistance: Float, +) diff --git a/app/src/main/java/org/blitzortung/android/alert/data/AlertSectorRange.kt b/app/src/main/java/org/blitzortung/android/alert/data/AlertSectorRange.kt index 6e55b9c2e..2abe63094 100644 --- a/app/src/main/java/org/blitzortung/android/alert/data/AlertSectorRange.kt +++ b/app/src/main/java/org/blitzortung/android/alert/data/AlertSectorRange.kt @@ -22,5 +22,5 @@ data class AlertSectorRange( val rangeMinimum: Float, val rangeMaximum: Float, val strikeCount: Int, - val latestStrikeTimestamp: Long + val latestStrikeTimestamp: Long, ) diff --git a/app/src/main/java/org/blitzortung/android/alert/event/AlertResultEvent.kt b/app/src/main/java/org/blitzortung/android/alert/event/AlertResultEvent.kt index 2f4d88a24..cd02a04dd 100644 --- a/app/src/main/java/org/blitzortung/android/alert/event/AlertResultEvent.kt +++ b/app/src/main/java/org/blitzortung/android/alert/event/AlertResultEvent.kt @@ -21,5 +21,5 @@ package org.blitzortung.android.alert.event import org.blitzortung.android.alert.AlertResult data class AlertResultEvent( - val alertResult: AlertResult? + val alertResult: AlertResult?, ) : AlertEvent diff --git a/app/src/main/java/org/blitzortung/android/alert/handler/AggregatingAlertDataMapper.kt b/app/src/main/java/org/blitzortung/android/alert/handler/AggregatingAlertDataMapper.kt index 44c5081fa..e188c8d5c 100644 --- a/app/src/main/java/org/blitzortung/android/alert/handler/AggregatingAlertDataMapper.kt +++ b/app/src/main/java/org/blitzortung/android/alert/handler/AggregatingAlertDataMapper.kt @@ -1,30 +1,32 @@ package org.blitzortung.android.alert.handler +import javax.inject.Inject import org.blitzortung.android.alert.data.AlertSector import org.blitzortung.android.alert.data.AlertSectorRange -import javax.inject.Inject -internal class AggregatingAlertDataMapper @Inject constructor() { - fun mapSector(aggregatingAlertSector: AggregatingAlertSector): AlertSector { - with(aggregatingAlertSector) { - return AlertSector( - label = label, - minimumSectorBearing = minimumSectorBearing, - maximumSectorBearing = maximumSectorBearing, - closestStrikeDistance = closestStrikeDistance, - ranges = ranges.map { mapSectorRange(it) } - ) +internal class AggregatingAlertDataMapper + @Inject + constructor() { + fun mapSector(aggregatingAlertSector: AggregatingAlertSector): AlertSector { + with(aggregatingAlertSector) { + return AlertSector( + label = label, + minimumSectorBearing = minimumSectorBearing, + maximumSectorBearing = maximumSectorBearing, + closestStrikeDistance = closestStrikeDistance, + ranges = ranges.map { mapSectorRange(it) }, + ) + } } - } - private fun mapSectorRange(aggregatingAlertSectorRange: AggregatingAlertSectorRange): AlertSectorRange { - with(aggregatingAlertSectorRange) { - return AlertSectorRange( - rangeMinimum = rangeMinimum, - rangeMaximum = rangeMaximum, - latestStrikeTimestamp = latestStrikeTimestamp, - strikeCount = strikeCount - ) + private fun mapSectorRange(aggregatingAlertSectorRange: AggregatingAlertSectorRange): AlertSectorRange { + with(aggregatingAlertSectorRange) { + return AlertSectorRange( + rangeMinimum = rangeMinimum, + rangeMaximum = rangeMaximum, + latestStrikeTimestamp = latestStrikeTimestamp, + strikeCount = strikeCount, + ) + } } } -} \ No newline at end of file diff --git a/app/src/main/java/org/blitzortung/android/alert/handler/AggregatingAlertSector.kt b/app/src/main/java/org/blitzortung/android/alert/handler/AggregatingAlertSector.kt index 953ee83f4..5202542c8 100644 --- a/app/src/main/java/org/blitzortung/android/alert/handler/AggregatingAlertSector.kt +++ b/app/src/main/java/org/blitzortung/android/alert/handler/AggregatingAlertSector.kt @@ -24,9 +24,8 @@ internal class AggregatingAlertSector( val label: String, val minimumSectorBearing: Float, val maximumSectorBearing: Float, - val ranges: List + val ranges: List, ) { - var closestStrikeDistance: Float = Float.POSITIVE_INFINITY private set diff --git a/app/src/main/java/org/blitzortung/android/alert/handler/AggregatingAlertSectorRange.kt b/app/src/main/java/org/blitzortung/android/alert/handler/AggregatingAlertSectorRange.kt index e75670038..794867caf 100644 --- a/app/src/main/java/org/blitzortung/android/alert/handler/AggregatingAlertSectorRange.kt +++ b/app/src/main/java/org/blitzortung/android/alert/handler/AggregatingAlertSectorRange.kt @@ -22,9 +22,8 @@ import org.blitzortung.android.data.beans.Strike internal class AggregatingAlertSectorRange( val rangeMinimum: Float, - val rangeMaximum: Float + val rangeMaximum: Float, ) { - var strikeCount: Int = 0 private set @@ -37,6 +36,4 @@ internal class AggregatingAlertSectorRange( } strikeCount += strike.multiplicity } - - } diff --git a/app/src/main/java/org/blitzortung/android/alert/handler/AlertDataHandler.kt b/app/src/main/java/org/blitzortung/android/alert/handler/AlertDataHandler.kt index 93b84f59a..8e1c4ec74 100644 --- a/app/src/main/java/org/blitzortung/android/alert/handler/AlertDataHandler.kt +++ b/app/src/main/java/org/blitzortung/android/alert/handler/AlertDataHandler.kt @@ -21,6 +21,8 @@ package org.blitzortung.android.alert.handler import android.content.res.Resources import android.location.Location import android.util.Log +import javax.inject.Inject +import kotlin.math.max import org.blitzortung.android.alert.AlertParameters import org.blitzortung.android.alert.AlertResult import org.blitzortung.android.alert.data.AlertSector @@ -29,187 +31,210 @@ import org.blitzortung.android.data.beans.GridElement import org.blitzortung.android.data.beans.GridParameters import org.blitzortung.android.data.beans.Strike import org.blitzortung.android.util.MeasurementSystem -import javax.inject.Inject -import kotlin.math.max -open class AlertDataHandler @Inject internal constructor( - private val aggregatingAlertDataMapper: AggregatingAlertDataMapper -) { - - private val strikeLocation: Location = Location("") - - fun checkStrikes( - strikes: Strikes, location: Location, parameters: AlertParameters, - referenceTime: Long = System.currentTimeMillis() - ): AlertResult? { - val gridParameters: GridParameters? = strikes.gridParameters - if (gridParameters != null && !gridParameters.isGlobal && !gridParameters.contains( - location.longitude, - location.latitude, - 0.2 - ) - ) { - Log.v( - LOG_TAG, - "Location $location is not in grid ${gridParameters.longitudeInterval} + ${gridParameters.latitudeInterval}" - ) - return null - } +open class AlertDataHandler + @Inject + internal constructor( + private val aggregatingAlertDataMapper: AggregatingAlertDataMapper, + ) { + private val strikeLocation: Location = Location("") + + fun checkStrikes( + strikes: Strikes, + location: Location, + parameters: AlertParameters, + referenceTime: Long = System.currentTimeMillis(), + ): AlertResult? { + val gridParameters: GridParameters? = strikes.gridParameters + if (gridParameters != null && !gridParameters.isGlobal && + !gridParameters.contains( + location.longitude, + location.latitude, + 0.2, + ) + ) { + Log.v( + LOG_TAG, + "Location $location is not in grid ${gridParameters.longitudeInterval} + ${gridParameters.latitudeInterval}", + ) + return null + } - val sectors = createSectors(parameters) + val sectors = createSectors(parameters) - val thresholdTime = referenceTime - parameters.alarmInterval + val thresholdTime = referenceTime - parameters.alarmInterval - val strikeLocation = Location("") + val strikeLocation = Location("") - strikes.strikes.forEach { strike -> - val bearingToStrike = calculateBearingToStrike(location, strikeLocation, strike) + strikes.strikes.forEach { strike -> + val bearingToStrike = calculateBearingToStrike(location, strikeLocation, strike) - val alertSector = getRelevantSector(bearingToStrike.toDouble(), sectors) - alertSector?.let { - checkStrike( - alertSector, - strike, - gridParameters, - parameters.measurementSystem, - location, - thresholdTime - ) + val alertSector = getRelevantSector(bearingToStrike.toDouble(), sectors) + alertSector?.let { + checkStrike( + alertSector, + strike, + gridParameters, + parameters.measurementSystem, + location, + thresholdTime, + ) + } } - } - return AlertResult(sectors.map { aggregatingAlertDataMapper.mapSector(it) }, parameters, referenceTime) - } + return AlertResult(sectors.map { aggregatingAlertDataMapper.mapSector(it) }, parameters, referenceTime) + } - private fun checkStrike( - sector: AggregatingAlertSector, - strike: Strike, - gridParameters: GridParameters?, - measurementSystem: MeasurementSystem, - location: Location, - thresholdTime: Long - ) { - val distance = calculateDistanceTo(location, strike, gridParameters, measurementSystem) + private fun checkStrike( + sector: AggregatingAlertSector, + strike: Strike, + gridParameters: GridParameters?, + measurementSystem: MeasurementSystem, + location: Location, + thresholdTime: Long, + ) { + val distance = calculateDistanceTo(location, strike, gridParameters, measurementSystem) - sector.ranges.find { distance <= it.rangeMaximum }?.let { - it.addStrike(strike) - if (strike.timestamp >= thresholdTime) { - sector.updateClosestStrikeDistance(distance) + sector.ranges.find { distance <= it.rangeMaximum }?.let { + it.addStrike(strike) + if (strike.timestamp >= thresholdTime) { + sector.updateClosestStrikeDistance(distance) + } } } - } - private fun calculateDistanceTo( - location: Location, - strike: Strike, - gridParameters: GridParameters?, - measurementSystem: MeasurementSystem - ): Float { - if (strike is GridElement && gridParameters != null) { - strikeLocation.longitude = - closestValue(location.longitude, strike.longitude, gridParameters.longitudeDelta) - strikeLocation.latitude = closestValue(location.latitude, strike.latitude, gridParameters.latitudeDelta) - } else { - strikeLocation.longitude = strike.longitude - strikeLocation.latitude = strike.latitude + private fun calculateDistanceTo( + location: Location, + strike: Strike, + gridParameters: GridParameters?, + measurementSystem: MeasurementSystem, + ): Float { + if (strike is GridElement && gridParameters != null) { + strikeLocation.longitude = + closestValue(location.longitude, strike.longitude, gridParameters.longitudeDelta) + strikeLocation.latitude = closestValue(location.latitude, strike.latitude, gridParameters.latitudeDelta) + } else { + strikeLocation.longitude = strike.longitude + strikeLocation.latitude = strike.latitude + } + val distanceInMeters = location.distanceTo(strikeLocation) + return measurementSystem.calculateDistance(distanceInMeters) } - val distanceInMeters = location.distanceTo(strikeLocation) - return measurementSystem.calculateDistance(distanceInMeters) - } - private fun closestValue(location: Double, target: Double, gridSize: Double): Double { - val delta = gridSize / 2.0 - return if (location in target - delta..target + delta) { - location - } else { - if (location < target) { - target - delta + private fun closestValue( + location: Double, + target: Double, + gridSize: Double, + ): Double { + val delta = gridSize / 2.0 + return if (location in target - delta..target + delta) { + location } else { - target + delta + if (location < target) { + target - delta + } else { + target + delta + } } } - } - - fun getLatestTimstampWithin(distanceLimit: Float, alertResult: AlertResult): Long { - return alertResult.sectors.fold(0L) { latestTimestamp, sector -> - max(latestTimestamp, getLatestTimestampWithin(distanceLimit, sector)) + fun getLatestTimstampWithin( + distanceLimit: Float, + alertResult: AlertResult, + ): Long { + return alertResult.sectors.fold(0L) { latestTimestamp, sector -> + max(latestTimestamp, getLatestTimestampWithin(distanceLimit, sector)) + } } - } - fun getTextMessage(alertResult: AlertResult, notificationDistanceLimit: Float, resources: Resources): String { - return alertResult.sectorsByDistance - .filter { it.key <= notificationDistanceLimit } - .map { - "%s %.0f%s".format( - it.value.label, - it.key, - resources.getString(alertResult.parameters.measurementSystem.unitNameString) - ) - }.joinToString() - } + fun getTextMessage( + alertResult: AlertResult, + notificationDistanceLimit: Float, + resources: Resources, + ): String { + return alertResult.sectorsByDistance + .filter { it.key <= notificationDistanceLimit } + .map { + "%s %.0f%s".format( + it.value.label, + it.key, + resources.getString(alertResult.parameters.measurementSystem.unitNameString), + ) + }.joinToString() + } - private fun getLatestTimestampWithin(distanceLimit: Float, sector: AlertSector): Long { - return sector.ranges - .filter { distanceLimit <= it.rangeMaximum }.maxOfOrNull { it.latestStrikeTimestamp } ?: 0L - } + private fun getLatestTimestampWithin( + distanceLimit: Float, + sector: AlertSector, + ): Long { + return sector.ranges + .filter { distanceLimit <= it.rangeMaximum }.maxOfOrNull { it.latestStrikeTimestamp } ?: 0L + } - private fun createSectors(alertParameters: AlertParameters): List { - val sectorLabels = alertParameters.sectorLabels - val sectorWidth = 360f / sectorLabels.size - - var bearing = -180f - return sectorLabels.map { sectorLabel -> - var minimumSectorBearing = bearing - sectorWidth / 2.0f - minimumSectorBearing += (if (minimumSectorBearing < -180f) 360f else 0f) - val maximumSectorBearing = bearing + sectorWidth / 2.0f - val alertSector = AggregatingAlertSector( - sectorLabel, - minimumSectorBearing, - maximumSectorBearing, - createRanges(alertParameters) - ) - bearing += sectorWidth - alertSector + private fun createSectors(alertParameters: AlertParameters): List { + val sectorLabels = alertParameters.sectorLabels + val sectorWidth = 360f / sectorLabels.size + + var bearing = -180f + return sectorLabels.map { sectorLabel -> + var minimumSectorBearing = bearing - sectorWidth / 2.0f + minimumSectorBearing += (if (minimumSectorBearing < -180f) 360f else 0f) + val maximumSectorBearing = bearing + sectorWidth / 2.0f + val alertSector = + AggregatingAlertSector( + sectorLabel, + minimumSectorBearing, + maximumSectorBearing, + createRanges(alertParameters), + ) + bearing += sectorWidth + alertSector + } } - } - private fun createRanges(alertParameters: AlertParameters): List { - val rangeSteps = alertParameters.rangeSteps + private fun createRanges(alertParameters: AlertParameters): List { + val rangeSteps = alertParameters.rangeSteps - var rangeMinimum = 0.0f - return rangeSteps.map { rangeMaximum -> - val alertSectorRange = AggregatingAlertSectorRange(rangeMinimum, rangeMaximum) - rangeMinimum = rangeMaximum - alertSectorRange + var rangeMinimum = 0.0f + return rangeSteps.map { rangeMaximum -> + val alertSectorRange = AggregatingAlertSectorRange(rangeMinimum, rangeMaximum) + rangeMinimum = rangeMaximum + alertSectorRange + } } - } - private fun calculateBearingToStrike(location: Location, strikeLocation: Location, strike: Strike): Float { - strikeLocation.longitude = strike.longitude - strikeLocation.latitude = strike.latitude - return location.bearingTo(strikeLocation) - } + private fun calculateBearingToStrike( + location: Location, + strikeLocation: Location, + strike: Strike, + ): Float { + strikeLocation.longitude = strike.longitude + strikeLocation.latitude = strike.latitude + return location.bearingTo(strikeLocation) + } - private fun getRelevantSector( - bearing: Double, - sectors: Collection - ): AggregatingAlertSector? { - return sectors.firstOrNull { sectorContainsBearing(it, bearing) } - } + private fun getRelevantSector( + bearing: Double, + sectors: Collection, + ): AggregatingAlertSector? { + return sectors.firstOrNull { sectorContainsBearing(it, bearing) } + } - private fun sectorContainsBearing(sector: AggregatingAlertSector, bearing: Double): Boolean { - val minimumSectorBearing = sector.minimumSectorBearing - val maximumSectorBearing = sector.maximumSectorBearing + private fun sectorContainsBearing( + sector: AggregatingAlertSector, + bearing: Double, + ): Boolean { + val minimumSectorBearing = sector.minimumSectorBearing + val maximumSectorBearing = sector.maximumSectorBearing - return when { - maximumSectorBearing > minimumSectorBearing -> - bearing < maximumSectorBearing && bearing >= minimumSectorBearing + return when { + maximumSectorBearing > minimumSectorBearing -> + bearing < maximumSectorBearing && bearing >= minimumSectorBearing - maximumSectorBearing < minimumSectorBearing -> - bearing >= minimumSectorBearing || bearing < maximumSectorBearing + maximumSectorBearing < minimumSectorBearing -> + bearing >= minimumSectorBearing || bearing < maximumSectorBearing - else -> true + else -> true + } } } -} \ No newline at end of file diff --git a/app/src/main/java/org/blitzortung/android/alert/handler/AlertHandler.kt b/app/src/main/java/org/blitzortung/android/alert/handler/AlertHandler.kt index b12dfe218..5b0ff629c 100644 --- a/app/src/main/java/org/blitzortung/android/alert/handler/AlertHandler.kt +++ b/app/src/main/java/org/blitzortung/android/alert/handler/AlertHandler.kt @@ -22,6 +22,8 @@ import android.content.Context import android.content.SharedPreferences import android.location.Location import android.util.Log +import javax.inject.Inject +import javax.inject.Singleton import org.blitzortung.android.alert.AlertParameters import org.blitzortung.android.alert.AlertResult import org.blitzortung.android.alert.event.AlertCancelEvent @@ -41,226 +43,239 @@ import org.blitzortung.android.location.LocationHandler import org.blitzortung.android.protocol.ConsumerContainer import org.blitzortung.android.protocol.Event import org.blitzortung.android.util.MeasurementSystem -import javax.inject.Inject -import javax.inject.Singleton - @Singleton -class AlertHandler @Inject constructor( - private val locationHandler: LocationHandler, - preferences: SharedPreferences, - private val context: Context, - private val notificationHandler: NotificationHandler, - private val alertDataHandler: AlertDataHandler, - private val alertSignal: AlertSignal - -) : OnSharedPreferenceChangeListener { - - var alertParameters: AlertParameters - private set - - private val alertConsumerContainer: ConsumerContainer = object : ConsumerContainer() { - override fun addedFirstConsumer() { - Log.d(Main.LOG_TAG, "AlertHandler: added first alert consumer") - } +class AlertHandler + @Inject + constructor( + private val locationHandler: LocationHandler, + preferences: SharedPreferences, + private val context: Context, + private val notificationHandler: NotificationHandler, + private val alertDataHandler: AlertDataHandler, + private val alertSignal: AlertSignal, + ) : OnSharedPreferenceChangeListener { + var alertParameters: AlertParameters + private set + + private val alertConsumerContainer: ConsumerContainer = + object : ConsumerContainer() { + override fun addedFirstConsumer() { + Log.d(Main.LOG_TAG, "AlertHandler: added first alert consumer") + } - override fun removedLastConsumer() { - Log.d(Main.LOG_TAG, "AlertHandler: removed last alert consumer") - } - } + override fun removedLastConsumer() { + Log.d(Main.LOG_TAG, "AlertHandler: removed last alert consumer") + } + } - private var lastStrikes: Strikes? = null + private var lastStrikes: Strikes? = null - private var alertEnabled: Boolean = false + private var alertEnabled: Boolean = false - private var notificationDistanceLimit: Float = 0.0f + private var notificationDistanceLimit: Float = 0.0f - private var notificationLastTimestamp: Long = 0 + private var notificationLastTimestamp: Long = 0 - private var signalingDistanceLimit: Float = 0.0f + private var signalingDistanceLimit: Float = 0.0f - private var signalingThresholdTime: Long = 0 + private var signalingThresholdTime: Long = 0 - private var signalingLastTimestamp: Long = 0 + private var signalingLastTimestamp: Long = 0 - private val locationEventConsumer: (LocationEvent) -> Unit = { event -> - Log.v(Main.LOG_TAG, "AlertHandler.locationEventConsumer ${event.location}") + private val locationEventConsumer: (LocationEvent) -> Unit = { event -> + Log.v(Main.LOG_TAG, "AlertHandler.locationEventConsumer ${event.location}") - checkStrikes(lastStrikes, event.location) - } + checkStrikes(lastStrikes, event.location) + } - init { - locationHandler.requestUpdates(locationEventConsumer) - } + init { + locationHandler.requestUpdates(locationEventConsumer) + } - val dataEventConsumer: (Event) -> Unit = { event -> - if (event is ResultEvent) { - if (!event.flags.ignoreForAlerting) { - Log.v(Main.LOG_TAG, "AlertHandler.dataEventConsumer $event") - if (!event.failed && event.containsRealtimeData() && event.strikes != null) { - val strikes = Strikes(event.strikes, event.gridParameters) - checkStrikes(strikes, locationHandler.location) - } else { - if (!event.containsRealtimeData()) { - lastStrikes = null + val dataEventConsumer: (Event) -> Unit = { event -> + if (event is ResultEvent) { + if (!event.flags.ignoreForAlerting) { + Log.v(Main.LOG_TAG, "AlertHandler.dataEventConsumer $event") + if (!event.failed && event.containsRealtimeData() && event.strikes != null) { + val strikes = Strikes(event.strikes, event.gridParameters) + checkStrikes(strikes, locationHandler.location) + } else { + if (!event.containsRealtimeData()) { + lastStrikes = null + } + broadcastResult(null) } - broadcastResult(null) } } } - } - init { - Log.d(Main.LOG_TAG, "AlertHandler() create $this") - val rangeSteps = listOf(10f, 25f, 50f, 100f, 250f, 500f) - val alarmInterval = 10 * 60 * 1000L - val sectorLabels = context.resources.getStringArray(R.array.direction_names).toList() - alertParameters = AlertParameters(alarmInterval, rangeSteps, sectorLabels, MeasurementSystem.METRIC) - - preferences.registerOnSharedPreferenceChangeListener(this) - onSharedPreferenceChanged( - preferences, PreferenceKey.ALERT_ENABLED, PreferenceKey.MEASUREMENT_UNIT, - PreferenceKey.ALERT_NOTIFICATION_DISTANCE_LIMIT, PreferenceKey.ALERT_SIGNALING_DISTANCE_LIMIT, - PreferenceKey.ALERT_SIGNALING_THRESHOLD_TIME, PreferenceKey.ALERT_VIBRATION_SIGNAL, - PreferenceKey.ALERT_SOUND_SIGNAL - ) - } + init { + Log.d(Main.LOG_TAG, "AlertHandler() create $this") + val rangeSteps = listOf(10f, 25f, 50f, 100f, 250f, 500f) + val alarmInterval = 10 * 60 * 1000L + val sectorLabels = context.resources.getStringArray(R.array.direction_names).toList() + alertParameters = AlertParameters(alarmInterval, rangeSteps, sectorLabels, MeasurementSystem.METRIC) + + preferences.registerOnSharedPreferenceChangeListener(this) + onSharedPreferenceChanged( + preferences, + PreferenceKey.ALERT_ENABLED, + PreferenceKey.MEASUREMENT_UNIT, + PreferenceKey.ALERT_NOTIFICATION_DISTANCE_LIMIT, + PreferenceKey.ALERT_SIGNALING_DISTANCE_LIMIT, + PreferenceKey.ALERT_SIGNALING_THRESHOLD_TIME, + PreferenceKey.ALERT_VIBRATION_SIGNAL, + PreferenceKey.ALERT_SOUND_SIGNAL, + ) + } - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: PreferenceKey) { - when (key) { - PreferenceKey.ALERT_ENABLED -> { - alertEnabled = sharedPreferences.get(key, false) - Log.v(Main.LOG_TAG, "AlertHandler.onSharedPreferenceChanged() alertEnabled = $alertEnabled") - } + override fun onSharedPreferenceChanged( + sharedPreferences: SharedPreferences, + key: PreferenceKey, + ) { + when (key) { + PreferenceKey.ALERT_ENABLED -> { + alertEnabled = sharedPreferences.get(key, false) + Log.v(Main.LOG_TAG, "AlertHandler.onSharedPreferenceChanged() alertEnabled = $alertEnabled") + } - PreferenceKey.MEASUREMENT_UNIT -> { - val measurementSystemName = sharedPreferences.get(key, MeasurementSystem.METRIC.toString()) + PreferenceKey.MEASUREMENT_UNIT -> { + val measurementSystemName = sharedPreferences.get(key, MeasurementSystem.METRIC.toString()) - alertParameters = - alertParameters.copy(measurementSystem = MeasurementSystem.valueOf(measurementSystemName)) - Log.v( - Main.LOG_TAG, - "AlertHandler.onSharedPreferenceChanged() measurementSystem = ${alertParameters.measurementSystem}" - ) - } + alertParameters = + alertParameters.copy(measurementSystem = MeasurementSystem.valueOf(measurementSystemName)) + Log.v( + Main.LOG_TAG, + "AlertHandler.onSharedPreferenceChanged() measurementSystem = ${alertParameters.measurementSystem}", + ) + } - PreferenceKey.ALERT_NOTIFICATION_DISTANCE_LIMIT -> { - notificationDistanceLimit = sharedPreferences.get(key, "50").toFloat() - Log.v( - Main.LOG_TAG, - "AlertHandler.onSharedPreferenceChanged() notificationDistanceLimit = $notificationDistanceLimit" - ) - } + PreferenceKey.ALERT_NOTIFICATION_DISTANCE_LIMIT -> { + notificationDistanceLimit = sharedPreferences.get(key, "50").toFloat() + Log.v( + Main.LOG_TAG, + "AlertHandler.onSharedPreferenceChanged() notificationDistanceLimit = $notificationDistanceLimit", + ) + } - PreferenceKey.ALERT_SIGNALING_DISTANCE_LIMIT -> { - signalingDistanceLimit = sharedPreferences.get(key, "25").toFloat() - Log.v( - Main.LOG_TAG, - "AlertHandler.onSharedPreferenceChanged() signalingDistanceLimit = $signalingDistanceLimit" - ) - } + PreferenceKey.ALERT_SIGNALING_DISTANCE_LIMIT -> { + signalingDistanceLimit = sharedPreferences.get(key, "25").toFloat() + Log.v( + Main.LOG_TAG, + "AlertHandler.onSharedPreferenceChanged() signalingDistanceLimit = $signalingDistanceLimit", + ) + } - PreferenceKey.ALERT_SIGNALING_THRESHOLD_TIME -> { - signalingThresholdTime = sharedPreferences.get(key, "25").toLong() * 1000 * 60 - Log.v( - Main.LOG_TAG, - "AlertHandler.onSharedPreferenceChanged() signalingThresholdTime = $signalingThresholdTime" - ) - } + PreferenceKey.ALERT_SIGNALING_THRESHOLD_TIME -> { + signalingThresholdTime = sharedPreferences.get(key, "25").toLong() * 1000 * 60 + Log.v( + Main.LOG_TAG, + "AlertHandler.onSharedPreferenceChanged() signalingThresholdTime = $signalingThresholdTime", + ) + } - else -> {} + else -> {} + } } - } - private fun checkStrikes(strikes: Strikes?, location: Location?) { - lastStrikes = strikes + private fun checkStrikes( + strikes: Strikes?, + location: Location?, + ) { + lastStrikes = strikes - val alertResult = if (location != null && strikes != null) { - alertDataHandler.checkStrikes(strikes, location, alertParameters) - } else { - Log.v( - Main.LOG_TAG, - "AlertHandler.checkStrikes() strikes: ${strikes != null}, location: ${locationHandler.location != null}" - ) - null - } + val alertResult = + if (location != null && strikes != null) { + alertDataHandler.checkStrikes(strikes, location, alertParameters) + } else { + Log.v( + Main.LOG_TAG, + "AlertHandler.checkStrikes() strikes: ${strikes != null}, location: ${locationHandler.location != null}", + ) + null + } - processResult(alertResult) - } + processResult(alertResult) + } - val maxDistance: Float - get() = alertParameters.rangeSteps.last() + val maxDistance: Float + get() = alertParameters.rangeSteps.last() - private fun broadcastResult(alertResult: AlertResult?) { - val alertEvent = if (alertResult != null) AlertResultEvent(alertResult) else ALERT_CANCEL_EVENT - Log.d(Main.LOG_TAG, "AlertHandler.broadcastResult(${alertEvent})") - alertConsumerContainer.storeAndBroadcast(alertEvent) - } + private fun broadcastResult(alertResult: AlertResult?) { + val alertEvent = if (alertResult != null) AlertResultEvent(alertResult) else ALERT_CANCEL_EVENT + Log.d(Main.LOG_TAG, "AlertHandler.broadcastResult($alertEvent)") + alertConsumerContainer.storeAndBroadcast(alertEvent) + } - private fun processResult(alertResult: AlertResult?) { - if (alertEnabled && alertResult != null) { - if (alertResult.closestStrikeDistance <= signalingDistanceLimit) { - alertSignal(alertResult) - } - if (alertResult.closestStrikeDistance <= notificationDistanceLimit) { - alertNotification(alertResult) + private fun processResult(alertResult: AlertResult?) { + if (alertEnabled && alertResult != null) { + if (alertResult.closestStrikeDistance <= signalingDistanceLimit) { + alertSignal(alertResult) + } + if (alertResult.closestStrikeDistance <= notificationDistanceLimit) { + alertNotification(alertResult) + } } + broadcastResult(alertResult) } - broadcastResult(alertResult) - } - private fun alertSignal(alertResult: AlertResult) { - val signalingLatestTimestamp = alertDataHandler.getLatestTimstampWithin(signalingDistanceLimit, alertResult) - if (signalingLatestTimestamp > signalingLastTimestamp + signalingThresholdTime) { - Log.d(Main.LOG_TAG, "AlertHandler.alertSignal() signal ${signalingLatestTimestamp / 1000}") - alertSignal.emitSignal() - signalingLastTimestamp = signalingLatestTimestamp - } else { - Log.d( - Main.LOG_TAG, - "AlertHandler.alertSignal() skipped - ${(signalingLatestTimestamp - signalingLastTimestamp) / 1000}, threshold: ${signalingThresholdTime / 1000}" - ) + private fun alertSignal(alertResult: AlertResult) { + val signalingLatestTimestamp = alertDataHandler.getLatestTimstampWithin(signalingDistanceLimit, alertResult) + if (signalingLatestTimestamp > signalingLastTimestamp + signalingThresholdTime) { + Log.d(Main.LOG_TAG, "AlertHandler.alertSignal() signal ${signalingLatestTimestamp / 1000}") + alertSignal.emitSignal() + signalingLastTimestamp = signalingLatestTimestamp + } else { + Log.d( + Main.LOG_TAG, + "AlertHandler.alertSignal() skipped - ${(signalingLatestTimestamp - signalingLastTimestamp) / 1000}, threshold: ${signalingThresholdTime / 1000}", + ) + } } - } - private fun alertNotification(alertResult: AlertResult) { - val notificationLatestTimestamp = - alertDataHandler.getLatestTimstampWithin(notificationDistanceLimit, alertResult) - if (notificationLatestTimestamp > notificationLastTimestamp) { - Log.d(Main.LOG_TAG, "AlertHandler.alertNotification() notification ${notificationLatestTimestamp / 1000}") - notificationHandler.sendNotification( - context.resources.getString(R.string.activity) + ": " + alertDataHandler.getTextMessage( - alertResult, - notificationDistanceLimit, - context.resources + private fun alertNotification(alertResult: AlertResult) { + val notificationLatestTimestamp = + alertDataHandler.getLatestTimstampWithin(notificationDistanceLimit, alertResult) + if (notificationLatestTimestamp > notificationLastTimestamp) { + Log.d( + Main.LOG_TAG, + "AlertHandler.alertNotification() notification ${notificationLatestTimestamp / 1000}", ) - ) - notificationLastTimestamp = notificationLatestTimestamp - } else { - Log.d( - Main.LOG_TAG, - "AlertHandler.alertNotification() skipped ${notificationLatestTimestamp - notificationLastTimestamp}" - ) + notificationHandler.sendNotification( + context.resources.getString(R.string.activity) + ": " + + alertDataHandler.getTextMessage( + alertResult, + notificationDistanceLimit, + context.resources, + ), + ) + notificationLastTimestamp = notificationLatestTimestamp + } else { + Log.d( + Main.LOG_TAG, + "AlertHandler.alertNotification() skipped ${notificationLatestTimestamp - notificationLastTimestamp}", + ) + } } - } - fun requestUpdates(alertEventConsumer: (AlertEvent) -> Unit) { - alertConsumerContainer.addConsumer(alertEventConsumer) - } + fun requestUpdates(alertEventConsumer: (AlertEvent) -> Unit) { + alertConsumerContainer.addConsumer(alertEventConsumer) + } - fun removeUpdates(alertEventConsumer: (AlertEvent) -> Unit) { - alertConsumerContainer.removeConsumer(alertEventConsumer) - } + fun removeUpdates(alertEventConsumer: (AlertEvent) -> Unit) { + alertConsumerContainer.removeConsumer(alertEventConsumer) + } - val alertEvent: AlertEvent - get() = alertConsumerContainer.currentPayload ?: ALERT_CANCEL_EVENT + val alertEvent: AlertEvent + get() = alertConsumerContainer.currentPayload ?: ALERT_CANCEL_EVENT - companion object { - val ALERT_CANCEL_EVENT = AlertCancelEvent() + companion object { + val ALERT_CANCEL_EVENT = AlertCancelEvent() + } } -} data class Strikes( val strikes: Collection, - val gridParameters: GridParameters? = null + val gridParameters: GridParameters? = null, ) diff --git a/app/src/main/java/org/blitzortung/android/alert/handler/AlertSignal.kt b/app/src/main/java/org/blitzortung/android/alert/handler/AlertSignal.kt index 08a16c85e..26aa1904e 100644 --- a/app/src/main/java/org/blitzortung/android/alert/handler/AlertSignal.kt +++ b/app/src/main/java/org/blitzortung/android/alert/handler/AlertSignal.kt @@ -9,10 +9,14 @@ import android.media.Ringtone import android.media.RingtoneManager import android.net.Uri import android.os.Build +import android.os.VibrationEffect import android.os.Vibrator import android.util.Log import androidx.annotation.RequiresApi +import androidx.core.net.toUri import androidx.preference.PreferenceManager +import javax.inject.Inject +import javax.inject.Singleton import org.blitzortung.android.app.Main import org.blitzortung.android.app.view.OnSharedPreferenceChangeListener import org.blitzortung.android.app.view.PreferenceKey @@ -20,84 +24,100 @@ import org.blitzortung.android.app.view.PreferenceKey.ALERT_SOUND_SIGNAL import org.blitzortung.android.app.view.PreferenceKey.ALERT_VIBRATION_SIGNAL import org.blitzortung.android.app.view.get import org.blitzortung.android.util.isAtLeast -import javax.inject.Inject -import javax.inject.Singleton -import androidx.core.net.toUri @Singleton -class AlertSignal @Inject constructor( - private val context: Context, - private val vibrator: Vibrator, - private val notificationManager: NotificationManager -) : OnSharedPreferenceChangeListener { +class AlertSignal + @Inject + constructor( + private val context: Context, + private val vibrator: Vibrator, + private val notificationManager: NotificationManager, + ) : OnSharedPreferenceChangeListener { + init { + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + preferences.registerOnSharedPreferenceChangeListener(this) + onSharedPreferenceChanged(preferences, ALERT_VIBRATION_SIGNAL, ALERT_SOUND_SIGNAL) + } - init { - val preferences = PreferenceManager.getDefaultSharedPreferences(context) - preferences.registerOnSharedPreferenceChangeListener(this) - onSharedPreferenceChanged(preferences, ALERT_VIBRATION_SIGNAL, ALERT_SOUND_SIGNAL) - } + private var soundSignal: Uri? = null - private var soundSignal: Uri? = null + private var vibrationDuration = 0L - private var vibrationDuration = 0L + override fun onSharedPreferenceChanged( + sharedPreferences: SharedPreferences, + key: PreferenceKey, + ) { + when (key) { + ALERT_VIBRATION_SIGNAL -> { + vibrationDuration = sharedPreferences.get(key, 3) * 10L + Log.v( + Main.LOG_TAG, + "AlertHandler.onSharedPreferenceChanged() vibrationDuration = $vibrationDuration", + ) + } - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: PreferenceKey) { + ALERT_SOUND_SIGNAL -> { + val signalUri = sharedPreferences.get(key, "") + soundSignal = if (signalUri.isNotEmpty()) signalUri.toUri() else null + Log.v(Main.LOG_TAG, "AlertHandler.onSharedPreferenceChanged() soundSignal = $soundSignal") + } - when (key) { - ALERT_VIBRATION_SIGNAL -> { - vibrationDuration = sharedPreferences.get(key, 3) * 10L - Log.v(Main.LOG_TAG, "AlertHandler.onSharedPreferenceChanged() vibrationDuration = $vibrationDuration") + else -> {} } + } - ALERT_SOUND_SIGNAL -> { - val signalUri = sharedPreferences.get(key, "") - soundSignal = if (signalUri.isNotEmpty()) signalUri.toUri() else null - Log.v(Main.LOG_TAG, "AlertHandler.onSharedPreferenceChanged() soundSignal = $soundSignal") + fun emitSignal() { + vibrateIfEnabled() + val playSound = if (isAtLeast(Build.VERSION_CODES.M)) !doNotDisturb() else true + if (playSound) { + playSoundIfEnabled() } - - else -> {} } - } - fun emitSignal() { - vibrateIfEnabled() - val playSound = if (isAtLeast(Build.VERSION_CODES.M)) !doNotDisturb() else true - if (playSound) { - playSoundIfEnabled() + private fun vibrateIfEnabled() { + if (vibrationDuration > 0) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Android 8.0+ (API 26+): Use VibrationEffect + val vibrationEffect = VibrationEffect.createOneShot( + vibrationDuration, + VibrationEffect.DEFAULT_AMPLITUDE + ) + vibrator.vibrate(vibrationEffect) + } else { + // Legacy devices: Use deprecated method + @Suppress("DEPRECATION") + vibrator.vibrate(vibrationDuration) + } + } } - } - private fun vibrateIfEnabled() { - vibrator.vibrate(vibrationDuration) - } - - private fun playSoundIfEnabled() { - soundSignal?.let { signal -> - RingtoneManager.getRingtone(context, signal)?.let { ringtone -> - playRingtone(ringtone) + private fun playSoundIfEnabled() { + soundSignal?.let { signal -> + RingtoneManager.getRingtone(context, signal)?.let { ringtone -> + playRingtone(ringtone) + } } } - } - private fun playRingtone(ringtone: Ringtone): Int { - if (!ringtone.isPlaying) { - if (isAtLeast(Build.VERSION_CODES.LOLLIPOP)) { - ringtone.audioAttributes = - AudioAttributes.Builder().setLegacyStreamType(AudioManager.STREAM_NOTIFICATION).build() - } else { - @Suppress("DEPRECATION") - ringtone.streamType = AudioManager.STREAM_NOTIFICATION + private fun playRingtone(ringtone: Ringtone): Int { + if (!ringtone.isPlaying) { + if (isAtLeast(Build.VERSION_CODES.LOLLIPOP)) { + ringtone.audioAttributes = + AudioAttributes.Builder().setLegacyStreamType(AudioManager.STREAM_NOTIFICATION).build() + } else { + @Suppress("DEPRECATION") + ringtone.streamType = AudioManager.STREAM_NOTIFICATION + } + ringtone.play() } - ringtone.play() + return Log.v(Main.LOG_TAG, "playing " + ringtone.getTitle(context)) } - return Log.v(Main.LOG_TAG, "playing " + ringtone.getTitle(context)) - } - @RequiresApi(Build.VERSION_CODES.M) - private fun doNotDisturb(): Boolean { - val currentInterruptionFilter = notificationManager.currentInterruptionFilter - val doNotDisturb = currentInterruptionFilter >= NotificationManager.INTERRUPTION_FILTER_PRIORITY - Log.v(Main.LOG_TAG, "AlertHandler.doNotDisturb() current: $currentInterruptionFilter filter: $doNotDisturb") - return doNotDisturb + @RequiresApi(Build.VERSION_CODES.M) + private fun doNotDisturb(): Boolean { + val currentInterruptionFilter = notificationManager.currentInterruptionFilter + val doNotDisturb = currentInterruptionFilter >= NotificationManager.INTERRUPTION_FILTER_PRIORITY + Log.v(Main.LOG_TAG, "AlertHandler.doNotDisturb() current: $currentInterruptionFilter filter: $doNotDisturb") + return doNotDisturb + } } -} \ No newline at end of file diff --git a/app/src/main/java/org/blitzortung/android/app/AppService.kt b/app/src/main/java/org/blitzortung/android/app/AppService.kt index 7dbd1aa81..1f35de679 100644 --- a/app/src/main/java/org/blitzortung/android/app/AppService.kt +++ b/app/src/main/java/org/blitzortung/android/app/AppService.kt @@ -33,6 +33,7 @@ import android.util.Log import androidx.annotation.RequiresApi import androidx.core.app.ServiceCompat import dagger.android.AndroidInjection +import javax.inject.Inject import org.blitzortung.android.alert.handler.AlertHandler import org.blitzortung.android.app.controller.NotificationHandler.Companion.CHANNEL_ID import org.blitzortung.android.app.view.OnSharedPreferenceChangeListener @@ -43,11 +44,8 @@ import org.blitzortung.android.data.provider.result.DataEvent import org.blitzortung.android.location.LocationHandler import org.blitzortung.android.util.LogUtil import org.blitzortung.android.util.isAtLeast -import javax.inject.Inject - class AppService : Service(), OnSharedPreferenceChangeListener { - init { Log.d(Main.LOG_TAG, "AppService() create") } @@ -83,7 +81,7 @@ class AppService : Service(), OnSharedPreferenceChangeListener { private var lastUpdateTime: Long? = null private val dataEventConsumer = { _: DataEvent -> - //releaseWakeLock() + // releaseWakeLock() } override fun onCreate() { @@ -103,7 +101,11 @@ class AppService : Service(), OnSharedPreferenceChangeListener { configureServiceMode() } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int, + ): Int { Log.v(Main.LOG_TAG, "AppService.onStartCommand() startId: $startId $intent") if (!locationHandler.backgroundMode) { @@ -115,9 +117,10 @@ class AppService : Service(), OnSharedPreferenceChangeListener { if (intent != null && RETRIEVE_DATA_ACTION == intent.action) { val currentTimeSeconds = System.currentTimeMillis() / 1000 - val timeDifference = lastUpdateTime?.let { - currentTimeSeconds - it - } + val timeDifference = + lastUpdateTime?.let { + currentTimeSeconds - it + } if (timeDifference == null || timeDifference > 0.6 * backgroundPeriod) { Log.i(Main.LOG_TAG, "AppService.onStartCommand() with time difference ${timeDifference ?: 0} s") lastUpdateTime = currentTimeSeconds @@ -125,7 +128,7 @@ class AppService : Service(), OnSharedPreferenceChangeListener { } else { Log.d( Main.LOG_TAG, - "AppService.onStartCommand() skip with insufficient time passed: ${currentTimeSeconds - lastUpdateTime!!} s < $backgroundPeriod s" + "AppService.onStartCommand() skip with insufficient time passed: ${currentTimeSeconds - lastUpdateTime!!} s < $backgroundPeriod s", ) } } else { @@ -134,11 +137,14 @@ class AppService : Service(), OnSharedPreferenceChangeListener { val intent = Intent(this, Main::class.java) intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP - val flags = PendingIntent.FLAG_CANCEL_CURRENT or (if (isAtLeast(Build.VERSION_CODES.M)) { - PendingIntent.FLAG_IMMUTABLE - } else { - 0 - }) + val flags = + PendingIntent.FLAG_CANCEL_CURRENT or ( + if (isAtLeast(Build.VERSION_CODES.M)) { + PendingIntent.FLAG_IMMUTABLE + } else { + 0 + } + ) val contentIntent = PendingIntent.getActivity(this, 0, intent, flags) if (isAtLeast(29)) { @@ -150,14 +156,15 @@ class AppService : Service(), OnSharedPreferenceChangeListener { @RequiresApi(Build.VERSION_CODES.Q) private fun startForeground(contentIntent: PendingIntent?) { - val notification = Notification.Builder(this, CHANNEL_ID) - .setSmallIcon(R.drawable.icon) - .setContentTitle(this.resources.getText(R.string.app_name)) - .setContentText("bla") - .setContentIntent(contentIntent) - .setAutoCancel(true) - .setWhen(System.currentTimeMillis()) - .setShowWhen(true).build() + val notification = + Notification.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.icon) + .setContentTitle(this.resources.getText(R.string.app_name)) + .setContentText("bla") + .setContentIntent(contentIntent) + .setAutoCancel(true) + .setWhen(System.currentTimeMillis()) + .setShowWhen(true).build() ServiceCompat.startForeground(this, 0, notification, FOREGROUND_SERVICE_TYPE_MANIFEST) } @@ -191,7 +198,7 @@ class AppService : Service(), OnSharedPreferenceChangeListener { packageManager.setComponentEnabledSetting( receiver, if (enable) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED, - PackageManager.DONT_KILL_APP + PackageManager.DONT_KILL_APP, ) } @@ -227,11 +234,12 @@ class AppService : Service(), OnSharedPreferenceChangeListener { Log.v(Main.LOG_TAG, "AppService.createAlarm() with backgroundPeriod=%d".format(backgroundPeriod)) val intent = Intent(this, AppService::class.java) intent.action = RETRIEVE_DATA_ACTION - val flags = if (isAtLeast(Build.VERSION_CODES.M)) { - PendingIntent.FLAG_IMMUTABLE - } else { - 0 - } + val flags = + if (isAtLeast(Build.VERSION_CODES.M)) { + PendingIntent.FLAG_IMMUTABLE + } else { + 0 + } val pendingIntent = PendingIntent.getService(this, 0, intent, flags) this.pendingIntent = pendingIntent @@ -255,7 +263,10 @@ class AppService : Service(), OnSharedPreferenceChangeListener { } } - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: PreferenceKey) { + override fun onSharedPreferenceChanged( + sharedPreferences: SharedPreferences, + key: PreferenceKey, + ) { when (key) { PreferenceKey.ALERT_ENABLED -> { alertEnabled = sharedPreferences.get(key, false) diff --git a/app/src/main/java/org/blitzortung/android/app/BOApplication.kt b/app/src/main/java/org/blitzortung/android/app/BOApplication.kt index 71a76deb1..07ea51a17 100644 --- a/app/src/main/java/org/blitzortung/android/app/BOApplication.kt +++ b/app/src/main/java/org/blitzortung/android/app/BOApplication.kt @@ -4,13 +4,12 @@ import android.app.Application import dagger.android.AndroidInjector import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector +import javax.inject.Inject import org.blitzortung.android.dagger.component.DaggerAppComponent import org.blitzortung.android.dagger.module.AppModule import org.blitzortung.android.dagger.module.ServiceModule -import javax.inject.Inject class BOApplication : Application(), HasAndroidInjector { - @set:Inject lateinit var androidInjector: DispatchingAndroidInjector @@ -30,4 +29,4 @@ class BOApplication : Application(), HasAndroidInjector { companion object { const val WAKE_LOCK_TAG = "boAndroid:WakeLock" } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/blitzortung/android/app/BootReceiver.kt b/app/src/main/java/org/blitzortung/android/app/BootReceiver.kt index bd10931ce..be0523b0d 100644 --- a/app/src/main/java/org/blitzortung/android/app/BootReceiver.kt +++ b/app/src/main/java/org/blitzortung/android/app/BootReceiver.kt @@ -25,7 +25,10 @@ import android.util.Log import org.blitzortung.android.app.Main.Companion.LOG_TAG class BootReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { + override fun onReceive( + context: Context, + intent: Intent, + ) { if (intent.action == "android.intent.action.BOOT_COMPLETED") { Log.v(LOG_TAG, "BootReceiver.onReceive() intent action: ${intent.action}") @@ -40,4 +43,4 @@ class BootReceiver : BroadcastReceiver() { Log.v(LOG_TAG, "BootReceiver.onReceive() invalid intent action: ${intent.action}") } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/blitzortung/android/app/ButtonGroup.kt b/app/src/main/java/org/blitzortung/android/app/ButtonGroup.kt index 0a3e0eae0..1c613a1ae 100644 --- a/app/src/main/java/org/blitzortung/android/app/ButtonGroup.kt +++ b/app/src/main/java/org/blitzortung/android/app/ButtonGroup.kt @@ -19,5 +19,5 @@ package org.blitzortung.android.app enum class ButtonGroup { - DATA_UPDATING -} \ No newline at end of file + DATA_UPDATING, +} diff --git a/app/src/main/java/org/blitzortung/android/app/Main.kt b/app/src/main/java/org/blitzortung/android/app/Main.kt index 7ae6b4478..03a459f9d 100644 --- a/app/src/main/java/org/blitzortung/android/app/Main.kt +++ b/app/src/main/java/org/blitzortung/android/app/Main.kt @@ -39,6 +39,8 @@ import androidx.core.view.isVisible import androidx.fragment.app.FragmentActivity import androidx.preference.PreferenceManager import dagger.android.AndroidInjection +import javax.inject.Inject +import kotlin.math.roundToInt import org.blitzortung.android.alert.event.AlertResultEvent import org.blitzortung.android.alert.handler.AlertHandler import org.blitzortung.android.app.components.BuildVersion @@ -81,8 +83,6 @@ import org.osmdroid.config.Configuration import org.osmdroid.events.ZoomEvent import org.osmdroid.tileprovider.util.StorageUtils import org.osmdroid.util.GeoPoint -import javax.inject.Inject -import kotlin.math.roundToInt class Main : FragmentActivity(), OnSharedPreferenceChangeListener { private var backgroundAlertEnabled: Boolean = false @@ -163,17 +163,19 @@ class Main : FragmentActivity(), OnSharedPreferenceChangeListener { } if (event.strikes != null) { - val strikes = if (event.updated > 0 && !initializeOverlay) { - val size = event.strikes.size - event.strikes.subList(size - event.updated, size) - } else { - event.strikes - } + val strikes = + if (event.updated > 0 && !initializeOverlay) { + val size = event.strikes.size + event.strikes.subList(size - event.updated, size) + } else { + event.strikes + } strikeListOverlay.addStrikes(strikes) } binding.alertView.setColorHandler( - strikeColorHandler, strikeListOverlay.parameters.intervalDuration + strikeColorHandler, + strikeListOverlay.parameters.intervalDuration, ) strikeListOverlay.refresh() @@ -231,13 +233,14 @@ class Main : FragmentActivity(), OnSharedPreferenceChangeListener { strikeColorHandler = StrikeColorHandler(preferences) - statusComponent = StatusComponent( - findViewById(R.id.warning), - findViewById(R.id.status), - findViewById(R.id.progress), - findViewById(R.id.error_indicator), - resources - ) + statusComponent = + StatusComponent( + findViewById(R.id.warning), + findViewById(R.id.status), + findViewById(R.id.progress), + findViewById(R.id.error_indicator), + resources, + ) buttonColumnHandler = ButtonColumnHandler(if (TabletAwareView.isTablet(this)) 75f else 55f) configureMenuAccess() @@ -245,7 +248,7 @@ class Main : FragmentActivity(), OnSharedPreferenceChangeListener { val historyButtons = historyController.getButtons() buttonColumnHandler.addAllElements(historyButtons, ButtonGroup.DATA_UPDATING) - //setupDetailModeButton() + // setupDetailModeButton() buttonColumnHandler.updateButtonColumn() @@ -256,34 +259,41 @@ class Main : FragmentActivity(), OnSharedPreferenceChangeListener { changeLogComponent.showChangeLogDialog(this) } - binding.timeSlider.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { - override fun onProgressChanged(p0: SeekBar?, p1: Int, p2: Boolean) { - if (p2) { - val changed = dataHandler.setPosition(p1) - if (changed) { - if (dataHandler.isRealtime) { - dataHandler.restart() - } else { - Log.v(LOG_TAG, "TimeSlider call updateData()") - dataHandler.updateData() + binding.timeSlider.setOnSeekBarChangeListener( + object : OnSeekBarChangeListener { + override fun onProgressChanged( + p0: SeekBar?, + p1: Int, + p2: Boolean, + ) { + if (p2) { + val changed = dataHandler.setPosition(p1) + if (changed) { + if (dataHandler.isRealtime) { + dataHandler.restart() + } else { + Log.v(LOG_TAG, "TimeSlider call updateData()") + dataHandler.updateData() + } } } } - } - override fun onStartTrackingTouch(p0: SeekBar?) { - } - - override fun onStopTrackingTouch(p0: SeekBar?) { - } - }) + override fun onStartTrackingTouch(p0: SeekBar?) { + } - permissionRequesters = arrayOf( - LocationPermissionRequester(this, preferences), - NotificationPermissionRequester(this, preferences), - BackgroundLocationPermissionRequester(this, preferences), - WakeupPermissionRequester(this, preferences) + override fun onStopTrackingTouch(p0: SeekBar?) { + } + }, ) + + permissionRequesters = + arrayOf( + LocationPermissionRequester(this, preferences), + NotificationPermissionRequester(this, preferences), + BackgroundLocationPermissionRequester(this, preferences), + WakeupPermissionRequester(this, preferences), + ) } private fun initializeOsmDroid() { @@ -337,11 +347,12 @@ class Main : FragmentActivity(), OnSharedPreferenceChangeListener { setOnClickListener { val currentLocation = locationHandler.location if (currentLocation != null) { - val diameter = if (!keepZoomOnGotoOwnLocation) { - determineTargetZoomRadius(alertHandler) - } else { - null - } + val diameter = + if (!keepZoomOnGotoOwnLocation) { + determineTargetZoomRadius(alertHandler) + } else { + null + } animateToLocationAndVisibleSize(currentLocation.longitude, currentLocation.latitude, diameter) } @@ -361,7 +372,7 @@ class Main : FragmentActivity(), OnSharedPreferenceChangeListener { animateToLocationAndVisibleSize( gridParameters.rectCenterLongitude, gridParameters.rectCenterLatitude, - if (parameters.region == LOCAL_REGION) 1800f else 5000f + if (parameters.region == LOCAL_REGION) 1800f else 5000f, ) } else { animateToLocationAndVisibleSize(-30.0, 0.0, 40000f) @@ -389,17 +400,22 @@ class Main : FragmentActivity(), OnSharedPreferenceChangeListener { dialog.show(supportFragmentManager, "QuickSettingsDialog") } - private fun animateToLocationAndVisibleSize(longitude: Double, latitude: Double, diameter: Float?) { + private fun animateToLocationAndVisibleSize( + longitude: Double, + latitude: Double, + diameter: Float?, + ) { Log.d(LOG_TAG, "Main.animateAndZoomTo() %.4f, %.4f, %.0fkm".format(longitude, latitude, diameter)) val mapView = mapFragment.mapView - //If no diameter is provided, we keep the current zoomLevel - val targetZoomLevel = if (diameter != null) { - mapFragment.calculateTargetZoomLevel(diameter * 1000f) * 1.0 - } else { - mapView.zoomLevelDouble - } + // If no diameter is provided, we keep the current zoomLevel + val targetZoomLevel = + if (diameter != null) { + mapFragment.calculateTargetZoomLevel(diameter * 1000f) * 1.0 + } else { + mapView.zoomLevelDouble + } mapView.controller.animateTo(GeoPoint(latitude, longitude), targetZoomLevel, OwnMapView.DEFAULT_ZOOM_SPEED) } @@ -448,7 +464,6 @@ class Main : FragmentActivity(), OnSharedPreferenceChangeListener { Log.d(LOG_TAG, "Main.onRestart()") } - override fun onResume() { super.onResume() @@ -458,7 +473,8 @@ class Main : FragmentActivity(), OnSharedPreferenceChangeListener { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { PermissionsSupport.ensure( - this, *permissionRequesters + this, + *permissionRequesters, ) } @@ -585,10 +601,14 @@ class Main : FragmentActivity(), OnSharedPreferenceChangeListener { strikeListOverlay.clear() } - override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { Log.v( LOG_TAG, - "Main.onRequestPermissionResult() permissions: ${permissions.joinToString(",")}, requestCode: $requestCode" + "Main.onRequestPermissionResult() permissions: ${permissions.joinToString(",")}, requestCode: $requestCode", ) for (requester in permissionRequesters) { val handled = requester.onRequestPermissionsResult(requestCode, permissions, grantResults) @@ -597,7 +617,10 @@ class Main : FragmentActivity(), OnSharedPreferenceChangeListener { super.onRequestPermissionsResult(requestCode, permissions, grantResults) } - override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { + override fun onKeyUp( + keyCode: Int, + event: KeyEvent?, + ): Boolean { if (keyCode == KeyEvent.KEYCODE_MENU) { Log.v(LOG_TAG, "Main.onKeyUp(KEYCODE_MENU)") showPopupMenu(binding.upperRow) @@ -606,7 +629,10 @@ class Main : FragmentActivity(), OnSharedPreferenceChangeListener { return super.onKeyUp(keyCode, event) } - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: PreferenceKey) { + override fun onSharedPreferenceChanged( + sharedPreferences: SharedPreferences, + key: PreferenceKey, + ) { when (key) { PreferenceKey.COLOR_SCHEME -> { strikeListOverlay.refresh() diff --git a/app/src/main/java/org/blitzortung/android/app/MainPopupMenu.kt b/app/src/main/java/org/blitzortung/android/app/MainPopupMenu.kt index 582264142..632e22156 100644 --- a/app/src/main/java/org/blitzortung/android/app/MainPopupMenu.kt +++ b/app/src/main/java/org/blitzortung/android/app/MainPopupMenu.kt @@ -25,9 +25,8 @@ class MainPopupMenu( dataHandler: MainDataHandler, alertHandler: AlertHandler, private val buildVersion: BuildVersion, - private val changeLogComponent: ChangeLogComponent + private val changeLogComponent: ChangeLogComponent, ) : PopupMenu(context, anchor) { - init { setOnMenuItemClickListener(ClickListener(context, preferences, dataHandler, alertHandler)) } @@ -36,28 +35,30 @@ class MainPopupMenu( private val context: Context, private val preferences: SharedPreferences, private val dataHandler: MainDataHandler, - private val alertHandler: AlertHandler + private val alertHandler: AlertHandler, ) : OnMenuItemClickListener { override fun onMenuItemClick(item: MenuItem?): Boolean { if (item?.itemId == R.id.menu_preferences) { context.startActivity(Intent(context, SettingsActivity::class.java)) } else { - val dialog = when (item?.itemId) { - R.id.menu_info -> InfoDialog(context, buildVersion) + val dialog = + when (item?.itemId) { + R.id.menu_info -> InfoDialog(context, buildVersion) - R.id.menu_alarms -> AlertDialog( - context, - AlertDialogColorHandler(preferences), - dataHandler, - alertHandler - ) + R.id.menu_alarms -> + AlertDialog( + context, + AlertDialogColorHandler(preferences), + dataHandler, + alertHandler, + ) - R.id.menu_log -> LogDialog(context, dataHandler.calculateTotalCacheSize(), buildVersion) + R.id.menu_log -> LogDialog(context, dataHandler.calculateTotalCacheSize(), buildVersion) - R.id.menu_changelog -> changeLogComponent.getChangeLogDialog(context) + R.id.menu_changelog -> changeLogComponent.getChangeLogDialog(context) - else -> null - } + else -> null + } if (dialog is Dialog) { dialog.show() diff --git a/app/src/main/java/org/blitzortung/android/app/WidgetProvider.kt b/app/src/main/java/org/blitzortung/android/app/WidgetProvider.kt index 592136dd9..df1c2103b 100644 --- a/app/src/main/java/org/blitzortung/android/app/WidgetProvider.kt +++ b/app/src/main/java/org/blitzortung/android/app/WidgetProvider.kt @@ -25,8 +25,11 @@ import android.widget.RemoteViews import org.blitzortung.android.app.view.AlertView class WidgetProvider : AppWidgetProvider() { - - override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { for (element in appWidgetIds) { element updateAppWidget(context) @@ -34,18 +37,17 @@ class WidgetProvider : AppWidgetProvider() { } private fun updateAppWidget(context: Context) { - val alertView = AlertView(context) alertView.measure(150, 150) alertView.layout(0, 0, 150, 150) alertView.isDrawingCacheEnabled = true val bitmap = alertView.drawingCache - val remoteViews = RemoteViews( - context.packageName, - R.layout.widget - ) + val remoteViews = + RemoteViews( + context.packageName, + R.layout.widget, + ) remoteViews.setImageViewBitmap(R.layout.widget, bitmap) - } } diff --git a/app/src/main/java/org/blitzortung/android/app/components/ChangeLogComponent.kt b/app/src/main/java/org/blitzortung/android/app/components/ChangeLogComponent.kt index e13f6b776..bd4a7ae38 100644 --- a/app/src/main/java/org/blitzortung/android/app/components/ChangeLogComponent.kt +++ b/app/src/main/java/org/blitzortung/android/app/components/ChangeLogComponent.kt @@ -20,16 +20,17 @@ package org.blitzortung.android.app.components import android.app.AlertDialog import android.content.Context -import org.blitzortung.android.dialogs.changelog.ChangelogDialog import javax.inject.Inject +import org.blitzortung.android.dialogs.changelog.ChangelogDialog -class ChangeLogComponent @Inject constructor( -) { - fun getChangeLogDialog(context: Context): AlertDialog { - return ChangelogDialog(context) - } +class ChangeLogComponent + @Inject + constructor() { + fun getChangeLogDialog(context: Context): AlertDialog { + return ChangelogDialog(context) + } - fun showChangeLogDialog(context: Context) { - getChangeLogDialog(context).show() + fun showChangeLogDialog(context: Context) { + getChangeLogDialog(context).show() + } } -} \ No newline at end of file diff --git a/app/src/main/java/org/blitzortung/android/app/components/VersionComponent.kt b/app/src/main/java/org/blitzortung/android/app/components/VersionComponent.kt index d4a69684f..a7ddfb16e 100644 --- a/app/src/main/java/org/blitzortung/android/app/components/VersionComponent.kt +++ b/app/src/main/java/org/blitzortung/android/app/components/VersionComponent.kt @@ -22,79 +22,88 @@ import android.content.Context import android.content.SharedPreferences import android.util.Log import androidx.core.content.edit +import javax.inject.Inject import org.blitzortung.android.app.BuildConfig import org.blitzortung.android.app.Main -import javax.inject.Inject -class VersionComponent @Inject constructor( - context: Context, - buildVersion: BuildVersion -) { +class VersionComponent + @Inject + constructor( + context: Context, + buildVersion: BuildVersion, + ) { + var state: State? = null + private set - var state: State? = null - private set + var configuredVersionCode: Int = 0 + private set - var configuredVersionCode: Int = 0 - private set + var configuredMajorVersion: Int = 0 + private set - var configuredMajorVersion: Int = 0 - private set + var configuredMinorVersion: Int = 0 + private set - var configuredMinorVersion: Int = 0 - private set + private val preferences: SharedPreferences - private val preferences: SharedPreferences + init { + val packageName = context.packageName + preferences = context.getSharedPreferences(packageName, Context.MODE_PRIVATE) - init { - val packageName = context.packageName - preferences = context.getSharedPreferences(packageName, Context.MODE_PRIVATE) + configuredVersionCode = getAndStoreVersion(CONFIGURED_VERSION_CODE, buildVersion.versionCode) + configuredMajorVersion = getAndStoreVersion(CONFIGURED_MAJOR_VERSION, buildVersion.majorVersion) + configuredMinorVersion = getAndStoreVersion(CONFIGURED_MINOR_VERSION, buildVersion.minorVersion) - configuredVersionCode = getAndStoreVersion(CONFIGURED_VERSION_CODE, buildVersion.versionCode) - configuredMajorVersion = getAndStoreVersion(CONFIGURED_MAJOR_VERSION, buildVersion.majorVersion) - configuredMinorVersion = getAndStoreVersion(CONFIGURED_MINOR_VERSION, buildVersion.minorVersion) + state = + when { + configuredVersionCode == -1 -> State.FIRST_RUN + configuredMajorVersion != buildVersion.majorVersion || + configuredMinorVersion != buildVersion.minorVersion -> State.FIRST_RUN_AFTER_UPDATE - state = when { - configuredVersionCode == -1 -> State.FIRST_RUN - configuredMajorVersion != buildVersion.majorVersion || - configuredMinorVersion != buildVersion.minorVersion -> State.FIRST_RUN_AFTER_UPDATE + else -> State.NO_UPDATE + } - else -> State.NO_UPDATE + Log.d( + Main.LOG_TAG, + "updateVersionStatus() name=$packageName, state=$state, versionCode=$configuredVersionCode/${buildVersion.versionCode}, major=$configuredMajorVersion/${buildVersion.majorVersion} minor=$configuredMinorVersion/${buildVersion.minorVersion}", + ) } - Log.d( - Main.LOG_TAG, - "updateVersionStatus() name=$packageName, state=$state, versionCode=$configuredVersionCode/${buildVersion.versionCode}, major=$configuredMajorVersion/${buildVersion.majorVersion} minor=$configuredMinorVersion/${buildVersion.minorVersion}" - ) - } + private fun getAndStoreVersion( + prefsKey: String, + newValue: Int, + ): Int { + val knownValue = preferences.getInt(prefsKey, -1) + preferences.edit { putInt(prefsKey, newValue) } + return knownValue + } - private fun getAndStoreVersion(prefsKey: String, newValue: Int): Int { - val knownValue = preferences.getInt(prefsKey, -1) - preferences.edit { putInt(prefsKey, newValue) } - return knownValue - } + enum class State { + FIRST_RUN, + FIRST_RUN_AFTER_UPDATE, + NO_UPDATE, + } - enum class State { - FIRST_RUN, FIRST_RUN_AFTER_UPDATE, NO_UPDATE + companion object { + internal const val CONFIGURED_VERSION_CODE = "configured_version_code" + internal const val CONFIGURED_MAJOR_VERSION = "configured_major_version" + internal const val CONFIGURED_MINOR_VERSION = "configured_minor_version" + } } - companion object { - internal const val CONFIGURED_VERSION_CODE = "configured_version_code" - internal const val CONFIGURED_MAJOR_VERSION = "configured_major_version" - internal const val CONFIGURED_MINOR_VERSION = "configured_minor_version" - } -} - -class BuildVersion @Inject constructor() { - val versionCode = BuildConfig.VERSION_CODE - var versionName = BuildConfig.VERSION_NAME - val majorVersion: Int - val minorVersion: Int - val patchVersion: Int - - init { - val versionComponents = versionName.split(".") - majorVersion = versionComponents[0].toInt() - minorVersion = versionComponents[1].split('-')[0].toInt() - patchVersion = if (versionComponents.size > 2) versionComponents[2].toInt() else -1 +class BuildVersion + @Inject + constructor() { + val versionCode = BuildConfig.VERSION_CODE + var versionName = BuildConfig.VERSION_NAME + val majorVersion: Int + val minorVersion: Int + val patchVersion: Int + + init { + val versionComponents = versionName.split(".") + majorVersion = versionComponents[0].toInt() + minorVersion = versionComponents[1].split('-')[0].toInt() + patchVersion = if (versionComponents.size > 2) versionComponents[2].toInt() else -1 + } } -} \ No newline at end of file diff --git a/app/src/main/java/org/blitzortung/android/app/controller/ButtonColumnHandler.kt b/app/src/main/java/org/blitzortung/android/app/controller/ButtonColumnHandler.kt index a98958be6..5488846a9 100644 --- a/app/src/main/java/org/blitzortung/android/app/controller/ButtonColumnHandler.kt +++ b/app/src/main/java/org/blitzortung/android/app/controller/ButtonColumnHandler.kt @@ -20,11 +20,10 @@ package org.blitzortung.android.app.controller import android.view.View import android.widget.RelativeLayout -import org.blitzortung.android.app.helper.ViewHelper.pxFromSp import androidx.core.view.isVisible +import org.blitzortung.android.app.helper.ViewHelper.pxFromSp class ButtonColumnHandler>(private val buttonSize: Float) { - data class GroupedView(val view: V, val groups: Set) private val elements: MutableList> @@ -33,11 +32,17 @@ class ButtonColumnHandler>(private val buttonSize: Float) elements = arrayListOf() } - fun addElement(element: V, vararg groups: G) { + fun addElement( + element: V, + vararg groups: G, + ) { elements.add(GroupedView(element, groups.toSet())) } - fun addAllElements(elements: Collection, vararg groups: G) { + fun addAllElements( + elements: Collection, + vararg groups: G, + ) { this.elements.addAll(elements.map { GroupedView(it, groups.toSet()) }) } @@ -47,9 +52,11 @@ class ButtonColumnHandler>(private val buttonSize: Float) val element = elements[currentIndex] val view = element.view if (view.isVisible) { - val lp = RelativeLayout.LayoutParams( - RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT - ) + val lp = + RelativeLayout.LayoutParams( + RelativeLayout.LayoutParams.WRAP_CONTENT, + RelativeLayout.LayoutParams.WRAP_CONTENT, + ) lp.width = pxFromSp(view.context, buttonSize).toInt() lp.height = pxFromSp(view.context, buttonSize).toInt() lp.addRule(RelativeLayout.ALIGN_PARENT_RIGHT, 1) @@ -63,6 +70,4 @@ class ButtonColumnHandler>(private val buttonSize: Float) } } } - - } diff --git a/app/src/main/java/org/blitzortung/android/app/controller/HistoryController.kt b/app/src/main/java/org/blitzortung/android/app/controller/HistoryController.kt index 7d704d7b5..c1e4646f7 100644 --- a/app/src/main/java/org/blitzortung/android/app/controller/HistoryController.kt +++ b/app/src/main/java/org/blitzortung/android/app/controller/HistoryController.kt @@ -30,9 +30,8 @@ import org.blitzortung.android.protocol.Event class HistoryController( private val binding: MainBinding, private val buttonHandler: ButtonColumnHandler, - private val dataHandler: MainDataHandler + private val dataHandler: MainDataHandler, ) { - private val buttons: MutableCollection = arrayListOf() private var animationRunning = false @@ -89,7 +88,10 @@ class HistoryController( } } - private fun addButtonWithOnClickAction(button: ImageButton, action: (View) -> Unit): ImageButton { + private fun addButtonWithOnClickAction( + button: ImageButton, + action: (View) -> Unit, + ): ImageButton { return button.apply { buttons.add(this) visibility = View.INVISIBLE diff --git a/app/src/main/java/org/blitzortung/android/app/controller/NotificationHandler.kt b/app/src/main/java/org/blitzortung/android/app/controller/NotificationHandler.kt index 8a2f3ad87..2fa695153 100644 --- a/app/src/main/java/org/blitzortung/android/app/controller/NotificationHandler.kt +++ b/app/src/main/java/org/blitzortung/android/app/controller/NotificationHandler.kt @@ -18,7 +18,6 @@ package org.blitzortung.android.app.controller - import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager @@ -28,92 +27,104 @@ import android.content.Context import android.content.Intent import android.os.Build import androidx.annotation.RequiresApi - +import javax.inject.Inject import org.blitzortung.android.app.Main import org.blitzortung.android.app.R import org.blitzortung.android.util.isAtLeast -import javax.inject.Inject - -open class NotificationHandler @Inject constructor( - private val context: Context, - private val notificationManager: NotificationManager -) { - init { - if (isAtLeast(Build.VERSION_CODES.O)) { - createNotificationChannel() +open class NotificationHandler + @Inject + constructor( + private val context: Context, + private val notificationManager: NotificationManager, + ) { + init { + if (isAtLeast(Build.VERSION_CODES.O)) { + createNotificationChannel() + } } - } - fun sendNotification(notificationText: String) { - val intent = Intent(context, Main::class.java).apply { - action = Intent.ACTION_MAIN - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + fun sendNotification(notificationText: String) { + val intent = + Intent(context, Main::class.java).apply { + action = Intent.ACTION_MAIN + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + val flags = + PendingIntent.FLAG_UPDATE_CURRENT or ( + if (isAtLeast(Build.VERSION_CODES.M)) { + PendingIntent.FLAG_IMMUTABLE + } else { + 0 + } + ) + val contentIntent = PendingIntent.getActivity(context, 0, intent, flags) + + val notification = + when { + isAtLeast(Build.VERSION_CODES.O) -> { + createNotification(contentIntent, notificationText) + } + + else -> { + createJellyBeanNotification(contentIntent, notificationText) + } + } + + notificationManager.notify(R.id.alarm_notification_id, notification) } - val flags = PendingIntent.FLAG_UPDATE_CURRENT or (if (isAtLeast(Build.VERSION_CODES.M)) { - PendingIntent.FLAG_IMMUTABLE - } else { - 0 - }) - val contentIntent = PendingIntent.getActivity(context, 0, intent, flags) - - val notification = when { - isAtLeast(Build.VERSION_CODES.O) -> { - createNotification(contentIntent, notificationText) - } - else -> { - createJellyBeanNotification(contentIntent, notificationText) - } + @RequiresApi(Build.VERSION_CODES.O) + private fun createNotification( + contentIntent: PendingIntent?, + notificationText: String, + ): Notification { + return Notification.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.icon) + .setContentTitle(context.resources.getText(R.string.app_name)) + .setContentText(notificationText) + .setContentIntent(contentIntent) + .setAutoCancel(true) + .setWhen(System.currentTimeMillis()) + .setShowWhen(true).build() } - notificationManager.notify(R.id.alarm_notification_id, notification) - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun createNotification(contentIntent: PendingIntent?, notificationText: String): Notification { - return Notification.Builder(context, CHANNEL_ID) - .setSmallIcon(R.drawable.icon) - .setContentTitle(context.resources.getText(R.string.app_name)) - .setContentText(notificationText) - .setContentIntent(contentIntent) - .setAutoCancel(true) - .setWhen(System.currentTimeMillis()) - .setShowWhen(true).build() - } + private fun createJellyBeanNotification( + contentIntent: PendingIntent?, + notificationText: String, + ): Notification { + val builder = + Notification.Builder(context) + .setSmallIcon(R.drawable.icon) + .setContentTitle(context.resources.getText(R.string.app_name)) + .setContentText(notificationText) + .setContentIntent(contentIntent) + .setAutoCancel(true) + + if (isAtLeast(Build.VERSION_CODES.JELLY_BEAN_MR1)) { + builder.setWhen(System.currentTimeMillis()) + .setShowWhen(true) + } - private fun createJellyBeanNotification(contentIntent: PendingIntent?, notificationText: String): Notification { - val builder = Notification.Builder(context) - .setSmallIcon(R.drawable.icon) - .setContentTitle(context.resources.getText(R.string.app_name)) - .setContentText(notificationText) - .setContentIntent(contentIntent) - .setAutoCancel(true) - - if (isAtLeast(Build.VERSION_CODES.JELLY_BEAN_MR1)) { - builder.setWhen(System.currentTimeMillis()) - .setShowWhen(true) + return builder.build() } - return builder.build() - } - - private fun createNotificationChannel() { - // Create the NotificationChannel, but only on API 26+ because - // the NotificationChannel class is new and not in the support library - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val name = context.getString(R.string.notification_channel_name) - val importance = NotificationManager.IMPORTANCE_DEFAULT - val channel = NotificationChannel(CHANNEL_ID, name, importance).apply { - setImportance(IMPORTANCE_LOW) + private fun createNotificationChannel() { + // Create the NotificationChannel, but only on API 26+ because + // the NotificationChannel class is new and not in the support library + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val name = context.getString(R.string.notification_channel_name) + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = + NotificationChannel(CHANNEL_ID, name, importance).apply { + setImportance(IMPORTANCE_LOW) + } + // Register the channel with the system + notificationManager.createNotificationChannel(channel) } - // Register the channel with the system - notificationManager.createNotificationChannel(channel) } - } - companion object { - const val CHANNEL_ID = "channel" + companion object { + const val CHANNEL_ID = "channel" + } } - -} diff --git a/app/src/main/java/org/blitzortung/android/app/helper/ViewHelper.kt b/app/src/main/java/org/blitzortung/android/app/helper/ViewHelper.kt index 8e395108e..08e7e1d02 100644 --- a/app/src/main/java/org/blitzortung/android/app/helper/ViewHelper.kt +++ b/app/src/main/java/org/blitzortung/android/app/helper/ViewHelper.kt @@ -21,13 +21,18 @@ package org.blitzortung.android.app.helper import android.content.Context object ViewHelper { - - fun pxFromSp(context: Context, sp: Float): Float { + fun pxFromSp( + context: Context, + sp: Float, + ): Float { val displayMetrics = context.resources.displayMetrics return sp * displayMetrics.scaledDensity } - fun pxFromDp(context: Context, dp: Float): Float { + fun pxFromDp( + context: Context, + dp: Float, + ): Float { val displayMetrics = context.resources.displayMetrics return dp * displayMetrics.density } diff --git a/app/src/main/java/org/blitzortung/android/app/permission/LocationProviderRelation.kt b/app/src/main/java/org/blitzortung/android/app/permission/LocationProviderRelation.kt index c0ebbdab6..e88a46276 100644 --- a/app/src/main/java/org/blitzortung/android/app/permission/LocationProviderRelation.kt +++ b/app/src/main/java/org/blitzortung/android/app/permission/LocationProviderRelation.kt @@ -3,7 +3,10 @@ package org.blitzortung.android.app.permission import android.location.LocationManager enum class LocationProviderRelation(val providerName: String) { - GPS(LocationManager.GPS_PROVIDER), PASSIVE(LocationManager.PASSIVE_PROVIDER), NETWORK(LocationManager.NETWORK_PROVIDER); + GPS(LocationManager.GPS_PROVIDER), + PASSIVE(LocationManager.PASSIVE_PROVIDER), + NETWORK(LocationManager.NETWORK_PROVIDER), + ; companion object { val byProviderName: Map = diff --git a/app/src/main/java/org/blitzortung/android/app/permission/PermissionsManager.kt b/app/src/main/java/org/blitzortung/android/app/permission/PermissionsManager.kt index 098e17a19..c7034599f 100644 --- a/app/src/main/java/org/blitzortung/android/app/permission/PermissionsManager.kt +++ b/app/src/main/java/org/blitzortung/android/app/permission/PermissionsManager.kt @@ -9,16 +9,19 @@ import androidx.appcompat.app.AlertDialog import org.blitzortung.android.app.Main.Companion.LOG_TAG class PermissionsSupport( - private val activity: Activity + private val activity: Activity, ) { - @RequiresApi(Build.VERSION_CODES.M) - fun request(permission: String, requestCode: Int, permissionRequiredStringId: Int): Boolean { + fun request( + permission: String, + requestCode: Int, + permissionRequiredStringId: Int, + ): Boolean { val showRationale = activity.shouldShowRequestPermissionRationale(permission) val isGranted = activity.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED Log.v( LOG_TAG, - "PermissionsSupport.request() permission: $permission, requestCode: $requestCode, isGranted: $isGranted, showRationale: $showRationale" + "PermissionsSupport.request() permission: $permission, requestCode: $requestCode, isGranted: $isGranted, showRationale: $showRationale", ) return if (!isGranted) { @@ -41,7 +44,7 @@ class PermissionsSupport( ) { Log.v( LOG_TAG, - "PermissionsSupport.requestPermissionsAfterDialog() permission: $permission, dialogResource: $dialogTextResource, requestCode: $requestCode" + "PermissionsSupport.requestPermissionsAfterDialog() permission: $permission, dialogResource: $dialogTextResource, requestCode: $requestCode", ) val message = activity.resources.getString(dialogTextResource) @@ -53,8 +56,10 @@ class PermissionsSupport( companion object { @RequiresApi(Build.VERSION_CODES.M) - fun ensure(activity: Activity, vararg permissionRequesters : PermissionRequester) { - + fun ensure( + activity: Activity, + vararg permissionRequesters: PermissionRequester, + ) { val permissionsSupport = PermissionsSupport(activity) for (permissionRequester in permissionRequesters) { @@ -70,6 +75,12 @@ class PermissionsSupport( interface PermissionRequester { val name: String + fun request(permissionsSupport: PermissionsSupport): Boolean - fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray): Boolean = false + + fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ): Boolean = false } diff --git a/app/src/main/java/org/blitzortung/android/app/permission/requester/BackgroundLocationPermissionRequester.kt b/app/src/main/java/org/blitzortung/android/app/permission/requester/BackgroundLocationPermissionRequester.kt index b86bdf5f7..463b171ad 100644 --- a/app/src/main/java/org/blitzortung/android/app/permission/requester/BackgroundLocationPermissionRequester.kt +++ b/app/src/main/java/org/blitzortung/android/app/permission/requester/BackgroundLocationPermissionRequester.kt @@ -9,6 +9,7 @@ import android.util.Log import androidx.annotation.RequiresApi import androidx.appcompat.app.AlertDialog import androidx.core.content.edit +import kotlin.text.toInt import org.blitzortung.android.app.Main.Companion.LOG_TAG import org.blitzortung.android.app.R import org.blitzortung.android.app.permission.PermissionRequester @@ -18,11 +19,10 @@ import org.blitzortung.android.app.view.PreferenceKey import org.blitzortung.android.app.view.get import org.blitzortung.android.app.view.put import org.blitzortung.android.util.isAtLeast -import kotlin.text.toInt class BackgroundLocationPermissionRequester( private val activity: Activity, - private val preferences: SharedPreferences + private val preferences: SharedPreferences, ) : PermissionRequester { override val name: String = "background location" @@ -40,7 +40,7 @@ class BackgroundLocationPermissionRequester( permissionsSupport.request( ACCESS_BACKGROUND_LOCATION, REQUEST_CODE_BACKGROUND_LOCATION, - R.string.location_permission_background_required + R.string.location_permission_background_required, ) }.setNegativeButton(android.R.string.cancel) { _, _ -> preferences.edit { put(PreferenceKey.BACKGROUND_QUERY_PERIOD, "0") } @@ -52,9 +52,10 @@ class BackgroundLocationPermissionRequester( } companion object { - internal fun isBackgroundAlertEnabled(preferences: SharedPreferences) : Boolean = + internal fun isBackgroundAlertEnabled(preferences: SharedPreferences): Boolean = preferences.get(PreferenceKey.BACKGROUND_QUERY_PERIOD, "0") .toInt() > 0 + const val REQUEST_CODE_BACKGROUND_LOCATION = 102 } } diff --git a/app/src/main/java/org/blitzortung/android/app/permission/requester/LocationPermissionRequester.kt b/app/src/main/java/org/blitzortung/android/app/permission/requester/LocationPermissionRequester.kt index 4c11bc60e..6311d4760 100644 --- a/app/src/main/java/org/blitzortung/android/app/permission/requester/LocationPermissionRequester.kt +++ b/app/src/main/java/org/blitzortung/android/app/permission/requester/LocationPermissionRequester.kt @@ -26,7 +26,7 @@ import org.blitzortung.android.location.provider.ManualLocationProvider class LocationPermissionRequester( private val activity: Activity, - private val preferences: SharedPreferences + private val preferences: SharedPreferences, ) : PermissionRequester { override val name: String = "location" @@ -36,14 +36,20 @@ class LocationPermissionRequester( return if (permission != null) { permissionsSupport.request( - permission, requestCode, R.string.location_permission_required + permission, + requestCode, + R.string.location_permission_required, ) } else { false } } - override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray): Boolean { + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ): Boolean { val locationProviderRelation = LocationProviderRelation.byOrdinal[requestCode] val alertEnabled = preferences.get(PreferenceKey.ALERT_ENABLED, false) return if (locationProviderRelation != null) { @@ -52,7 +58,7 @@ class LocationPermissionRequester( val previousValue = preferences.get(PreferenceKey.LOCATION_MODE, "n/a") Log.i( LOG_TAG, - "Main.onRequestPermissionResult() $providerName permission has been granted. (code $requestCode, previous: $previousValue)" + "Main.onRequestPermissionResult() $providerName permission has been granted. (code $requestCode, previous: $previousValue)", ) preferences.edit { put(PreferenceKey.LOCATION_MODE, providerName) @@ -60,7 +66,7 @@ class LocationPermissionRequester( } else { Log.i( LOG_TAG, - "Main.onRequestPermissionResult() $providerName permission was NOT granted. (code $requestCode)" + "Main.onRequestPermissionResult() $providerName permission was NOT granted. (code $requestCode)", ) preferences.edit { put(PreferenceKey.LOCATION_MODE, MANUAL_PROVIDER) @@ -79,15 +85,15 @@ class LocationPermissionRequester( } } - companion object { internal fun getLocationPermission(preferences: SharedPreferences): Pair { val locationProviderName = preferences.get(PreferenceKey.LOCATION_MODE, PASSIVE_PROVIDER) - val permission = when (locationProviderName) { - PASSIVE_PROVIDER, GPS_PROVIDER -> ACCESS_FINE_LOCATION - NETWORK_PROVIDER -> ACCESS_COARSE_LOCATION - else -> null - } + val permission = + when (locationProviderName) { + PASSIVE_PROVIDER, GPS_PROVIDER -> ACCESS_FINE_LOCATION + NETWORK_PROVIDER -> ACCESS_COARSE_LOCATION + else -> null + } val requestCode = (LocationProviderRelation.Companion.byProviderName[locationProviderName]?.ordinal ?: Int.MAX_VALUE) return Pair(permission, requestCode) diff --git a/app/src/main/java/org/blitzortung/android/app/permission/requester/NotificationPermissionRequester.kt b/app/src/main/java/org/blitzortung/android/app/permission/requester/NotificationPermissionRequester.kt index e1525bdc1..08253abf9 100644 --- a/app/src/main/java/org/blitzortung/android/app/permission/requester/NotificationPermissionRequester.kt +++ b/app/src/main/java/org/blitzortung/android/app/permission/requester/NotificationPermissionRequester.kt @@ -19,7 +19,7 @@ import org.blitzortung.android.app.view.put class NotificationPermissionRequester( private val activity: Activity, - val preferences: SharedPreferences + val preferences: SharedPreferences, ) : PermissionRequester { override val name: String = "notification" @@ -27,13 +27,21 @@ class NotificationPermissionRequester( val requiresNotifications = isAlertEnabled(preferences) || isBackgroundAlertEnabled(preferences) Log.v(LOG_TAG, "NotificationPermissionRequester.request() requiresNotifications: $requiresNotifications") return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && requiresNotifications) { - permissionsSupport.request(POST_NOTIFICATIONS, REQUEST_CODE_POST_NOTIFICATIONS, R.string.post_notifications_request) + permissionsSupport.request( + POST_NOTIFICATIONS, + REQUEST_CODE_POST_NOTIFICATIONS, + R.string.post_notifications_request, + ) } else { false } } - override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray): Boolean { + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ): Boolean { val alertEnabled = isAlertEnabled(preferences) val backgroundAlertEnabled = isBackgroundAlertEnabled(preferences) @@ -41,7 +49,7 @@ class NotificationPermissionRequester( if (grantResults.size == 1 && grantResults[0] == PackageManager.PERMISSION_DENIED && (alertEnabled || backgroundAlertEnabled)) { Log.i( LOG_TAG, - "Main.onRequestPermissionResult() POST_NOTIFICATIONS permission was NOT granted but is required for alerts. Disabling alerts and background queries" + "Main.onRequestPermissionResult() POST_NOTIFICATIONS permission was NOT granted but is required for alerts. Disabling alerts and background queries", ) Toast.makeText(activity, R.string.post_notifications_required_for_alerts, Toast.LENGTH_LONG).show() preferences.edit { @@ -56,10 +64,9 @@ class NotificationPermissionRequester( } companion object { - internal fun isAlertEnabled(preferences: SharedPreferences) : Boolean = + internal fun isAlertEnabled(preferences: SharedPreferences): Boolean = preferences.get(PreferenceKey.ALERT_ENABLED, false) const val REQUEST_CODE_POST_NOTIFICATIONS = 101 } - } diff --git a/app/src/main/java/org/blitzortung/android/app/permission/requester/WakeupPermissionRequester.kt b/app/src/main/java/org/blitzortung/android/app/permission/requester/WakeupPermissionRequester.kt index 05c066c52..abe38d91e 100644 --- a/app/src/main/java/org/blitzortung/android/app/permission/requester/WakeupPermissionRequester.kt +++ b/app/src/main/java/org/blitzortung/android/app/permission/requester/WakeupPermissionRequester.kt @@ -26,7 +26,7 @@ import org.blitzortung.android.app.view.PreferenceKey class WakeupPermissionRequester( private val activity: Activity, private val preferences: SharedPreferences, -): PermissionRequester { +) : PermissionRequester { override val name: String = "wakeup" @RequiresApi(Build.VERSION_CODES.M) @@ -43,20 +43,21 @@ class WakeupPermissionRequester( if (!pm.isIgnoringBatteryOptimizations(packageName)) { val locationText = activity.resources.getString(R.string.open_battery_optimiziation) - val dialogClickListener = DialogInterface.OnClickListener { _, which -> - when (which) { - DialogInterface.BUTTON_POSITIVE -> { - disableBatteryOptimisation(packageName) - } + val dialogClickListener = + DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + disableBatteryOptimisation(packageName) + } - DialogInterface.BUTTON_NEGATIVE -> { - preferences.edit { - putString(PreferenceKey.BACKGROUND_QUERY_PERIOD.toString(), 0.toString()) - apply() + DialogInterface.BUTTON_NEGATIVE -> { + preferences.edit { + putString(PreferenceKey.BACKGROUND_QUERY_PERIOD.toString(), 0.toString()) + apply() + } } } } - } AlertDialog.Builder(activity).setMessage(locationText) .setPositiveButton(android.R.string.ok, dialogClickListener) @@ -75,14 +76,15 @@ class WakeupPermissionRequester( Log.v(LOG_TAG, "requestWakeupPermissions() request ignore battery optimizations") val allowIgnoreBatteryOptimization = activity.checkSelfPermission(REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) == PackageManager.PERMISSION_GRANTED - val intent = if (allowIgnoreBatteryOptimization) { - Intent( - Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, - "package:$packageName".toUri() - ) - } else { - Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) - } + val intent = + if (allowIgnoreBatteryOptimization) { + Intent( + Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + "package:$packageName".toUri(), + ) + } else { + Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) + } try { activity.startActivity(intent) @@ -97,7 +99,7 @@ class WakeupPermissionRequester( Log.e( LOG_TAG, "requestWakeupPermissions() could not open battery optimization settings", - e + e, ) } } diff --git a/app/src/main/java/org/blitzortung/android/app/view/AlertView.kt b/app/src/main/java/org/blitzortung/android/app/view/AlertView.kt index 5a4591fbc..73c248b63 100644 --- a/app/src/main/java/org/blitzortung/android/app/view/AlertView.kt +++ b/app/src/main/java/org/blitzortung/android/app/view/AlertView.kt @@ -32,6 +32,10 @@ import android.util.AttributeSet import android.util.Log import androidx.core.graphics.createBitmap import androidx.preference.PreferenceManager +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sin import org.blitzortung.android.alert.AlertResult import org.blitzortung.android.alert.data.AlertSector import org.blitzortung.android.alert.event.AlertEvent @@ -46,311 +50,330 @@ import org.blitzortung.android.dialogs.AlertDialogColorHandler import org.blitzortung.android.location.LocationEvent import org.blitzortung.android.map.overlay.color.ColorHandler import org.blitzortung.android.util.TabletAwareView -import kotlin.math.cos -import kotlin.math.max -import kotlin.math.min -import kotlin.math.sin - -class AlertView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyle: Int = 0 -) : TabletAwareView(context, attrs, defStyle) { - private val arcArea = RectF() - private val background = Paint() - private val sectorPaint = Paint() - private val lines = Paint(Paint.ANTI_ALIAS_FLAG) - private val textStyle = Paint(Paint.ANTI_ALIAS_FLAG) - private val warnText = Paint(Paint.ANTI_ALIAS_FLAG) - private val transfer = Paint() - private val alarmNotAvailableTextLines: Array = context.getString(R.string.alarms_not_available) - .split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - private lateinit var colorHandler: ColorHandler - private var intervalDuration: Int = 0 - private var temporaryBitmap: Bitmap? = null - private var temporaryCanvas: Canvas? = null - private var alertResult: AlertResult? = null - private var location: Location? = null - private var enableDescriptionText = false - - val alertEventConsumer: (AlertEvent?) -> Unit = { event -> - val eventAlertResult = if (event is AlertResultEvent) { - event.alertResult - } else { - null - } - val updated = alertResult != eventAlertResult - if (updated) { - Log.v(Main.LOG_TAG, "AlertView alertEventConsumer received $event") - alertResult = eventAlertResult - invalidate() - } - } - - val locationEventConsumer: (LocationEvent) -> Unit = { locationEvent -> - val updated = location != locationEvent.location - if (updated) { - Log.v(Main.LOG_TAG, "AlertView received location ${locationEvent.location}") - location = locationEvent.location - val visibility = if (location != null) VISIBLE else INVISIBLE - setVisibility(visibility) - invalidate() - } - } - init { - with(lines) { - color = 0xff404040.toInt() - style = Style.STROKE +class AlertView + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, + ) : TabletAwareView(context, attrs, defStyle) { + private val arcArea = RectF() + private val background = Paint() + private val sectorPaint = Paint() + private val lines = Paint(Paint.ANTI_ALIAS_FLAG) + private val textStyle = Paint(Paint.ANTI_ALIAS_FLAG) + private val warnText = Paint(Paint.ANTI_ALIAS_FLAG) + private val transfer = Paint() + private val alarmNotAvailableTextLines: Array = + context.getString(R.string.alarms_not_available) + .split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + private lateinit var colorHandler: ColorHandler + private var intervalDuration: Int = 0 + private var temporaryBitmap: Bitmap? = null + private var temporaryCanvas: Canvas? = null + private var alertResult: AlertResult? = null + private var location: Location? = null + private var enableDescriptionText = false + + val alertEventConsumer: (AlertEvent?) -> Unit = { event -> + val eventAlertResult = + if (event is AlertResultEvent) { + event.alertResult + } else { + null + } + val updated = alertResult != eventAlertResult + if (updated) { + Log.v(Main.LOG_TAG, "AlertView alertEventConsumer received $event") + alertResult = eventAlertResult + invalidate() + } } - with(textStyle) { - color = 0xff404040.toInt() - textSize = 0.8f * this@AlertView.textSize * textSizeFactor(context) + val locationEventConsumer: (LocationEvent) -> Unit = { locationEvent -> + val updated = location != locationEvent.location + if (updated) { + Log.v(Main.LOG_TAG, "AlertView received location ${locationEvent.location}") + location = locationEvent.location + val visibility = if (location != null) VISIBLE else INVISIBLE + setVisibility(visibility) + invalidate() + } } - background.color = 0xffb0b0b0.toInt() - } - - fun enableLongClickListener(dataHandler: MainDataHandler, alertHandler: AlertHandler) { - - val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) + init { + with(lines) { + color = 0xff404040.toInt() + style = Style.STROKE + } - setOnLongClickListener { - AlertDialog(context, AlertDialogColorHandler(sharedPreferences), dataHandler, alertHandler) - .show() + with(textStyle) { + color = 0xff404040.toInt() + textSize = 0.8f * this@AlertView.textSize * textSizeFactor(context) + } - true + background.color = 0xffb0b0b0.toInt() } - } - fun enableDescriptionText() { - enableDescriptionText = true - } - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - val getSize = fun(spec: Int) = MeasureSpec.getSize(spec) - - val parentWidth = getSize(widthMeasureSpec) * sizeFactor - val parentHeight = getSize(heightMeasureSpec) * sizeFactor + fun enableLongClickListener( + dataHandler: MainDataHandler, + alertHandler: AlertHandler, + ) { + val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) - val size = min(parentWidth.toInt(), parentHeight.toInt()) + setOnLongClickListener { + AlertDialog(context, AlertDialogColorHandler(sharedPreferences), dataHandler, alertHandler) + .show() - super.onMeasure( - MeasureSpec.makeMeasureSpec(size, MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec(size, MeasureSpec.EXACTLY) - ) - } + true + } + } - override fun onDraw(canvas: Canvas) { - val size = max(width, height) - val pad = ViewHelper.pxFromDp(context, 5f) - - val center = size / 2.0f - val radius = center - pad - - prepareTemporaryBitmap(size) - - val alertResult = alertResult - val temporaryCanvas = temporaryCanvas - val temporaryBitmap = temporaryBitmap - if (temporaryBitmap != null && temporaryCanvas != null) { - if (alertResult != null && intervalDuration != 0) { - val alertParameters = alertResult.parameters - val rangeSteps = alertParameters.rangeSteps - val rangeStepCount = rangeSteps.size - val radiusIncrement = radius / rangeStepCount - val sectorWidth = (360 / alertParameters.sectorLabels.size).toFloat() - - with(lines) { - color = colorHandler.lineColor - strokeWidth = (size / 150).toFloat() - } + fun enableDescriptionText() { + enableDescriptionText = true + } - with(textStyle) { - textAlign = Align.CENTER - color = colorHandler.textColor - } + override fun onMeasure( + widthMeasureSpec: Int, + heightMeasureSpec: Int, + ) { + val getSize = fun(spec: Int) = MeasureSpec.getSize(spec) - val actualTime = System.currentTimeMillis() + val parentWidth = getSize(widthMeasureSpec) * sizeFactor + val parentHeight = getSize(heightMeasureSpec) * sizeFactor - for (alertSector in alertResult.sectors) { + val size = min(parentWidth.toInt(), parentHeight.toInt()) - val startAngle = alertSector.minimumSectorBearing + 90f + 180f + super.onMeasure( + MeasureSpec.makeMeasureSpec(size, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(size, MeasureSpec.EXACTLY), + ) + } - val ranges = alertSector.ranges - for (rangeIndex in ranges.indices.reversed()) { - val alertSectorRange = ranges[rangeIndex] + override fun onDraw(canvas: Canvas) { + val size = max(width, height) + val pad = ViewHelper.pxFromDp(context, 5f) + + val center = size / 2.0f + val radius = center - pad + + prepareTemporaryBitmap(size) + + val alertResult = alertResult + val temporaryCanvas = temporaryCanvas + val temporaryBitmap = temporaryBitmap + if (temporaryBitmap != null && temporaryCanvas != null) { + if (alertResult != null && intervalDuration != 0) { + val alertParameters = alertResult.parameters + val rangeSteps = alertParameters.rangeSteps + val rangeStepCount = rangeSteps.size + val radiusIncrement = radius / rangeStepCount + val sectorWidth = (360 / alertParameters.sectorLabels.size).toFloat() + + with(lines) { + color = colorHandler.lineColor + strokeWidth = (size / 150).toFloat() + } - val sectorRadius = (rangeIndex + 1) * radiusIncrement - val leftTop = center - sectorRadius - val bottomRight = center + sectorRadius + with(textStyle) { + textAlign = Align.CENTER + color = colorHandler.textColor + } - val drawColor = alertSectorRange.strikeCount > 0 - if (drawColor) { - val color = colorHandler.getColor( - actualTime, - alertSectorRange.latestStrikeTimestamp, - intervalDuration + val actualTime = System.currentTimeMillis() + + for (alertSector in alertResult.sectors) { + val startAngle = alertSector.minimumSectorBearing + 90f + 180f + + val ranges = alertSector.ranges + for (rangeIndex in ranges.indices.reversed()) { + val alertSectorRange = ranges[rangeIndex] + + val sectorRadius = (rangeIndex + 1) * radiusIncrement + val leftTop = center - sectorRadius + val bottomRight = center + sectorRadius + + val drawColor = alertSectorRange.strikeCount > 0 + if (drawColor) { + val color = + colorHandler.getColor( + actualTime, + alertSectorRange.latestStrikeTimestamp, + intervalDuration, + ) + sectorPaint.color = color + } + arcArea.set(leftTop, leftTop, bottomRight, bottomRight) + temporaryCanvas.drawArc( + arcArea, + startAngle, + sectorWidth, + true, + if (drawColor) sectorPaint else background, ) - sectorPaint.color = color } - arcArea.set(leftTop, leftTop, bottomRight, bottomRight) - temporaryCanvas.drawArc( - arcArea, - startAngle, - sectorWidth, - true, - if (drawColor) sectorPaint else background - ) } - } - - for (alertSector in alertResult.sectors) { - val bearing = alertSector.minimumSectorBearing.toDouble() - temporaryCanvas.drawLine( - center, - center, - center + (radius * sin(bearing / 180.0f * Math.PI)).toFloat(), - center + (radius * -cos(bearing / 180.0f * Math.PI)).toFloat(), - lines - ) - if (enableDescriptionText && size > TEXT_MINIMUM_SIZE) { - drawSectorLabel(center, radiusIncrement, alertSector, bearing + sectorWidth / 2.0) - } - } + for (alertSector in alertResult.sectors) { + val bearing = alertSector.minimumSectorBearing.toDouble() + temporaryCanvas.drawLine( + center, + center, + center + (radius * sin(bearing / 180.0f * Math.PI)).toFloat(), + center + (radius * -cos(bearing / 180.0f * Math.PI)).toFloat(), + lines, + ) - textStyle.textAlign = Align.RIGHT - val textHeight = textStyle.getFontMetrics(null) - for (radiusIndex in 0 until rangeStepCount) { - val leftTop = center - (radiusIndex + 1) * radiusIncrement - val bottomRight = center + (radiusIndex + 1) * radiusIncrement - arcArea.set(leftTop, leftTop, bottomRight, bottomRight) - if (radiusIndex == rangeStepCount - 1) { - lines.strokeWidth = (size / 80).toFloat() + if (enableDescriptionText && size > TEXT_MINIMUM_SIZE) { + drawSectorLabel(center, radiusIncrement, alertSector, bearing + sectorWidth / 2.0) + } } - temporaryCanvas.drawArc(arcArea, 0f, 360f, false, lines) - if (enableDescriptionText && size > TEXT_MINIMUM_SIZE) { - val text = "%.0f".format(rangeSteps[radiusIndex]) - temporaryCanvas.drawText( - text, - center + (radiusIndex + 0.85f) * radiusIncrement, - center + textHeight / 3f, - textStyle - ) + textStyle.textAlign = Align.RIGHT + val textHeight = textStyle.getFontMetrics(null) + for (radiusIndex in 0 until rangeStepCount) { + val leftTop = center - (radiusIndex + 1) * radiusIncrement + val bottomRight = center + (radiusIndex + 1) * radiusIncrement + arcArea.set(leftTop, leftTop, bottomRight, bottomRight) if (radiusIndex == rangeStepCount - 1) { - val distanceUnit = resources.getString(alertParameters.measurementSystem.unitNameString) + lines.strokeWidth = (size / 80).toFloat() + } + temporaryCanvas.drawArc(arcArea, 0f, 360f, false, lines) + + if (enableDescriptionText && size > TEXT_MINIMUM_SIZE) { + val text = "%.0f".format(rangeSteps[radiusIndex]) temporaryCanvas.drawText( - distanceUnit, + text, center + (radiusIndex + 0.85f) * radiusIncrement, - center + textHeight * 1.33f, - textStyle + center + textHeight / 3f, + textStyle, ) + if (radiusIndex == rangeStepCount - 1) { + val distanceUnit = resources.getString(alertParameters.measurementSystem.unitNameString) + temporaryCanvas.drawText( + distanceUnit, + center + (radiusIndex + 0.85f) * radiusIncrement, + center + textHeight * 1.33f, + textStyle, + ) + } } } - } - - } else { - if (enableDescriptionText && size > TEXT_MINIMUM_SIZE) { - drawAlertOrLocationMissingMessage(center, temporaryCanvas) } else { - if (location != null) { - drawOwnLocationSymbol(center, radius, size, temporaryCanvas) + if (enableDescriptionText && size > TEXT_MINIMUM_SIZE) { + drawAlertOrLocationMissingMessage(center, temporaryCanvas) + } else { + if (location != null) { + drawOwnLocationSymbol(center, radius, size, temporaryCanvas) + } } } + canvas.drawBitmap(temporaryBitmap, 0f, 0f, transfer) } - canvas.drawBitmap(temporaryBitmap, 0f, 0f, transfer) } - } - - private fun drawAlertOrLocationMissingMessage(center: Float, canvas: Canvas) { - with(warnText) { - color = resources.getColor(R.color.RedWarn) - textAlign = Align.CENTER - textSize = DEFAULT_FONT_SIZE.toFloat() - val maxWidth = alarmNotAvailableTextLines.maxOfOrNull { warnText.measureText(it) } - ?: (width.toFloat() - 20) - val scale = (width - 20).toFloat() / maxWidth + private fun drawAlertOrLocationMissingMessage( + center: Float, + canvas: Canvas, + ) { + with(warnText) { + color = resources.getColor(R.color.RedWarn) + textAlign = Align.CENTER + textSize = DEFAULT_FONT_SIZE.toFloat() + + val maxWidth = + alarmNotAvailableTextLines.maxOfOrNull { warnText.measureText(it) } + ?: (width.toFloat() - 20) + val scale = (width - 20).toFloat() / maxWidth + + // Now scale the text so we can use the whole width of the canvas + textSize = scale * DEFAULT_FONT_SIZE + } - //Now scale the text so we can use the whole width of the canvas - textSize = scale * DEFAULT_FONT_SIZE + for (line in alarmNotAvailableTextLines.indices) { + canvas.drawText( + alarmNotAvailableTextLines[line], + center, + center + (line - 1) * warnText.getFontMetrics(null), + warnText, + ) + } } + private fun drawOwnLocationSymbol( + center: Float, + radius: Float, + size: Int, + temporaryCanvas: Canvas, + ) { + with(lines) { + color = colorHandler.lineColor + strokeWidth = (size / 80).toFloat() + } - for (line in alarmNotAvailableTextLines.indices) { - canvas.drawText( - alarmNotAvailableTextLines[line], - center, - center + (line - 1) * warnText.getFontMetrics(null), - warnText - ) - } - } + val largeRadius = radius * 0.8f + val leftTop = center - largeRadius + val bottomRight = center + largeRadius + arcArea.set(leftTop, leftTop, bottomRight, bottomRight) + temporaryCanvas.drawArc(arcArea, 0f, 360f, false, lines) - private fun drawOwnLocationSymbol(center: Float, radius: Float, size: Int, temporaryCanvas: Canvas) { - with(lines) { - color = colorHandler.lineColor - strokeWidth = (size / 80).toFloat() + val smallRadius = radius * 0.6f + temporaryCanvas.drawLine(center - smallRadius, center, center + smallRadius, center, lines) + temporaryCanvas.drawLine(center, center - smallRadius, center, center + smallRadius, lines) } - val largeRadius = radius * 0.8f - val leftTop = center - largeRadius - val bottomRight = center + largeRadius - arcArea.set(leftTop, leftTop, bottomRight, bottomRight) - temporaryCanvas.drawArc(arcArea, 0f, 360f, false, lines) + private fun drawSectorLabel( + center: Float, + radiusIncrement: Float, + sector: AlertSector, + bearing: Double, + ) { + if (bearing != 90.0) { + val text = sector.label + val textRadius = (sector.ranges.size - 0.5f) * radiusIncrement + temporaryCanvas!!.drawText( + text, + center + (textRadius * sin(bearing / 180.0 * Math.PI)).toFloat(), + center + (textRadius * -cos(bearing / 180.0 * Math.PI)).toFloat() + textStyle.getFontMetrics(null) / 3f, + textStyle, + ) + } + } - val smallRadius = radius * 0.6f - temporaryCanvas.drawLine(center - smallRadius, center, center + smallRadius, center, lines) - temporaryCanvas.drawLine(center, center - smallRadius, center, center + smallRadius, lines) - } + private fun prepareTemporaryBitmap(size: Int) { + if (temporaryBitmap == null) { + val temporaryBitmap = createBitmap(size, size) + this.temporaryBitmap = temporaryBitmap + temporaryCanvas = Canvas(temporaryBitmap) + } + background.color = colorHandler.backgroundColor + background.xfermode = XFERMODE_CLEAR + temporaryCanvas!!.drawPaint(background) - private fun drawSectorLabel(center: Float, radiusIncrement: Float, sector: AlertSector, bearing: Double) { - if (bearing != 90.0) { - val text = sector.label - val textRadius = (sector.ranges.size - 0.5f) * radiusIncrement - temporaryCanvas!!.drawText( - text, - center + (textRadius * sin(bearing / 180.0 * Math.PI)).toFloat(), - center + (textRadius * -cos(bearing / 180.0 * Math.PI)).toFloat() + textStyle.getFontMetrics(null) / 3f, - textStyle - ) + background.xfermode = XFERMODE_SRC } - } - private fun prepareTemporaryBitmap(size: Int) { - if (temporaryBitmap == null) { - val temporaryBitmap = createBitmap(size, size) - this.temporaryBitmap = temporaryBitmap - temporaryCanvas = Canvas(temporaryBitmap) + fun setColorHandler( + colorHandler: ColorHandler, + intervalDuration: Int, + ) { + this.colorHandler = colorHandler + this.intervalDuration = intervalDuration } - background.color = colorHandler.backgroundColor - background.xfermode = XFERMODE_CLEAR - temporaryCanvas!!.drawPaint(background) - background.xfermode = XFERMODE_SRC - } - - fun setColorHandler(colorHandler: ColorHandler, intervalDuration: Int) { - this.colorHandler = colorHandler - this.intervalDuration = intervalDuration - } - - override fun setBackgroundColor(backgroundColor: Int) { - background.color = backgroundColor - } + override fun setBackgroundColor(backgroundColor: Int) { + background.color = backgroundColor + } - fun setAlpha(alpha: Int) { - transfer.alpha = alpha - } + fun setAlpha(alpha: Int) { + transfer.alpha = alpha + } - companion object { - private const val TEXT_MINIMUM_SIZE = 300 - private const val DEFAULT_FONT_SIZE = 20 - private val XFERMODE_CLEAR = PorterDuffXfermode(PorterDuff.Mode.CLEAR) - private val XFERMODE_SRC = PorterDuffXfermode(PorterDuff.Mode.SRC) + companion object { + private const val TEXT_MINIMUM_SIZE = 300 + private const val DEFAULT_FONT_SIZE = 20 + private val XFERMODE_CLEAR = PorterDuffXfermode(PorterDuff.Mode.CLEAR) + private val XFERMODE_SRC = PorterDuffXfermode(PorterDuff.Mode.SRC) + } } - -} diff --git a/app/src/main/java/org/blitzortung/android/app/view/HistogramView.kt b/app/src/main/java/org/blitzortung/android/app/view/HistogramView.kt index d9e19f349..c743643b7 100644 --- a/app/src/main/java/org/blitzortung/android/app/view/HistogramView.kt +++ b/app/src/main/java/org/blitzortung/android/app/view/HistogramView.kt @@ -34,158 +34,169 @@ import org.blitzortung.android.util.TabletAwareView private const val SMALL_TEXT_SCALE = 0.7f -class HistogramView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyle: Int = 0 -) : TabletAwareView(context, attrs, defStyle) { - - private val backgroundPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG) - private val foregroundPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG) - private val textPaint: Paint - private val defaultForegroundColor: Int - private val backgroundRect: RectF - private var strikesOverlay: StrikeListOverlay? = null - private var histogram: IntArray? = null - lateinit var mapFragment: MapFragment - - val dataConsumer = { event: Event -> - if (event is ResultEvent) { - updateHistogram(event) +class HistogramView + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, + ) : TabletAwareView(context, attrs, defStyle) { + private val backgroundPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val foregroundPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val textPaint: Paint + private val defaultForegroundColor: Int + private val backgroundRect: RectF + private var strikesOverlay: StrikeListOverlay? = null + private var histogram: IntArray? = null + lateinit var mapFragment: MapFragment + + val dataConsumer = { event: Event -> + if (event is ResultEvent) { + updateHistogram(event) + } } - } - init { - backgroundPaint.color = 0x00b0b0b0 + init { + backgroundPaint.color = 0x00b0b0b0 - defaultForegroundColor = context.resources.getColor(R.color.text_foreground) - - textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = defaultForegroundColor - textSize = this@HistogramView.textSize - textAlign = Paint.Align.RIGHT - } + defaultForegroundColor = context.resources.getColor(R.color.text_foreground) - foregroundPaint.strokeWidth = 5f + textPaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = defaultForegroundColor + textSize = this@HistogramView.textSize + textAlign = Paint.Align.RIGHT + } - backgroundRect = RectF() - } + foregroundPaint.strokeWidth = 5f - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - val getSize = fun(spec: Int) = MeasureSpec.getSize(spec) + backgroundRect = RectF() + } - val parentWidth = getSize(widthMeasureSpec) * sizeFactor - val parentHeight = getSize(heightMeasureSpec) * sizeFactor + override fun onMeasure( + widthMeasureSpec: Int, + heightMeasureSpec: Int, + ) { + val getSize = fun(spec: Int) = MeasureSpec.getSize(spec) - super.onMeasure( - MeasureSpec.makeMeasureSpec(parentWidth.toInt(), MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec(parentHeight.toInt(), MeasureSpec.EXACTLY) - ) - } + val parentWidth = getSize(widthMeasureSpec) * sizeFactor + val parentHeight = getSize(heightMeasureSpec) * sizeFactor - override fun onDraw(canvas: Canvas) { - val strikesOverlay = strikesOverlay - val histogram = histogram - if (strikesOverlay != null && histogram != null && histogram.isNotEmpty()) { - val colorHandler = strikesOverlay.colorHandler - val minutesPerColor = strikesOverlay.parameters.intervalDuration / colorHandler.numberOfColors - val minutesPerBin = 5 - val ratio = minutesPerColor / minutesPerBin - if (ratio == 0) { - return - } - - backgroundRect.set(0f, 0f, width.toFloat(), height.toFloat()) - canvas.drawRect(backgroundRect, backgroundPaint) + super.onMeasure( + MeasureSpec.makeMeasureSpec(parentWidth.toInt(), MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(parentHeight.toInt(), MeasureSpec.EXACTLY), + ) + } - var topCoordinate = padding + override fun onDraw(canvas: Canvas) { + val strikesOverlay = strikesOverlay + val histogram = histogram + if (strikesOverlay != null && histogram != null && histogram.isNotEmpty()) { + val colorHandler = strikesOverlay.colorHandler + val minutesPerColor = strikesOverlay.parameters.intervalDuration / colorHandler.numberOfColors + val minutesPerBin = 5 + val ratio = minutesPerColor / minutesPerBin + if (ratio == 0) { + return + } + + backgroundRect.set(0f, 0f, width.toFloat(), height.toFloat()) + canvas.drawRect(backgroundRect, backgroundPaint) + + var topCoordinate = padding + + val maximumCount = histogram.maxOrNull() ?: 0 + + canvas.drawText( + "%.1f/%s _".format( + maximumCount.toFloat() / minutesPerBin, + resources.getString(R.string.unit_minute), + ), + width - 2 * padding, + topCoordinate + textSize / 1.2f, + textPaint, + ) - val maximumCount = histogram.maxOrNull() ?: 0 + topCoordinate += textSize - canvas.drawText( - "%.1f/%s _".format( - maximumCount.toFloat() / minutesPerBin, resources.getString(R.string.unit_minute) - ), width - 2 * padding, topCoordinate + textSize / 1.2f, textPaint - ) + val ymax = if (maximumCount == 0) 1 else maximumCount - topCoordinate += textSize + val x0 = padding + val xd = (width - 2 * padding) / (histogram.size - 1) - val ymax = if (maximumCount == 0) 1 else maximumCount + val y0 = height - padding + val yd = (height - topCoordinate - padding) / ymax - val x0 = padding - val xd = (width - 2 * padding) / (histogram.size - 1) + foregroundPaint.strokeWidth = 5f + for (i in 0 until histogram.size - 1) { + foregroundPaint.color = colorHandler.getColor((histogram.size - 1 - i) / ratio) + canvas.drawLine( + x0 + xd * i, + y0 - yd * histogram[i], + x0 + xd * (i + 1), + y0 - yd * histogram[i + 1], + foregroundPaint, + ) + } - val y0 = height - padding - val yd = (height - topCoordinate - padding) / ymax + foregroundPaint.strokeWidth = 3f + foregroundPaint.color = defaultForegroundColor - foregroundPaint.strokeWidth = 5f - for (i in 0 until histogram.size - 1) { - foregroundPaint.color = colorHandler.getColor((histogram.size - 1 - i) / ratio) - canvas.drawLine( - x0 + xd * i, - y0 - yd * histogram[i], - x0 + xd * (i + 1), - y0 - yd * histogram[i + 1], - foregroundPaint - ) + canvas.drawLine(padding, height - padding, width - padding, height - padding, foregroundPaint) + canvas.drawLine(width - padding, padding, width - padding, height - padding, foregroundPaint) } - - foregroundPaint.strokeWidth = 3f - foregroundPaint.color = defaultForegroundColor - - canvas.drawLine(padding, height - padding, width - padding, height - padding, foregroundPaint) - canvas.drawLine(width - padding, padding, width - padding, height - padding, foregroundPaint) } - } - fun setStrikesOverlay(strikesOverlay: StrikeListOverlay) { - this.strikesOverlay = strikesOverlay - } + fun setStrikesOverlay(strikesOverlay: StrikeListOverlay) { + this.strikesOverlay = strikesOverlay + } - private fun updateHistogram(dataEvent: ResultEvent) { - if (dataEvent.failed) { - visibility = INVISIBLE - histogram = null - } else { - val histogram = dataEvent.histogram + private fun updateHistogram(dataEvent: ResultEvent) { + if (dataEvent.failed) { + visibility = INVISIBLE + histogram = null + } else { + val histogram = dataEvent.histogram - val hasHistogram = histogram != null && histogram.isNotEmpty() + val hasHistogram = histogram != null && histogram.isNotEmpty() - this.histogram = if (hasHistogram) { - histogram - } else { - createHistogram(dataEvent) - } + this.histogram = + if (hasHistogram) { + histogram + } else { + createHistogram(dataEvent) + } - visibility = if (hasHistogram) VISIBLE else INVISIBLE + visibility = if (hasHistogram) VISIBLE else INVISIBLE - if (hasHistogram) { - invalidate() + if (hasHistogram) { + invalidate() + } } } - } - - private fun createHistogram(result: ResultEvent): IntArray { - result.parameters.let { parameters -> - if (result.strikes == null) { - return intArrayOf() - } - - Log.v(LOG_TAG, "HistogramView create histogram from ${result.strikes.size} total strikes") - val referenceTime = result.referenceTime - - val binInterval = 5 - val binCount = parameters.intervalDuration / binInterval - val histogram = IntArray(binCount) - result.strikes.forEach { strike -> - val binIndex = (binCount - 1) - ((referenceTime - strike.timestamp) / 1000 / 60 / binInterval).toInt() - if (binIndex in 0 until binCount) - histogram[binIndex]++ + private fun createHistogram(result: ResultEvent): IntArray { + result.parameters.let { parameters -> + if (result.strikes == null) { + return intArrayOf() + } + + Log.v(LOG_TAG, "HistogramView create histogram from ${result.strikes.size} total strikes") + val referenceTime = result.referenceTime + + val binInterval = 5 + val binCount = parameters.intervalDuration / binInterval + val histogram = IntArray(binCount) + + result.strikes.forEach { strike -> + val binIndex = (binCount - 1) - ((referenceTime - strike.timestamp) / 1000 / 60 / binInterval).toInt() + if (binIndex in 0 until binCount) { + histogram[binIndex]++ + } + } + return histogram } - return histogram } } -} data class Strike(val timestamp: Long) diff --git a/app/src/main/java/org/blitzortung/android/app/view/LegendView.kt b/app/src/main/java/org/blitzortung/android/app/view/LegendView.kt index 208abd124..90d4b612d 100644 --- a/app/src/main/java/org/blitzortung/android/app/view/LegendView.kt +++ b/app/src/main/java/org/blitzortung/android/app/view/LegendView.kt @@ -24,221 +24,247 @@ import android.graphics.Color import android.graphics.Paint import android.graphics.RectF import android.util.AttributeSet +import kotlin.math.max +import kotlin.math.min import org.blitzortung.android.app.R import org.blitzortung.android.map.overlay.StrikeListOverlay import org.blitzortung.android.util.TabletAwareView -import kotlin.math.max -import kotlin.math.min - -class LegendView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyle: Int = 0 -) : TabletAwareView(context, attrs, defStyle) { - private val colorFieldSize: Float = textSize - private val textPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = -1 - textSize = this@LegendView.textSize - } - private val gridTextPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = -1 - textSize = this@LegendView.textSize * GRID_HEIGHT - textAlign = Paint.Align.CENTER - } - private val regionTextPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = -1 - textSize = this@LegendView.textSize * REGION_HEIGHT - textAlign = Paint.Align.CENTER - } - private val countThresholdTextPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = -1 - textSize = this@LegendView.textSize * COUNT_THRESHOLD_HEIGHT - textAlign = Paint.Align.CENTER - } - private val backgroundPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = context.resources.getColor(R.color.translucent_background) - } - private val foregroundPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG) - private val backgroundRect: RectF = RectF() - private val legendColorRect: RectF = RectF() - var strikesOverlay: StrikeListOverlay? = null - - init { - setBackgroundColor(Color.TRANSPARENT) - } - private fun determineWidth(intervalDuration: Int): Float { - val numberOfColors = strikesOverlay?.colorHandler?.numberOfColors ?: 1 - var innerWidth = colorFieldSize + padding + textPaint.measureText( - LEGEND_FORMAT.format( - '<', - intervalDuration / numberOfColors * (numberOfColors - 1), - context.resources.getString(R.string.unit_minute) - ) - ) +class LegendView + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, + ) : TabletAwareView(context, attrs, defStyle) { + private val colorFieldSize: Float = textSize + private val textPaint: Paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = -1 + textSize = this@LegendView.textSize + } + private val gridTextPaint: Paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = -1 + textSize = this@LegendView.textSize * GRID_HEIGHT + textAlign = Paint.Align.CENTER + } + private val regionTextPaint: Paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = -1 + textSize = this@LegendView.textSize * REGION_HEIGHT + textAlign = Paint.Align.CENTER + } + private val countThresholdTextPaint: Paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = -1 + textSize = this@LegendView.textSize * COUNT_THRESHOLD_HEIGHT + textAlign = Paint.Align.CENTER + } + private val backgroundPaint: Paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = context.resources.getColor(R.color.translucent_background) + } + private val foregroundPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val backgroundRect: RectF = RectF() + private val legendColorRect: RectF = RectF() + var strikesOverlay: StrikeListOverlay? = null - if (hasRegion()) { - innerWidth = max(innerWidth, regionTextPaint.measureText(regionName)) + init { + setBackgroundColor(Color.TRANSPARENT) } - return padding + innerWidth + padding - } + private fun determineWidth(intervalDuration: Int): Float { + val numberOfColors = strikesOverlay?.colorHandler?.numberOfColors ?: 1 + var innerWidth = + colorFieldSize + padding + + textPaint.measureText( + LEGEND_FORMAT.format( + '<', + intervalDuration / numberOfColors * (numberOfColors - 1), + context.resources.getString(R.string.unit_minute), + ), + ) - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - val getSize = fun(spec: Int) = MeasureSpec.getSize(spec) + if (hasRegion()) { + innerWidth = max(innerWidth, regionTextPaint.measureText(regionName)) + } - val parentWidth = getSize(widthMeasureSpec) - val parentHeight = getSize(heightMeasureSpec) + return padding + innerWidth + padding + } - val width = min( - determineWidth( - strikesOverlay?.parameters?.intervalDuration - ?: 0 - ), parentWidth.toFloat() - ) + override fun onMeasure( + widthMeasureSpec: Int, + heightMeasureSpec: Int, + ) { + val getSize = fun(spec: Int) = MeasureSpec.getSize(spec) + + val parentWidth = getSize(widthMeasureSpec) + val parentHeight = getSize(heightMeasureSpec) + + val width = + min( + determineWidth( + strikesOverlay?.parameters?.intervalDuration + ?: 0, + ), + parentWidth.toFloat(), + ) - val colorHandler = strikesOverlay?.colorHandler + val colorHandler = strikesOverlay?.colorHandler - var height = 0.0f - if (colorHandler != null) { - height = min((colorFieldSize + padding) * colorHandler.colors.size + padding, parentHeight.toFloat()) + var height = 0.0f + if (colorHandler != null) { + height = min((colorFieldSize + padding) * colorHandler.colors.size + padding, parentHeight.toFloat()) - if (hasRegion()) { - height += colorFieldSize * REGION_HEIGHT + padding - } + if (hasRegion()) { + height += colorFieldSize * REGION_HEIGHT + padding + } - if (usesGrid()) { - height += colorFieldSize * GRID_HEIGHT + padding + if (usesGrid()) { + height += colorFieldSize * GRID_HEIGHT + padding - if (hasCountThreshold()) { - height += colorFieldSize * COUNT_THRESHOLD_HEIGHT + padding + if (hasCountThreshold()) { + height += colorFieldSize * COUNT_THRESHOLD_HEIGHT + padding + } } } - } - - super.onMeasure( - MeasureSpec.makeMeasureSpec(width.toInt(), MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec(height.toInt(), MeasureSpec.EXACTLY) - ) - } - override fun onDraw(canvas: Canvas) { - strikesOverlay?.let { strikesOverlay -> - val colorHandler = strikesOverlay.colorHandler - val minutesPerColor = strikesOverlay.parameters.intervalDuration / colorHandler.numberOfColors + super.onMeasure( + MeasureSpec.makeMeasureSpec(width.toInt(), MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(height.toInt(), MeasureSpec.EXACTLY), + ) + } - backgroundRect.set(0f, 0f, width.toFloat(), height.toFloat()) - canvas.drawRect(backgroundRect, backgroundPaint) + override fun onDraw(canvas: Canvas) { + strikesOverlay?.let { strikesOverlay -> + val colorHandler = strikesOverlay.colorHandler + val minutesPerColor = strikesOverlay.parameters.intervalDuration / colorHandler.numberOfColors - val numberOfColors = colorHandler.numberOfColors + backgroundRect.set(0f, 0f, width.toFloat(), height.toFloat()) + canvas.drawRect(backgroundRect, backgroundPaint) - var topCoordinate = padding + val numberOfColors = colorHandler.numberOfColors - for (index in 0 until numberOfColors) { - foregroundPaint.color = colorHandler.getColor(index) - legendColorRect.set(padding, topCoordinate, padding + colorFieldSize, topCoordinate + colorFieldSize) - canvas.drawRect(legendColorRect, foregroundPaint) + var topCoordinate = padding - val isLastValue = index == numberOfColors - 1 - val minuteUnit = context.getString(R.string.unit_minute) - val text = LEGEND_FORMAT.format( - if (isLastValue) '>' else '<', - (index + (if (isLastValue) 0 else 1)) * minutesPerColor, - minuteUnit - ) + for (index in 0 until numberOfColors) { + foregroundPaint.color = colorHandler.getColor(index) + legendColorRect.set( + padding, + topCoordinate, + padding + colorFieldSize, + topCoordinate + colorFieldSize, + ) + canvas.drawRect(legendColorRect, foregroundPaint) - canvas.drawText(text, 2 * padding + colorFieldSize, topCoordinate + colorFieldSize / 1.1f, textPaint) + val isLastValue = index == numberOfColors - 1 + val minuteUnit = context.getString(R.string.unit_minute) + val text = + LEGEND_FORMAT.format( + if (isLastValue) '>' else '<', + (index + (if (isLastValue) 0 else 1)) * minutesPerColor, + minuteUnit, + ) - topCoordinate += colorFieldSize + padding - } + canvas.drawText( + text, + 2 * padding + colorFieldSize, + topCoordinate + colorFieldSize / 1.1f, + textPaint, + ) - if (hasRegion()) { - canvas.drawText( - regionName, - width / 2.0f, - topCoordinate + colorFieldSize * REGION_HEIGHT / 1.1f, - regionTextPaint - ) - topCoordinate += colorFieldSize * REGION_HEIGHT + padding - } + topCoordinate += colorFieldSize + padding + } - if (usesGrid()) { - canvas.drawText( - context.getString(R.string.legend_grid) + ": " + gridInfo, - width / 2.0f, - topCoordinate + colorFieldSize * GRID_HEIGHT / 1.1f, - gridTextPaint - ) - topCoordinate += colorFieldSize * GRID_HEIGHT + padding + if (hasRegion()) { + canvas.drawText( + regionName, + width / 2.0f, + topCoordinate + colorFieldSize * REGION_HEIGHT / 1.1f, + regionTextPaint, + ) + topCoordinate += colorFieldSize * REGION_HEIGHT + padding + } - if (hasCountThreshold()) { - val countThreshold = strikesOverlay.parameters.countThreshold + if (usesGrid()) { canvas.drawText( - "# > $countThreshold", + context.getString(R.string.legend_grid) + ": " + gridInfo, width / 2.0f, - topCoordinate + colorFieldSize * COUNT_THRESHOLD_HEIGHT / 1.1f, - countThresholdTextPaint + topCoordinate + colorFieldSize * GRID_HEIGHT / 1.1f, + gridTextPaint, ) - topCoordinate += colorFieldSize * COUNT_THRESHOLD_HEIGHT + padding + topCoordinate += colorFieldSize * GRID_HEIGHT + padding + + if (hasCountThreshold()) { + val countThreshold = strikesOverlay.parameters.countThreshold + canvas.drawText( + "# > $countThreshold", + width / 2.0f, + topCoordinate + colorFieldSize * COUNT_THRESHOLD_HEIGHT / 1.1f, + countThresholdTextPaint, + ) + topCoordinate += colorFieldSize * COUNT_THRESHOLD_HEIGHT + padding + } } } } - } - private val regionName: String - get() { + private val regionName: String + get() { - val strikesOverlay = strikesOverlay + val strikesOverlay = strikesOverlay - if (strikesOverlay != null) { - val regionNumber = strikesOverlay.parameters.region - val dataArea = strikesOverlay.parameters.dataArea + if (strikesOverlay != null) { + val regionNumber = strikesOverlay.parameters.region + val dataArea = strikesOverlay.parameters.dataArea - for ((index, regionNumberString) in resources.getStringArray(R.array.regions_values).withIndex()) { - if (regionNumber == Integer.parseInt(regionNumberString)) { - val detail = if (regionNumber < 0 && dataArea != null) { - " [${dataArea.scale}]" - } else { - "" + for ((index, regionNumberString) in resources.getStringArray(R.array.regions_values).withIndex()) { + if (regionNumber == Integer.parseInt(regionNumberString)) { + val detail = + if (regionNumber < 0 && dataArea != null) { + " [${dataArea.scale}]" + } else { + "" + } + return resources.getStringArray(R.array.regions)[index] + detail } - return resources.getStringArray(R.array.regions)[index] + detail } } + + return resources.getString(R.string.not_available) } - return resources.getString(R.string.not_available) + fun setAlpha(alpha: Int) { + foregroundPaint.alpha = alpha } - fun setAlpha(alpha: Int) { - foregroundPaint.alpha = alpha - } - - private fun usesGrid(): Boolean { - return strikesOverlay?.usesGrid() == true - } + private fun usesGrid(): Boolean { + return strikesOverlay?.usesGrid() == true + } - private fun hasRegion(): Boolean { - return (strikesOverlay?.parameters?.region ?: 0) != 0 - } + private fun hasRegion(): Boolean { + return (strikesOverlay?.parameters?.region ?: 0) != 0 + } - private val gridInfo: String - get() { - val gridSize = strikesOverlay?.gridParameters?.size - return if (gridSize != null) { - "%.0f %s".format(gridSize / 1000.0, context.getString(R.string.unit_km)) - } else { - context.getString(R.string.not_available) + private val gridInfo: String + get() { + val gridSize = strikesOverlay?.gridParameters?.size + return if (gridSize != null) { + "%.0f %s".format(gridSize / 1000.0, context.getString(R.string.unit_km)) + } else { + context.getString(R.string.not_available) + } } - } - private fun hasCountThreshold(): Boolean { - return (strikesOverlay?.parameters?.countThreshold ?: 0) > 0 - } + private fun hasCountThreshold(): Boolean { + return (strikesOverlay?.parameters?.countThreshold ?: 0) > 0 + } - companion object { - const val REGION_HEIGHT = 1.1f - const val GRID_HEIGHT = 0.8f - const val COUNT_THRESHOLD_HEIGHT = 0.8f - const val LEGEND_FORMAT = "%c %d %s" + companion object { + const val REGION_HEIGHT = 1.1f + const val GRID_HEIGHT = 0.8f + const val COUNT_THRESHOLD_HEIGHT = 0.8f + const val LEGEND_FORMAT = "%c %d %s" + } } -} diff --git a/app/src/main/java/org/blitzortung/android/app/view/MessageListPreference.kt b/app/src/main/java/org/blitzortung/android/app/view/MessageListPreference.kt index e9b654b78..e27b0bbf0 100644 --- a/app/src/main/java/org/blitzortung/android/app/view/MessageListPreference.kt +++ b/app/src/main/java/org/blitzortung/android/app/view/MessageListPreference.kt @@ -5,12 +5,13 @@ import android.util.AttributeSet import androidx.preference.ListPreference import org.blitzortung.android.app.R // Make sure this R import is correct for your project -class MessageListPreference @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null -) : ListPreference(context, attrs) { - - init { - dialogLayoutResource = R.layout.custom_list_preference_dialog_layout +class MessageListPreference + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + ) : ListPreference(context, attrs) { + init { + dialogLayoutResource = R.layout.custom_list_preference_dialog_layout + } } -} diff --git a/app/src/main/java/org/blitzortung/android/app/view/PreferenceKey.kt b/app/src/main/java/org/blitzortung/android/app/view/PreferenceKey.kt index 480dd44df..65ba21880 100644 --- a/app/src/main/java/org/blitzortung/android/app/view/PreferenceKey.kt +++ b/app/src/main/java/org/blitzortung/android/app/view/PreferenceKey.kt @@ -55,14 +55,14 @@ enum class PreferenceKey(val key: String) { ANIMATION_INTERVAL_DURATION("animation_interval_duration"), ANIMATION_SLEEP_DURATION("animation_sleep_duration"), ANIMATION_CYCLE_SLEEP_DURATION("animation_cycle_sleep_duration"), - DIAGNOSIS_ENABLED("diagnosis_enabled"); + DIAGNOSIS_ENABLED("diagnosis_enabled"), + ; override fun toString(): String { return key } companion object { - private val stringToValueMap = HashMap() init { @@ -85,19 +85,23 @@ enum class PreferenceKey(val key: String) { } } -//Helper function to retrieve a preference value of a PreferenceKey -internal inline fun SharedPreferences.get(prefKey: PreferenceKey, default: T): T { +// Helper function to retrieve a preference value of a PreferenceKey +internal inline fun SharedPreferences.get( + prefKey: PreferenceKey, + default: T, +): T { val key = prefKey.toString() - //Set is not possible because of type erasure, so for Set we still need to use the old way - val value: Any = when (default) { - is Long -> getLong(key, default) - is Int -> getInt(key, default) - is Boolean -> this.getBoolean(key, default) - is String -> getString(key, default) ?: default - is Float -> getFloat(key, default) - else -> throw IllegalArgumentException("Type ${T::class} cannot be retrieved from a SharedPreference") - } + // Set is not possible because of type erasure, so for Set we still need to use the old way + val value: Any = + when (default) { + is Long -> getLong(key, default) + is Int -> getInt(key, default) + is Boolean -> this.getBoolean(key, default) + is String -> getString(key, default) ?: default + is Float -> getFloat(key, default) + else -> throw IllegalArgumentException("Type ${T::class} cannot be retrieved from a SharedPreference") + } return value as T } @@ -105,7 +109,7 @@ internal inline fun SharedPreferences.get(prefKey: PreferenceKey, de internal inline fun SharedPreferences.getAndConvert( prefKey: PreferenceKey, default: T, - convert: (T) -> V + convert: (T) -> V, ): V { val value = this.get(prefKey, default) return convert(value) @@ -114,7 +118,10 @@ internal inline fun SharedPreferences.getAndConvert( /** * A generic extension function to set a SharedPreference-Value */ -internal inline fun SharedPreferences.Editor.put(key: String, value: T) { +internal inline fun SharedPreferences.Editor.put( + key: String, + value: T, +) { when (value) { is String -> putString(key, value) is Int -> putInt(key, value) @@ -125,22 +132,33 @@ internal inline fun SharedPreferences.Editor.put(key: String, value: } } -internal inline fun SharedPreferences.Editor.put(key: PreferenceKey, value: T) { +internal inline fun SharedPreferences.Editor.put( + key: PreferenceKey, + value: T, +) { val keyString = key.key put(keyString, value) } interface OnSharedPreferenceChangeListener : SharedPreferences.OnSharedPreferenceChangeListener { - fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, vararg keys: PreferenceKey) { + fun onSharedPreferenceChanged( + sharedPreferences: SharedPreferences, + vararg keys: PreferenceKey, + ) { keys.forEach { onSharedPreferenceChanged(sharedPreferences, it) } } - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, keyString: String?) { + override fun onSharedPreferenceChanged( + sharedPreferences: SharedPreferences, + keyString: String?, + ) { val key = PreferenceKey.fromString(keyString.orEmpty()) key?.also { onSharedPreferenceChanged(sharedPreferences, it) } } - fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: PreferenceKey) - + fun onSharedPreferenceChanged( + sharedPreferences: SharedPreferences, + key: PreferenceKey, + ) } diff --git a/app/src/main/java/org/blitzortung/android/app/view/RegionView.kt b/app/src/main/java/org/blitzortung/android/app/view/RegionView.kt index fe0ca1938..60a3d97bf 100644 --- a/app/src/main/java/org/blitzortung/android/app/view/RegionView.kt +++ b/app/src/main/java/org/blitzortung/android/app/view/RegionView.kt @@ -19,208 +19,213 @@ import org.osmdroid.util.BoundingBox private const val SMALL_TEXT_SCALE = 0.6f -class RegionView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyle: Int = 0 -) : TabletAwareView(context, attrs, defStyle), MapListener, OnSharedPreferenceChangeListener { - - private val backgroundPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG) - private val foregroundPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG) - private val textPaint: Paint - private val defaultForegroundColor: Int - private val backgroundRect: RectF - private var mapArea: BoundingBox? = null - private var zoomLevel: Double? = null - - private var gridParameters: GridParameters? = null - - val dataConsumer = { event: Event -> - if (event is ResultEvent) { - updateHistogram(event) +class RegionView + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, + ) : TabletAwareView(context, attrs, defStyle), MapListener, OnSharedPreferenceChangeListener { + private val backgroundPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val foregroundPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val textPaint: Paint + private val defaultForegroundColor: Int + private val backgroundRect: RectF + private var mapArea: BoundingBox? = null + private var zoomLevel: Double? = null + + private var gridParameters: GridParameters? = null + + val dataConsumer = { event: Event -> + if (event is ResultEvent) { + updateHistogram(event) + } } - } - init { - backgroundPaint.color = 0x00b0b0b0 + init { + backgroundPaint.color = 0x00b0b0b0 - defaultForegroundColor = context.resources.getColor(R.color.text_foreground) + defaultForegroundColor = context.resources.getColor(R.color.text_foreground) - textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = defaultForegroundColor - textSize = this@RegionView.textSize * SMALL_TEXT_SCALE - textAlign = Paint.Align.RIGHT - } + textPaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = defaultForegroundColor + textSize = this@RegionView.textSize * SMALL_TEXT_SCALE + textAlign = Paint.Align.RIGHT + } - foregroundPaint.strokeWidth = 5f + foregroundPaint.strokeWidth = 5f - backgroundRect = RectF() + backgroundRect = RectF() - val preferences = PreferenceManager.getDefaultSharedPreferences(context) - preferences.registerOnSharedPreferenceChangeListener(this) - onSharedPreferenceChanged(preferences, PreferenceKey.DIAGNOSIS_ENABLED) - } + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + preferences.registerOnSharedPreferenceChangeListener(this) + onSharedPreferenceChanged(preferences, PreferenceKey.DIAGNOSIS_ENABLED) + } - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - val getSize = fun(spec: Int) = MeasureSpec.getSize(spec) + override fun onMeasure( + widthMeasureSpec: Int, + heightMeasureSpec: Int, + ) { + val getSize = fun(spec: Int) = MeasureSpec.getSize(spec) - val parentWidth = getSize(widthMeasureSpec) * sizeFactor - val parentHeight = getSize(heightMeasureSpec) * sizeFactor + val parentWidth = getSize(widthMeasureSpec) * sizeFactor + val parentHeight = getSize(heightMeasureSpec) * sizeFactor - super.onMeasure( - MeasureSpec.makeMeasureSpec(parentWidth.toInt(), MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec(parentHeight.toInt(), MeasureSpec.EXACTLY) - ) - } + super.onMeasure( + MeasureSpec.makeMeasureSpec(parentWidth.toInt(), MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(parentHeight.toInt(), MeasureSpec.EXACTLY), + ) + } - override fun onDraw(canvas: Canvas) { - - backgroundRect.set(0f, 0f, width.toFloat(), height.toFloat()) - canvas.drawRect(backgroundRect, backgroundPaint) - - - var topCoordinate = 2 * padding - - gridParameters?.let { - if (!it.isGlobal) { - val text = "%.1f..%.1f %.1f..%.1f".format( - it.longitudeStart, - it.longitudeEnd, - it.latitudeEnd, - it.latitudeStart - ) - canvas.drawText( - text, - width - 2 * padding, - topCoordinate + textSize / 1.2f * SMALL_TEXT_SCALE, - textPaint - ) - topCoordinate += (textSize + padding) * SMALL_TEXT_SCALE - - val xdelta = it.longitudeEnd - it.longitudeStart - val ydelta = it.latitudeStart - it.latitudeEnd - val text2 = "%.1f %.1f".format( - xdelta, - ydelta, - ) - canvas.drawText( - text2, - width - 2 * padding, - topCoordinate + textSize / 1.2f * SMALL_TEXT_SCALE, - textPaint - ) - topCoordinate += (textSize + padding) * SMALL_TEXT_SCALE - - val x0 = it.longitudeStart - val y0 = it.latitudeStart - - val xs = xdelta / (width - 2 * padding) - val ys = ydelta / (height - 2 * padding) - - mapArea?.let { - val x1 = padding + ((it.lonEast - x0) / xs).toFloat() - val x2 = padding + ((it.lonWest - x0) / xs).toFloat() - val y1 = padding + ((y0 - it.latNorth) / ys).toFloat() - val y2 = padding + ((y0 - it.latSouth) / ys).toFloat() - - foregroundPaint.strokeWidth = 1f - drawBox(canvas, x1, y1, x2, y2, foregroundPaint) + override fun onDraw(canvas: Canvas) { + backgroundRect.set(0f, 0f, width.toFloat(), height.toFloat()) + canvas.drawRect(backgroundRect, backgroundPaint) + + var topCoordinate = 2 * padding + + gridParameters?.let { + if (!it.isGlobal) { + val text = + "%.1f..%.1f %.1f..%.1f".format( + it.longitudeStart, + it.longitudeEnd, + it.latitudeEnd, + it.latitudeStart, + ) + canvas.drawText( + text, + width - 2 * padding, + topCoordinate + textSize / 1.2f * SMALL_TEXT_SCALE, + textPaint, + ) + topCoordinate += (textSize + padding) * SMALL_TEXT_SCALE + + val xdelta = it.longitudeEnd - it.longitudeStart + val ydelta = it.latitudeStart - it.latitudeEnd + val text2 = + "%.1f %.1f".format( + xdelta, + ydelta, + ) + canvas.drawText( + text2, + width - 2 * padding, + topCoordinate + textSize / 1.2f * SMALL_TEXT_SCALE, + textPaint, + ) + topCoordinate += (textSize + padding) * SMALL_TEXT_SCALE + + val x0 = it.longitudeStart + val y0 = it.latitudeStart + + val xs = xdelta / (width - 2 * padding) + val ys = ydelta / (height - 2 * padding) + + mapArea?.let { + val x1 = padding + ((it.lonEast - x0) / xs).toFloat() + val x2 = padding + ((it.lonWest - x0) / xs).toFloat() + val y1 = padding + ((y0 - it.latNorth) / ys).toFloat() + val y2 = padding + ((y0 - it.latSouth) / ys).toFloat() + + foregroundPaint.strokeWidth = 1f + drawBox(canvas, x1, y1, x2, y2, foregroundPaint) + } + topCoordinate += textSize + + foregroundPaint.strokeWidth = 3f + foregroundPaint.color = defaultForegroundColor + drawBox(canvas, padding, padding, width - padding, height - padding, foregroundPaint) } - topCoordinate += textSize - - foregroundPaint.strokeWidth = 3f - foregroundPaint.color = defaultForegroundColor - drawBox(canvas, padding, padding, width - padding, height - padding, foregroundPaint) - } - zoomLevel?.let { - val text = "Zoom %.1f".format(it) - canvas.drawText( - text, - width - 2 * padding, - height - 2 * padding, - textPaint - ) + zoomLevel?.let { + val text = "Zoom %.1f".format(it) + canvas.drawText( + text, + width - 2 * padding, + height - 2 * padding, + textPaint, + ) + } } } - } - - private fun drawBox( - canvas: Canvas, - x1: Float, - y1: Float, - x2: Float, - y2: Float, - paint: Paint - ) { - canvas.drawLine(x1, y2, x2, y2, paint) - canvas.drawLine(x1, y1, x2, y1, paint) - canvas.drawLine(x2, y1, x2, y2, paint) - canvas.drawLine(x1, y1, x1, y2, paint) - } + private fun drawBox( + canvas: Canvas, + x1: Float, + y1: Float, + x2: Float, + y2: Float, + paint: Paint, + ) { + canvas.drawLine(x1, y2, x2, y2, paint) + canvas.drawLine(x1, y1, x2, y1, paint) + canvas.drawLine(x2, y1, x2, y2, paint) + canvas.drawLine(x1, y1, x1, y2, paint) + } - private fun updateHistogram(dataEvent: ResultEvent) { - if (dataEvent.failed) { - visibility = INVISIBLE - gridParameters = null - } else { - gridParameters = dataEvent.gridParameters + private fun updateHistogram(dataEvent: ResultEvent) { + if (dataEvent.failed) { + visibility = INVISIBLE + gridParameters = null + } else { + gridParameters = dataEvent.gridParameters - invalidate() + invalidate() + } } - } - private fun isVisible(gridParameters: GridParameters?) = gridParameters != null && !gridParameters.isGlobal - override fun onScroll(event: ScrollEvent?): Boolean { - return if (event != null) { - this.mapArea = event.source.boundingBox - zoomLevel = event.source.zoomLevelDouble - updateViewSize() - true - } else { - false + private fun isVisible(gridParameters: GridParameters?) = gridParameters != null && !gridParameters.isGlobal + + override fun onScroll(event: ScrollEvent?): Boolean { + return if (event != null) { + this.mapArea = event.source.boundingBox + zoomLevel = event.source.zoomLevelDouble + updateViewSize() + true + } else { + false + } } - } - override fun onZoom(event: ZoomEvent?): Boolean { - return if (event != null) { - mapArea = event.source.boundingBox - zoomLevel = event.zoomLevel - updateViewSize() - true - } else { - false + override fun onZoom(event: ZoomEvent?): Boolean { + return if (event != null) { + mapArea = event.source.boundingBox + zoomLevel = event.zoomLevel + updateViewSize() + true + } else { + false + } } - } - private fun updateViewSize() { - mapArea?.let { - val lonDelta = it.lonEast - it.lonWest - val pixelSize = lonDelta / width - val latDelta = it.latNorth - it.latSouth - val height = Math.max((latDelta / pixelSize).toInt(), (3 * padding + 3 * textSize).toInt()) - layoutParams.height = height - invalidate() + private fun updateViewSize() { + mapArea?.let { + val lonDelta = it.lonEast - it.lonWest + val pixelSize = lonDelta / width + val latDelta = it.latNorth - it.latSouth + val height = Math.max((latDelta / pixelSize).toInt(), (3 * padding + 3 * textSize).toInt()) + layoutParams.height = height + invalidate() + } } - } - override fun onSharedPreferenceChanged( - sharedPreferences: SharedPreferences, - key: PreferenceKey - ) { - when (key) { - PreferenceKey.DIAGNOSIS_ENABLED -> { - val diagnosisEnabled = sharedPreferences.get(key, false) - visibility = if (diagnosisEnabled) { - VISIBLE - } else { - INVISIBLE + override fun onSharedPreferenceChanged( + sharedPreferences: SharedPreferences, + key: PreferenceKey, + ) { + when (key) { + PreferenceKey.DIAGNOSIS_ENABLED -> { + val diagnosisEnabled = sharedPreferences.get(key, false) + visibility = + if (diagnosisEnabled) { + VISIBLE + } else { + INVISIBLE + } } - } - else -> {} + else -> {} + } } } - -} \ No newline at end of file diff --git a/app/src/main/java/org/blitzortung/android/app/view/ScaledTextView.kt b/app/src/main/java/org/blitzortung/android/app/view/ScaledTextView.kt index 95b696db5..76d5d0856 100644 --- a/app/src/main/java/org/blitzortung/android/app/view/ScaledTextView.kt +++ b/app/src/main/java/org/blitzortung/android/app/view/ScaledTextView.kt @@ -2,13 +2,12 @@ package org.blitzortung.android.app.view import android.content.Context import android.util.AttributeSet +import androidx.core.content.withStyledAttributes import org.blitzortung.android.app.R import org.blitzortung.android.util.TabletAwareView -import androidx.core.content.withStyledAttributes class ScaledTextView(context: Context, attrs: AttributeSet?) : androidx.appcompat.widget.AppCompatTextView(context, attrs) { - init { context.withStyledAttributes(attrs, R.styleable.View, 0, 0) { diff --git a/app/src/main/java/org/blitzortung/android/app/view/components/StatusComponent.kt b/app/src/main/java/org/blitzortung/android/app/view/components/StatusComponent.kt index e2e0e9759..61d8d2849 100644 --- a/app/src/main/java/org/blitzortung/android/app/view/components/StatusComponent.kt +++ b/app/src/main/java/org/blitzortung/android/app/view/components/StatusComponent.kt @@ -33,9 +33,8 @@ class StatusComponent( private val status: TextView, private val progressBar: ProgressBar, private val errorIndicator: ImageView, - resources: Resources + resources: Resources, ) : AlertLabel { - private val alertLabelHandler: AlertLabelHandler val alertEventConsumer: (AlertEvent) -> Unit @@ -48,10 +47,11 @@ class StatusComponent( alertEventConsumer = { event -> alertLabelHandler.apply( - if (event is AlertResultEvent) + if (event is AlertResultEvent) { event.alertResult - else + } else { null + }, ) } } diff --git a/app/src/main/java/org/blitzortung/android/app/viewmodel/MainViewModel.kt b/app/src/main/java/org/blitzortung/android/app/viewmodel/MainViewModel.kt new file mode 100644 index 000000000..ef4778aa2 --- /dev/null +++ b/app/src/main/java/org/blitzortung/android/app/viewmodel/MainViewModel.kt @@ -0,0 +1,180 @@ +/* + + Copyright 2015 Andreas Würl + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ + +package org.blitzortung.android.app.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.blitzortung.android.alert.AlertRepository +import org.blitzortung.android.alert.event.AlertEvent +import org.blitzortung.android.data.Parameters +import org.blitzortung.android.data.provider.result.DataEvent +import org.blitzortung.android.data.provider.result.ResultEvent +import org.blitzortung.android.data.repository.StrikeDataRepository +import org.blitzortung.android.location.LocationEvent +import org.blitzortung.android.location.LocationRepository + +/** + * MainViewModel manages UI state and business logic for the Main Activity. + * This ViewModel provides reactive data streams using StateFlow and coordinates + * between the StrikeDataRepository, LocationRepository, and AlertRepository. + */ +class MainViewModel + @Inject + constructor( + private val strikeDataRepository: StrikeDataRepository, + private val locationRepository: LocationRepository, + private val alertRepository: AlertRepository, + ) : ViewModel() { + // UI State + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _hasError = MutableStateFlow(false) + val hasError: StateFlow = _hasError.asStateFlow() + + private val _clearDataRequested = MutableStateFlow(false) + val clearDataRequested: StateFlow = _clearDataRequested.asStateFlow() + + // Current result state + private val _currentResult = MutableStateFlow(null) + val currentResult: StateFlow = _currentResult.asStateFlow() + + // Data events flow + val dataEvents: StateFlow = + strikeDataRepository + .observeDataEvents() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = null, + ) + + // Location events flow + val locationEvents: StateFlow = + locationRepository + .observeLocationEvents() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = null, + ) + + // Alert events flow + val alertEvents: StateFlow = + alertRepository + .observeAlertEvents() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = null, + ) + + init { + observeDataEvents() + } + + private fun observeDataEvents() { + viewModelScope.launch { + dataEvents.collect { event -> + when (event) { + is org.blitzortung.android.data.provider.result.RequestStartedEvent -> { + _isLoading.value = true + } + is ResultEvent -> { + _isLoading.value = false + _hasError.value = event.failed + if (!event.failed) { + _currentResult.value = event + } + } + else -> { + _isLoading.value = false + } + } + } + } + } + + // Strike Data Operations + fun updateData() { + strikeDataRepository.updateData() + } + + fun getParameters(): Parameters = strikeDataRepository.getParameters() + + fun getIntervalDuration(): Int = strikeDataRepository.getIntervalDuration() + + fun isRealtime(): Boolean = strikeDataRepository.isRealtime() + + fun goRealtime(): Boolean = strikeDataRepository.goRealtime() + + fun setPosition(position: Int): Boolean = strikeDataRepository.setPosition(position) + + fun historySteps(): Int = strikeDataRepository.historySteps() + + fun start() { + strikeDataRepository.start() + } + + fun stop() { + strikeDataRepository.stop() + } + + fun restart() { + strikeDataRepository.restart() + } + + fun startAnimation() { + strikeDataRepository.startAnimation() + } + + fun toggleExtendedMode() { + strikeDataRepository.toggleExtendedMode() + } + + // Location Operations + fun enableBackgroundLocation() { + locationRepository.enableBackgroundMode() + } + + fun disableBackgroundLocation() { + locationRepository.disableBackgroundMode() + } + + // UI State Operations + fun requestClearData() { + _clearDataRequested.value = true + } + + fun clearDataCompleted() { + _clearDataRequested.value = false + } + + override fun onCleared() { + super.onCleared() + stop() + } + } diff --git a/app/src/main/java/org/blitzortung/android/app/viewmodel/MapViewModel.kt b/app/src/main/java/org/blitzortung/android/app/viewmodel/MapViewModel.kt new file mode 100644 index 000000000..9da2f1b9b --- /dev/null +++ b/app/src/main/java/org/blitzortung/android/app/viewmodel/MapViewModel.kt @@ -0,0 +1,86 @@ +/* + + Copyright 2015 Andreas Würl + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ + +package org.blitzortung.android.app.viewmodel + +import androidx.lifecycle.ViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.osmdroid.util.GeoPoint + +/** + * MapViewModel manages map state and configuration. + * Handles zoom level, center position, and map type preferences. + */ +class MapViewModel + @Inject + constructor() : ViewModel() { + // Map state + private val _zoomLevel = MutableStateFlow(3.0) + val zoomLevel: StateFlow = _zoomLevel.asStateFlow() + + private val _centerPosition = MutableStateFlow(null) + val centerPosition: StateFlow = _centerPosition.asStateFlow() + + private val _mapType = MutableStateFlow("OpenStreetMap") + val mapType: StateFlow = _mapType.asStateFlow() + + private val _isMapReady = MutableStateFlow(false) + val isMapReady: StateFlow = _isMapReady.asStateFlow() + + /** + * Update zoom level + */ + fun updateZoomLevel(zoom: Double) { + _zoomLevel.value = zoom + } + + /** + * Update center position + */ + fun updateCenterPosition(position: GeoPoint) { + _centerPosition.value = position + } + + /** + * Update map type + */ + fun updateMapType(type: String) { + _mapType.value = type + } + + /** + * Mark map as ready + */ + fun setMapReady(ready: Boolean) { + _isMapReady.value = ready + } + + /** + * Save current map state for restoration + */ + fun saveMapState( + zoom: Double, + center: GeoPoint, + ) { + _zoomLevel.value = zoom + _centerPosition.value = center + } + } diff --git a/app/src/main/java/org/blitzortung/android/app/viewmodel/SettingsViewModel.kt b/app/src/main/java/org/blitzortung/android/app/viewmodel/SettingsViewModel.kt new file mode 100644 index 000000000..7c839b1c9 --- /dev/null +++ b/app/src/main/java/org/blitzortung/android/app/viewmodel/SettingsViewModel.kt @@ -0,0 +1,131 @@ +/* + + Copyright 2015 Andreas Würl + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ + +package org.blitzortung.android.app.viewmodel + +import android.content.SharedPreferences +import androidx.lifecycle.ViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.blitzortung.android.app.view.PreferenceKey + +/** + * SettingsViewModel manages application settings and preferences. + * Provides reactive access to preference changes and validation. + */ +class SettingsViewModel + @Inject + constructor( + private val preferences: SharedPreferences, + ) : ViewModel() { + // Preference change notification + private val _preferenceChanged = MutableStateFlow(null) + val preferenceChanged: StateFlow = _preferenceChanged.asStateFlow() + + private val preferenceChangeListener = + SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + key?.let { + try { + val preferenceKey = PreferenceKey.valueOf(it) + _preferenceChanged.value = preferenceKey + } catch (e: IllegalArgumentException) { + // Ignore unknown preference keys + } + } + } + + init { + preferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener) + } + + /** + * Get string preference value + */ + fun getStringPreference( + key: PreferenceKey, + defaultValue: String, + ): String = preferences.getString(key.key, defaultValue) ?: defaultValue + + /** + * Get int preference value + */ + fun getIntPreference( + key: PreferenceKey, + defaultValue: Int, + ): Int = preferences.getInt(key.key, defaultValue) + + /** + * Get boolean preference value + */ + fun getBooleanPreference( + key: PreferenceKey, + defaultValue: Boolean, + ): Boolean = preferences.getBoolean(key.key, defaultValue) + + /** + * Get float preference value + */ + fun getFloatPreference( + key: PreferenceKey, + defaultValue: Float, + ): Float = preferences.getFloat(key.key, defaultValue) + + /** + * Set string preference value + */ + fun setStringPreference( + key: PreferenceKey, + value: String, + ) { + preferences.edit().putString(key.key, value).apply() + } + + /** + * Set int preference value + */ + fun setIntPreference( + key: PreferenceKey, + value: Int, + ) { + preferences.edit().putInt(key.key, value).apply() + } + + /** + * Set boolean preference value + */ + fun setBooleanPreference( + key: PreferenceKey, + value: Boolean, + ) { + preferences.edit().putBoolean(key.key, value).apply() + } + + /** + * Clear preference change notification + */ + fun clearPreferenceChange() { + _preferenceChanged.value = null + } + + override fun onCleared() { + super.onCleared() + preferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) + } + } diff --git a/app/src/main/java/org/blitzortung/android/app/viewmodel/ViewModelFactory.kt b/app/src/main/java/org/blitzortung/android/app/viewmodel/ViewModelFactory.kt new file mode 100644 index 000000000..ebf154584 --- /dev/null +++ b/app/src/main/java/org/blitzortung/android/app/viewmodel/ViewModelFactory.kt @@ -0,0 +1,50 @@ +/* + + Copyright 2015 Andreas Würl + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ + +package org.blitzortung.android.app.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +/** + * ViewModelFactory that uses Dagger to inject dependencies into ViewModels. + * This factory is injected with a map of ViewModel providers from Dagger. + */ +@Singleton +class ViewModelFactory + @Inject + constructor( + private val viewModels: MutableMap, Provider>, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + val creator = + viewModels[modelClass] + ?: viewModels.entries.firstOrNull { modelClass.isAssignableFrom(it.key) }?.value + ?: throw IllegalArgumentException("Unknown ViewModel class: $modelClass") + + return try { + creator.get() as T + } catch (e: Exception) { + throw RuntimeException("Failed to create ViewModel: $modelClass", e) + } + } + } diff --git a/app/src/main/java/org/blitzortung/android/dagger/component/AppComponent.kt b/app/src/main/java/org/blitzortung/android/dagger/component/AppComponent.kt index 69676b7d8..415fdfb4f 100644 --- a/app/src/main/java/org/blitzortung/android/dagger/component/AppComponent.kt +++ b/app/src/main/java/org/blitzortung/android/dagger/component/AppComponent.kt @@ -4,12 +4,13 @@ import dagger.Component import dagger.android.AndroidInjectionModule import dagger.android.AndroidInjector import dagger.android.support.AndroidSupportInjectionModule +import javax.inject.Singleton import org.blitzortung.android.app.BOApplication import org.blitzortung.android.dagger.module.ActivityBindingModule import org.blitzortung.android.dagger.module.AppModule import org.blitzortung.android.dagger.module.ServiceModule import org.blitzortung.android.dagger.module.SettingsModule -import javax.inject.Singleton +import org.blitzortung.android.dagger.module.ViewModelModule @Singleton @Component( @@ -19,8 +20,8 @@ import javax.inject.Singleton AppModule::class, ServiceModule::class, SettingsModule::class, - ActivityBindingModule::class - ] + ActivityBindingModule::class, + ViewModelModule::class, + ], ) interface AppComponent : AndroidInjector - diff --git a/app/src/main/java/org/blitzortung/android/dagger/module/ActivityBindingModule.kt b/app/src/main/java/org/blitzortung/android/dagger/module/ActivityBindingModule.kt index 164dd15af..dcaebeabb 100644 --- a/app/src/main/java/org/blitzortung/android/dagger/module/ActivityBindingModule.kt +++ b/app/src/main/java/org/blitzortung/android/dagger/module/ActivityBindingModule.kt @@ -7,10 +7,9 @@ import org.blitzortung.android.app.Main @Module interface ActivityBindingModule { - @ContributesAndroidInjector fun contributeMainActivityInjector(): Main @ContributesAndroidInjector fun contributesAppServiceInjector(): AppService -} \ No newline at end of file +} diff --git a/app/src/main/java/org/blitzortung/android/dagger/module/AppModule.kt b/app/src/main/java/org/blitzortung/android/dagger/module/AppModule.kt index d071520c5..b83b53d2b 100644 --- a/app/src/main/java/org/blitzortung/android/dagger/module/AppModule.kt +++ b/app/src/main/java/org/blitzortung/android/dagger/module/AppModule.kt @@ -10,42 +10,43 @@ import android.os.Vibrator import androidx.preference.PreferenceManager import dagger.Module import dagger.Provides -import org.blitzortung.android.app.BOApplication import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton +import org.blitzortung.android.app.BOApplication @Module -class AppModule @Inject constructor( - private val application: Application -) { - - @Provides - fun provideContext(): Context = application - - @Provides - fun provideSharedPrefs(): SharedPreferences = PreferenceManager.getDefaultSharedPreferences(application) - - @Provides - fun wakeLock(): PowerManager.WakeLock = - (application.getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, - BOApplication.WAKE_LOCK_TAG - ) - - @Provides - @Singleton - fun packageInfo(): PackageInfo = application.packageManager.getPackageInfo(application.packageName, 0) - - @Provides - @Named("agentSuffix") - @Singleton - fun agentSuffix(packageInfo: PackageInfo): String = "-${packageInfo.versionCode}" - - @Provides - fun notificationManager(): NotificationManager = - application.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - @Provides - fun provideVibrator(): Vibrator = application.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator -} \ No newline at end of file +class AppModule + @Inject + constructor( + private val application: Application, + ) { + @Provides + fun provideContext(): Context = application + + @Provides + fun provideSharedPrefs(): SharedPreferences = PreferenceManager.getDefaultSharedPreferences(application) + + @Provides + fun wakeLock(): PowerManager.WakeLock = + (application.getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + BOApplication.WAKE_LOCK_TAG, + ) + + @Provides + @Singleton + fun packageInfo(): PackageInfo = application.packageManager.getPackageInfo(application.packageName, 0) + + @Provides + @Named("agentSuffix") + @Singleton + fun agentSuffix(packageInfo: PackageInfo): String = "-${packageInfo.versionCode}" + + @Provides + fun notificationManager(): NotificationManager = + application.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + @Provides + fun provideVibrator(): Vibrator = application.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + } diff --git a/app/src/main/java/org/blitzortung/android/dagger/module/ServiceModule.kt b/app/src/main/java/org/blitzortung/android/dagger/module/ServiceModule.kt index 78d33f7d0..b73d16c5f 100644 --- a/app/src/main/java/org/blitzortung/android/dagger/module/ServiceModule.kt +++ b/app/src/main/java/org/blitzortung/android/dagger/module/ServiceModule.kt @@ -3,25 +3,27 @@ package org.blitzortung.android.dagger.module import android.app.AlarmManager import android.content.Context import android.os.Handler +import android.os.Looper import dagger.Module import dagger.Provides -import org.blitzortung.android.util.Period import javax.inject.Inject import javax.inject.Singleton +import org.blitzortung.android.util.Period @Module -class ServiceModule @Inject constructor( -) { - @Provides - @Singleton - fun provideHandler(): Handler = Handler() +class ServiceModule + @Inject + constructor() { + @Provides + @Singleton + fun provideHandler(): Handler = Handler(Looper.getMainLooper()) - @Provides - @Singleton - fun providePeriod(): Period = Period() + @Provides + @Singleton + fun providePeriod(): Period = Period() - @Provides - @Singleton - fun provideAlarmManager(context: Context): AlarmManager = - context.getSystemService(Context.ALARM_SERVICE) as AlarmManager -} \ No newline at end of file + @Provides + @Singleton + fun provideAlarmManager(context: Context): AlarmManager = + context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + } diff --git a/app/src/main/java/org/blitzortung/android/dagger/module/SettingsModule.kt b/app/src/main/java/org/blitzortung/android/dagger/module/SettingsModule.kt index 21621f1ca..d96ccd0a7 100644 --- a/app/src/main/java/org/blitzortung/android/dagger/module/SettingsModule.kt +++ b/app/src/main/java/org/blitzortung/android/dagger/module/SettingsModule.kt @@ -8,4 +8,4 @@ import org.blitzortung.android.settings.SettingsFragment interface SettingsModule { @ContributesAndroidInjector fun contributeSettingsFragment(): SettingsFragment -} \ No newline at end of file +} diff --git a/app/src/main/java/org/blitzortung/android/dagger/module/ViewModelModule.kt b/app/src/main/java/org/blitzortung/android/dagger/module/ViewModelModule.kt new file mode 100644 index 000000000..897503a61 --- /dev/null +++ b/app/src/main/java/org/blitzortung/android/dagger/module/ViewModelModule.kt @@ -0,0 +1,39 @@ +package org.blitzortung.android.dagger.module + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Binds +import dagger.MapKey +import dagger.Module +import dagger.multibindings.IntoMap +import org.blitzortung.android.app.viewmodel.MainViewModel +import org.blitzortung.android.app.viewmodel.MapViewModel +import org.blitzortung.android.app.viewmodel.SettingsViewModel +import org.blitzortung.android.app.viewmodel.ViewModelFactory +import kotlin.reflect.KClass + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +@MapKey +annotation class ViewModelKey(val value: KClass) + +@Module +abstract class ViewModelModule { + @Binds + abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory + + @Binds + @IntoMap + @ViewModelKey(MainViewModel::class) + abstract fun bindMainViewModel(viewModel: MainViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(MapViewModel::class) + abstract fun bindMapViewModel(viewModel: MapViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(SettingsViewModel::class) + abstract fun bindSettingsViewModel(viewModel: SettingsViewModel): ViewModel +} diff --git a/app/src/main/java/org/blitzortung/android/data/Coordsys.kt b/app/src/main/java/org/blitzortung/android/data/Coordsys.kt index 6ef3cad33..0adfbb8e1 100644 --- a/app/src/main/java/org/blitzortung/android/data/Coordsys.kt +++ b/app/src/main/java/org/blitzortung/android/data/Coordsys.kt @@ -20,11 +20,11 @@ package org.blitzortung.android.data import org.osmdroid.util.GeoPoint - object Coordsys { - - fun toMapCoords(longitude: Double, latitude: Double): GeoPoint { + fun toMapCoords( + longitude: Double, + latitude: Double, + ): GeoPoint { return GeoPoint(latitude, longitude) } - } diff --git a/app/src/main/java/org/blitzortung/android/data/CustomToast.kt b/app/src/main/java/org/blitzortung/android/data/CustomToast.kt index 1072c537d..b39c22805 100644 --- a/app/src/main/java/org/blitzortung/android/data/CustomToast.kt +++ b/app/src/main/java/org/blitzortung/android/data/CustomToast.kt @@ -7,9 +7,13 @@ import android.widget.Toast import org.blitzortung.android.app.R class CustomToast - (context: Context?) : Toast(context) { +(context: Context?) : Toast(context) { companion object { - fun makeText(context: Context, text: Int, duration: Int): Toast { + fun makeText( + context: Context, + text: Int, + duration: Int, + ): Toast { val t = Toast.makeText(context, text, duration) val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater @@ -19,5 +23,3 @@ class CustomToast } } } - - diff --git a/app/src/main/java/org/blitzortung/android/data/DataChannel.kt b/app/src/main/java/org/blitzortung/android/data/DataChannel.kt index a3a5b4c9e..74ab3b91c 100644 --- a/app/src/main/java/org/blitzortung/android/data/DataChannel.kt +++ b/app/src/main/java/org/blitzortung/android/data/DataChannel.kt @@ -20,5 +20,5 @@ package org.blitzortung.android.data enum class DataChannel { STRIKES, - PARTICIPANTS + PARTICIPANTS, } diff --git a/app/src/main/java/org/blitzortung/android/data/DataMode.kt b/app/src/main/java/org/blitzortung/android/data/DataMode.kt index 4de561c52..7c8b09091 100644 --- a/app/src/main/java/org/blitzortung/android/data/DataMode.kt +++ b/app/src/main/java/org/blitzortung/android/data/DataMode.kt @@ -20,5 +20,5 @@ package org.blitzortung.android.data data class DataMode( val grid: Boolean = true, - val region: Boolean = false -) \ No newline at end of file + val region: Boolean = false, +) diff --git a/app/src/main/java/org/blitzortung/android/data/FetchBackgroundDataTask.kt b/app/src/main/java/org/blitzortung/android/data/FetchBackgroundDataTask.kt index ce11f5ef8..fff4b2955 100644 --- a/app/src/main/java/org/blitzortung/android/data/FetchBackgroundDataTask.kt +++ b/app/src/main/java/org/blitzortung/android/data/FetchBackgroundDataTask.kt @@ -4,22 +4,21 @@ import android.annotation.SuppressLint import android.os.Build import android.os.PowerManager import android.util.Log +import kotlin.reflect.KSuspendFunction1 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.blitzortung.android.app.Main import org.blitzortung.android.data.provider.data.DataProvider import org.blitzortung.android.data.provider.result.ResultEvent import org.blitzortung.android.util.isAtLeast -import kotlin.reflect.KSuspendFunction1 internal class FetchBackgroundDataTask( dataMode: DataMode, dataProvider: DataProvider, resultConsumer: (ResultEvent) -> Unit, toast: KSuspendFunction1, - private val wakeLock: PowerManager.WakeLock + private val wakeLock: PowerManager.WakeLock, ) : FetchDataTask(dataMode, dataProvider, resultConsumer) { - override fun onPostExecute(result: ResultEvent?) { super.onPostExecute(result) if (wakeLock.isHeld) { @@ -37,7 +36,11 @@ internal class FetchBackgroundDataTask( } @SuppressLint("WakelockTimeout") - override suspend fun doInBackground(parameters: Parameters, history: History?, flags: Flags): ResultEvent? = + override suspend fun doInBackground( + parameters: Parameters, + history: History?, + flags: Flags, + ): ResultEvent? = withContext(Dispatchers.IO) { if (isAtLeast(Build.VERSION_CODES.N)) { wakeLock.acquire(ServiceDataHandler.WAKELOCK_TIMEOUT) @@ -45,13 +48,13 @@ internal class FetchBackgroundDataTask( wakeLock.acquire() } if (wakeLock.isHeld) { - //Log.v(Main.LOG_TAG, "FetchBackgroundDataTask aquired wakelock $wakeLock") + // Log.v(Main.LOG_TAG, "FetchBackgroundDataTask aquired wakelock $wakeLock") val updatedParameters = parameters.copy( interval = TimeInterval.BACKGROUND, countThreshold = 0, - gridSize = 5000 + gridSize = 5000, ) val updatedFlags = flags.copy(storeResult = false) @@ -61,4 +64,4 @@ internal class FetchBackgroundDataTask( null // ignore } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/blitzortung/android/data/FetchDataTask.kt b/app/src/main/java/org/blitzortung/android/data/FetchDataTask.kt index c6d526efe..e5d80a85c 100644 --- a/app/src/main/java/org/blitzortung/android/data/FetchDataTask.kt +++ b/app/src/main/java/org/blitzortung/android/data/FetchDataTask.kt @@ -1,6 +1,7 @@ package org.blitzortung.android.data import android.util.Log +import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -9,12 +10,11 @@ import kotlinx.coroutines.withContext import org.blitzortung.android.app.Main import org.blitzortung.android.data.provider.data.DataProvider import org.blitzortung.android.data.provider.result.ResultEvent -import kotlin.coroutines.CoroutineContext internal open class FetchDataTask( private val dataMode: DataMode, private val dataProvider: DataProvider, - private val resultConsumer: (ResultEvent) -> Unit + private val resultConsumer: (ResultEvent) -> Unit, ) : CoroutineScope { private var job: Job = Job() @@ -25,11 +25,19 @@ internal open class FetchDataTask( job.cancel() } - fun execute(parameters: Parameters, history: History? = null, flags: Flags = Flags()) = launch { + fun execute( + parameters: Parameters, + history: History? = null, + flags: Flags = Flags(), + ) = launch { onPostExecute(doInBackground(parameters, history, flags)) } - protected open suspend fun doInBackground(parameters: Parameters, history: History?, flags: Flags): ResultEvent? = + protected open suspend fun doInBackground( + parameters: Parameters, + history: History?, + flags: Flags, + ): ResultEvent? = withContext(Dispatchers.IO) { try { dataProvider.retrieveData { @@ -47,7 +55,7 @@ internal open class FetchDataTask( referenceTime = System.currentTimeMillis(), parameters = parameters, history = history, - flags = flags + flags = flags, ) } } @@ -57,4 +65,4 @@ internal open class FetchDataTask( resultConsumer.invoke(result) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/blitzortung/android/data/Flags.kt b/app/src/main/java/org/blitzortung/android/data/Flags.kt index f9f82fde3..daaf07066 100644 --- a/app/src/main/java/org/blitzortung/android/data/Flags.kt +++ b/app/src/main/java/org/blitzortung/android/data/Flags.kt @@ -27,5 +27,6 @@ data class Flags( ) : Serializable enum class Mode { - DATA, ANIMATION + DATA, + ANIMATION, } diff --git a/app/src/main/java/org/blitzortung/android/data/MainDataHandler.kt b/app/src/main/java/org/blitzortung/android/data/MainDataHandler.kt index c1ed29e98..340166d93 100644 --- a/app/src/main/java/org/blitzortung/android/data/MainDataHandler.kt +++ b/app/src/main/java/org/blitzortung/android/data/MainDataHandler.kt @@ -26,6 +26,11 @@ import android.location.Location import android.os.Handler import android.util.Log import android.widget.Toast +import java.util.Locale +import java.util.concurrent.atomic.AtomicLong +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.properties.Delegates import org.blitzortung.android.app.Main.Companion.LOG_TAG import org.blitzortung.android.app.R import org.blitzortung.android.app.view.OnSharedPreferenceChangeListener @@ -49,453 +54,463 @@ import org.osmdroid.events.MapListener import org.osmdroid.events.ScrollEvent import org.osmdroid.events.ZoomEvent import org.osmdroid.util.BoundingBox -import java.util.Locale -import java.util.concurrent.atomic.AtomicLong -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.properties.Delegates @Singleton -class MainDataHandler @Inject constructor( - private val context: Context, - private val dataProviderFactory: DataProviderFactory, - private val preferences: SharedPreferences, - private val handler: Handler, - private val cache: DataCache, - private val localData: LocalData, - private val updatePeriod: Period -) : OnSharedPreferenceChangeListener, Runnable, MapListener { +class MainDataHandler + @Inject + constructor( + private val context: Context, + private val dataProviderFactory: DataProviderFactory, + private val preferences: SharedPreferences, + private val handler: Handler, + private val cache: DataCache, + private val localData: LocalData, + private val updatePeriod: Period, + ) : OnSharedPreferenceChangeListener, Runnable, MapListener { + private var location: Location? = null - private var location: Location? = null + @Volatile + private var updatesEnabled = false - @Volatile - private var updatesEnabled = false + private var animationSleepDuration by Delegates.notNull() + private var animationCycleSleepDuration by Delegates.notNull() - private var animationSleepDuration by Delegates.notNull() - private var animationCycleSleepDuration by Delegates.notNull() + var mode = Mode.DATA + private set - var mode = Mode.DATA - private set + private var period: Int = 0 + private var sequenceNumber = AtomicLong() - private var period: Int = 0 - private var sequenceNumber = AtomicLong() + private var dataProvider: DataProvider? = null - private var dataProvider: DataProvider? = null + var parameters = Parameters() + private set - var parameters = Parameters() - private set + var history = History() + private set - var history = History() - private set + private var animationHistory: History? = null - private var animationHistory: History? = null + var autoGridSize = false + private set - var autoGridSize = false - private set + private val dataConsumerContainer = + object : ConsumerContainer() { + override fun addedFirstConsumer() { + Log.d(LOG_TAG, "MainDataHandler: added first data consumer") + } - private val dataConsumerContainer = object : ConsumerContainer() { - override fun addedFirstConsumer() { - Log.d(LOG_TAG, "MainDataHandler: added first data consumer") - } + override fun removedLastConsumer() { + Log.d(LOG_TAG, "MainDataHandler: removed last data consumer") + } + } - override fun removedLastConsumer() { - Log.d(LOG_TAG, "MainDataHandler: removed last data consumer") + val locationEventConsumer: (LocationEvent) -> Unit = { locationEvent -> + Log.v(LOG_TAG, "AlertView received location ${locationEvent.location}") + location = locationEvent.location } - } - - val locationEventConsumer: (LocationEvent) -> Unit = { locationEvent -> - Log.v(LOG_TAG, "AlertView received location ${locationEvent.location}") - location = locationEvent.location - } - private var dataMode = DataMode() - - init { - preferences.registerOnSharedPreferenceChangeListener(this) - - updatesEnabled = false - onSharedPreferenceChanged( - this.preferences, - PreferenceKey.DATA_SOURCE, - PreferenceKey.USERNAME, - PreferenceKey.PASSWORD, - PreferenceKey.GRID_SIZE, - PreferenceKey.COUNT_THRESHOLD, - PreferenceKey.REGION, - PreferenceKey.INTERVAL_DURATION, - PreferenceKey.HISTORIC_TIMESTEP, - PreferenceKey.QUERY_PERIOD, - PreferenceKey.ANIMATION_INTERVAL_DURATION, - PreferenceKey.ANIMATION_SLEEP_DURATION, - PreferenceKey.ANIMATION_CYCLE_SLEEP_DURATION, - ) - updatesEnabled = true - } + private var dataMode = DataMode() + + init { + preferences.registerOnSharedPreferenceChangeListener(this) + + updatesEnabled = false + onSharedPreferenceChanged( + this.preferences, + PreferenceKey.DATA_SOURCE, + PreferenceKey.USERNAME, + PreferenceKey.PASSWORD, + PreferenceKey.GRID_SIZE, + PreferenceKey.COUNT_THRESHOLD, + PreferenceKey.REGION, + PreferenceKey.INTERVAL_DURATION, + PreferenceKey.HISTORIC_TIMESTEP, + PreferenceKey.QUERY_PERIOD, + PreferenceKey.ANIMATION_INTERVAL_DURATION, + PreferenceKey.ANIMATION_SLEEP_DURATION, + PreferenceKey.ANIMATION_CYCLE_SLEEP_DURATION, + ) + updatesEnabled = true + } - fun requestUpdates(dataConsumer: (DataEvent) -> Unit) { - dataConsumerContainer.addConsumer(dataConsumer) - } + fun requestUpdates(dataConsumer: (DataEvent) -> Unit) { + dataConsumerContainer.addConsumer(dataConsumer) + } - fun removeUpdates(dataConsumer: (DataEvent) -> Unit) { - dataConsumerContainer.removeConsumer(dataConsumer) - } + fun removeUpdates(dataConsumer: (DataEvent) -> Unit) { + dataConsumerContainer.removeConsumer(dataConsumer) + } - val hasConsumers: Boolean - get() = dataConsumerContainer.isEmpty + val hasConsumers: Boolean + get() = dataConsumerContainer.isEmpty - fun updateData() { - if (updatesEnabled) { - sendEvent(REQUEST_STARTED_EVENT) + fun updateData() { + if (updatesEnabled) { + sendEvent(REQUEST_STARTED_EVENT) - updateUsingCache() + updateUsingCache() + } } - } - private fun updateUsingCache() { - var flags = Flags(mode = mode) - val sequenceNumber = sequenceNumber.incrementAndGet() - - val parameters = activeParameters - val cachedResult = cache.get(parameters) - if (cachedResult != null) { - Log.d(LOG_TAG, "MainDataHandler.updateData() cached $parameters") - sendEvent(cachedResult.copy(sequenceNumber = sequenceNumber)) - } else { - Log.d(LOG_TAG, "MainDataHandler.updateData() fetch $parameters") - FetchDataTask(dataMode, dataProvider!!) { - if (mode == Mode.ANIMATION) { - flags = flags.copy(storeResult = false) + private fun updateUsingCache() { + var flags = Flags(mode = mode) + val sequenceNumber = sequenceNumber.incrementAndGet() + + val parameters = activeParameters + val cachedResult = cache.get(parameters) + if (cachedResult != null) { + Log.d(LOG_TAG, "MainDataHandler.updateData() cached $parameters") + sendEvent(cachedResult.copy(sequenceNumber = sequenceNumber)) + } else { + Log.d(LOG_TAG, "MainDataHandler.updateData() fetch $parameters") + FetchDataTask(dataMode, dataProvider!!) { + if (mode == Mode.ANIMATION) { + flags = flags.copy(storeResult = false) + if (!it.containsRealtimeData()) { + flags = flags.copy(ignoreForAlerting = true) + } + } + val event = it.copy(flags = flags) if (!it.containsRealtimeData()) { - flags = flags.copy(ignoreForAlerting = true) + cache.put(event.parameters, event) } - } - val event = it.copy(flags = flags) - if (!it.containsRealtimeData()) { - cache.put(event.parameters, event) - } - sendEvent(event.copy(sequenceNumber = sequenceNumber)) - }.execute(parameters = parameters, history = history) + sendEvent(event.copy(sequenceNumber = sequenceNumber)) + }.execute(parameters = parameters, history = history) + } } - } - private val activeParameters: Parameters - get() { - return if (dataMode.grid) { - localData.updateParameters(parameters, location) - } else { - var parameters = parameters - if (!dataMode.region) { - parameters = parameters.copy(region = 0) + private val activeParameters: Parameters + get() { + return if (dataMode.grid) { + localData.updateParameters(parameters, location) + } else { + var parameters = parameters + if (!dataMode.region) { + parameters = parameters.copy(region = 0) + } + parameters.copy(gridSize = 0, countThreshold = 0) } - parameters.copy(gridSize = 0, countThreshold = 0) } - } - private fun sendEvent(dataEvent: DataEvent) { - if (dataEvent is ResultEvent) { - localData.storeResult(dataEvent.gridParameters) - } - if (dataEvent is ResultEvent && dataEvent.flags.storeResult) { - dataConsumerContainer.storeAndBroadcast(dataEvent) - } else { - dataConsumerContainer.broadcast(dataEvent) + private fun sendEvent(dataEvent: DataEvent) { + if (dataEvent is ResultEvent) { + localData.storeResult(dataEvent.gridParameters) + } + if (dataEvent is ResultEvent && dataEvent.flags.storeResult) { + dataConsumerContainer.storeAndBroadcast(dataEvent) + } else { + dataConsumerContainer.broadcast(dataEvent) + } } - } - override fun onSharedPreferenceChanged( - sharedPreferences: SharedPreferences, - key: PreferenceKey - ) { - when (key) { - PreferenceKey.DATA_SOURCE, PreferenceKey.SERVICE_URL -> { - val providerTypeString = - sharedPreferences.get( - PreferenceKey.DATA_SOURCE, - DataProviderType.RPC.toString() - ) - val providerType = - DataProviderType.valueOf(providerTypeString.uppercase(Locale.getDefault())) - val dataProvider = dataProviderFactory.getDataProviderForType(providerType) - this.dataProvider = dataProvider + override fun onSharedPreferenceChanged( + sharedPreferences: SharedPreferences, + key: PreferenceKey, + ) { + when (key) { + PreferenceKey.DATA_SOURCE, PreferenceKey.SERVICE_URL -> { + val providerTypeString = + sharedPreferences.get( + PreferenceKey.DATA_SOURCE, + DataProviderType.RPC.toString(), + ) + val providerType = + DataProviderType.valueOf(providerTypeString.uppercase(Locale.getDefault())) + val dataProvider = dataProviderFactory.getDataProviderForType(providerType) + this.dataProvider = dataProvider + + updateProviderSpecifics() + + if (providerTypeString == DataProviderType.HTTP.toString()) { + showBlitzortungProviderWarning() + } - updateProviderSpecifics() + Log.v(LOG_TAG, "MainDataHandler update data source: $providerType") - if (providerTypeString == DataProviderType.HTTP.toString()) { - showBlitzortungProviderWarning() + updateData() } - Log.v(LOG_TAG, "MainDataHandler update data source: $providerType") + PreferenceKey.GRID_SIZE -> { + val gridSizeString = sharedPreferences.get(key, AUTO_GRID_SIZE_VALUE) + if (gridSizeString == AUTO_GRID_SIZE_VALUE) { + autoGridSize = true + parameters = parameters.copy(gridSize = DEFAULT_GRID_SIZE) + } else { + val gridSize = Integer.parseInt(gridSizeString) + autoGridSize = false + parameters = parameters.copy(gridSize = gridSize) + } + updateData() + } - updateData() - } + PreferenceKey.COUNT_THRESHOLD -> { + val countThreshold = Integer.parseInt(sharedPreferences.get(key, "0")) + parameters = parameters.copy(countThreshold = countThreshold) + updateData() + } - PreferenceKey.GRID_SIZE -> { - val gridSizeString = sharedPreferences.get(key, AUTO_GRID_SIZE_VALUE) - if (gridSizeString == AUTO_GRID_SIZE_VALUE) { - autoGridSize = true - parameters = parameters.copy(gridSize = DEFAULT_GRID_SIZE) - } else { - val gridSize = Integer.parseInt(gridSizeString) - autoGridSize = false - parameters = parameters.copy(gridSize = gridSize) + PreferenceKey.INTERVAL_DURATION -> { + val intervalDuration = Integer.parseInt(sharedPreferences.get(key, "60")) + parameters = parameters.withIntervalDuration(intervalDuration) + updateData() } - updateData() - } - PreferenceKey.COUNT_THRESHOLD -> { - val countThreshold = Integer.parseInt(sharedPreferences.get(key, "0")) - parameters = parameters.copy(countThreshold = countThreshold) - updateData() - } + PreferenceKey.HISTORIC_TIMESTEP -> { + history = + history.copy( + timeIncrement = + sharedPreferences.get(key, "30").toInt(), + ) + } - PreferenceKey.INTERVAL_DURATION -> { - val intervalDuration = Integer.parseInt(sharedPreferences.get(key, "60")) - parameters = parameters.withIntervalDuration(intervalDuration) - updateData() - } + PreferenceKey.REGION -> { + val region = Integer.parseInt(sharedPreferences.get(key, "0")) + parameters = parameters.copy(region = region) + updateData() + } - PreferenceKey.HISTORIC_TIMESTEP -> { - history = history.copy( - timeIncrement = - sharedPreferences.get(key, "30").toInt() - ) - } + PreferenceKey.QUERY_PERIOD -> { + period = Integer.parseInt(sharedPreferences.get(key, "60")) + Log.v(LOG_TAG, "MainDataHandler query $period") + } - PreferenceKey.REGION -> { - val region = Integer.parseInt(sharedPreferences.get(key, "0")) - parameters = parameters.copy(region = region) - updateData() - } + PreferenceKey.ANIMATION_INTERVAL_DURATION -> { + val value = Integer.parseInt(sharedPreferences.get(key, "4")) + animationHistory = + when (value) { + 2 -> History(5, 120, false) + 4 -> History(10, 240, false) + 6 -> History(10, 360, false) + 12 -> History(20, 720, false) + 24 -> History(30, 1440, true) + else -> History(10, 240, false) + } + if (mode == Mode.ANIMATION) { + history = animationHistory!! + cache.clear() + } + } - PreferenceKey.QUERY_PERIOD -> { - period = Integer.parseInt(sharedPreferences.get(key, "60")) - Log.v(LOG_TAG, "MainDataHandler query $period") - } + PreferenceKey.ANIMATION_SLEEP_DURATION -> { + animationSleepDuration = sharedPreferences.getInt(key.key, 200).toLong() + } - PreferenceKey.ANIMATION_INTERVAL_DURATION -> { - val value = Integer.parseInt(sharedPreferences.get(key, "4")) - animationHistory = when (value) { - 2 -> History(5, 120, false) - 4 -> History(10, 240, false) - 6 -> History(10, 360, false) - 12 -> History(20, 720, false) - 24 -> History(30, 1440, true) - else -> History(10, 240, false) + PreferenceKey.ANIMATION_CYCLE_SLEEP_DURATION -> { + animationCycleSleepDuration = sharedPreferences.getInt(key.key, 3000).toLong() } - if (mode == Mode.ANIMATION) { - history = animationHistory!! - cache.clear() + + else -> { } } + } - PreferenceKey.ANIMATION_SLEEP_DURATION -> { - animationSleepDuration = sharedPreferences.getInt(key.key, 200).toLong() - } + private fun showBlitzortungProviderWarning() = + CustomToast.Companion.makeText(context, R.string.provider_warning, Toast.LENGTH_LONG).show() - PreferenceKey.ANIMATION_CYCLE_SLEEP_DURATION -> { - animationCycleSleepDuration = sharedPreferences.getInt(key.key, 3000).toLong() - } + private fun updateProviderSpecifics() { + val providerType = dataProvider!!.type - else -> { - } + dataMode = + when (providerType) { + DataProviderType.RPC -> DataMode(grid = true, region = false) + DataProviderType.HTTP -> DataMode(grid = false, region = true) + } } - } - private fun showBlitzortungProviderWarning() = - CustomToast.Companion.makeText(context, R.string.provider_warning, Toast.LENGTH_LONG).show() + fun toggleExtendedMode() { + dataMode = dataMode.copy(grid = dataMode.grid.xor(true)) - private fun updateProviderSpecifics() { - val providerType = dataProvider!!.type - - dataMode = when (providerType) { - DataProviderType.RPC -> DataMode(grid = true, region = false) - DataProviderType.HTTP -> DataMode(grid = false, region = true) + if (!isRealtime) { + val dataChannels = HashSet() + dataChannels.add(DataChannel.STRIKES) + updateData() + } } - } - fun toggleExtendedMode() { - dataMode = dataMode.copy(grid = dataMode.grid.xor(true)) + val intervalDuration: Int + get() = parameters.intervalDuration - if (!isRealtime) { - val dataChannels = HashSet() - dataChannels.add(DataChannel.STRIKES) - updateData() + fun goRealtime(): Boolean { + return updateParameters { it.goRealtime() } } - } - val intervalDuration: Int - get() = parameters.intervalDuration - - fun goRealtime(): Boolean { - return updateParameters { it.goRealtime() } - } + fun setPosition(position: Int): Boolean { + return updateParameters { it.withPosition(position, history) } + } - fun setPosition(position: Int): Boolean { - return updateParameters { it.withPosition(position, history) } - } + fun historySteps(): Int { + return parameters.intervalMaxPosition(history) + } - fun historySteps(): Int { - return parameters.intervalMaxPosition(history) - } + private fun updateParameters(updater: (Parameters) -> Parameters): Boolean { + val oldParameters = parameters + parameters = updater.invoke(parameters) + return parameters != oldParameters + } - private fun updateParameters(updater: (Parameters) -> Parameters): Boolean { - val oldParameters = parameters - parameters = updater.invoke(parameters) - return parameters != oldParameters - } + val isRealtime: Boolean + get() = parameters.isRealtime() - val isRealtime: Boolean - get() = parameters.isRealtime() + private fun broadcastEvent(event: DataEvent) { + dataConsumerContainer.broadcast(event) + } - private fun broadcastEvent(event: DataEvent) { - dataConsumerContainer.broadcast(event) - } + override fun run() { + when (mode) { + Mode.DATA -> { + val currentTime = Period.currentTime + val updateTargets = HashSet() - override fun run() { - when (mode) { - Mode.DATA -> { - val currentTime = Period.currentTime - val updateTargets = HashSet() + if (updatePeriod.shouldUpdate(currentTime, period)) { + updateTargets.add(DataChannel.STRIKES) + } - if (updatePeriod.shouldUpdate(currentTime, period)) { - updateTargets.add(DataChannel.STRIKES) - } + if (updateTargets.isNotEmpty()) { + updateData() + } - if (updateTargets.isNotEmpty()) { - updateData() + if (parameters.isRealtime()) { + val statusString = + "" + updatePeriod.getCurrentUpdatePeriod(currentTime, period) + "/" + period + broadcastEvent(StatusEvent(statusString)) + // Schedule the next update + handler.postDelayed(this, 1000) + } } - if (parameters.isRealtime()) { - val statusString = - "" + updatePeriod.getCurrentUpdatePeriod(currentTime, period) + "/" + period - broadcastEvent(StatusEvent(statusString)) - // Schedule the next update - handler.postDelayed(this, 1000) + Mode.ANIMATION -> { + parameters = parameters.animationStep(history) + val delay = + if (parameters.isRealtime()) { + if (animationCycleSleepDuration > 0) animationCycleSleepDuration else animationSleepDuration + } else { + animationSleepDuration + } + handler.postDelayed(this, delay) + updateUsingCache() } } + } - Mode.ANIMATION -> { - parameters = parameters.animationStep(history) - val delay = if (parameters.isRealtime()) { - if (animationCycleSleepDuration > 0) animationCycleSleepDuration else animationSleepDuration - } else animationSleepDuration - handler.postDelayed(this, delay) - updateUsingCache() + fun start() { + if (isRealtime || mode == Mode.ANIMATION) { + handler.post(this) } } - } - fun start() { - if (isRealtime || mode == Mode.ANIMATION) { + fun startAnimation() { + cache.clear() + this.history = animationHistory!! + mode = Mode.ANIMATION handler.post(this) } - } - fun startAnimation() { - cache.clear() - this.history = animationHistory!! - mode = Mode.ANIMATION - handler.post(this) - } - - fun restart() { - updatePeriod.restart() - if (mode == Mode.ANIMATION) { - cache.clear() + fun restart() { + updatePeriod.restart() + if (mode == Mode.ANIMATION) { + cache.clear() + } + history = History() + mode = Mode.DATA + start() } - history = History() - mode = Mode.DATA - start() - } - fun stop() { - handler.removeCallbacks(this) - } + fun stop() { + handler.removeCallbacks(this) + } - companion object { - val REQUEST_STARTED_EVENT = RequestStartedEvent() - val DEFAULT_DATA_CHANNELS = setOf(DataChannel.STRIKES) - } + companion object { + val REQUEST_STARTED_EVENT = RequestStartedEvent() + val DEFAULT_DATA_CHANNELS = setOf(DataChannel.STRIKES) + } - override fun onScroll(event: ScrollEvent?): Boolean { - return event?.let { updateGrid(event.source as OwnMapView, this.autoGridSize) } ?: false - } + override fun onScroll(event: ScrollEvent?): Boolean { + return event?.let { updateGrid(event.source as OwnMapView, this.autoGridSize) } ?: false + } - override fun onZoom(event: ZoomEvent?): Boolean { - return event?.let { updateGrid(event.source as OwnMapView, this.autoGridSize) } ?: false - } + override fun onZoom(event: ZoomEvent?): Boolean { + return event?.let { updateGrid(event.source as OwnMapView, this.autoGridSize) } ?: false + } - fun updateGrid( - mapView: OwnMapView, - autoGridSize: Boolean - ): Boolean { - val updatedAutoGridSize = updateAutoGridSize(mapView.zoomLevelDouble, autoGridSize) - val updatedLocation = mapView.boundingBox?.let { updateLocation(it, updatedAutoGridSize) } ?: false - - if (updatedLocation || updatedAutoGridSize) { - Log.v(LOG_TAG, "MainDataHandler.updateGrid() location: $updatedLocation gridSize: $updatedAutoGridSize") - if (mapView.isAnimating) { - addUpdateAfterAnimationListener(mapView) - } else { - Log.v(LOG_TAG, "MainDataHandler.updateGrid() call updateData()") - updateData() + fun updateGrid( + mapView: OwnMapView, + autoGridSize: Boolean, + ): Boolean { + val updatedAutoGridSize = updateAutoGridSize(mapView.zoomLevelDouble, autoGridSize) + val updatedLocation = mapView.boundingBox?.let { updateLocation(it, updatedAutoGridSize) } ?: false + + if (updatedLocation || updatedAutoGridSize) { + Log.v(LOG_TAG, "MainDataHandler.updateGrid() location: $updatedLocation gridSize: $updatedAutoGridSize") + if (mapView.isAnimating) { + addUpdateAfterAnimationListener(mapView) + } else { + Log.v(LOG_TAG, "MainDataHandler.updateGrid() call updateData()") + updateData() + } } + return updatedLocation || updatedAutoGridSize } - return updatedLocation || updatedAutoGridSize - } - fun updateLocation(boundingBox: BoundingBox, force: Boolean = false): Boolean = - localData.update(boundingBox, force) - - fun updateAutoGridSize(zoomLevel: Double, autoGridSize: Boolean): Boolean = - if (autoGridSize) { - val gridSize = when { - zoomLevel >= 7.5f -> 5000 - zoomLevel in 5f..7.5f -> 10000 - zoomLevel in 3.5f..5f -> 25000 - zoomLevel in 2.5f..3.5f -> 50000 - else -> 100000 - } - if (parameters.gridSize != gridSize) { - Log.v( - LOG_TAG, - "MainDataHandler.updateAutoGridSize() $zoomLevel : ${parameters.gridSize} -> $gridSize" - ) - parameters = parameters.copy(gridSize = gridSize) - true + fun updateLocation( + boundingBox: BoundingBox, + force: Boolean = false, + ): Boolean = localData.update(boundingBox, force) + + fun updateAutoGridSize( + zoomLevel: Double, + autoGridSize: Boolean, + ): Boolean = + if (autoGridSize) { + val gridSize = + when { + zoomLevel >= 7.5f -> 5000 + zoomLevel in 5f..7.5f -> 10000 + zoomLevel in 3.5f..5f -> 25000 + zoomLevel in 2.5f..3.5f -> 50000 + else -> 100000 + } + if (parameters.gridSize != gridSize) { + Log.v( + LOG_TAG, + "MainDataHandler.updateAutoGridSize() $zoomLevel : ${parameters.gridSize} -> $gridSize", + ) + parameters = parameters.copy(gridSize = gridSize) + true + } else { + false + } } else { false } - } else { - false - } - private fun addUpdateAfterAnimationListener(mapView: OwnMapView) { - val animator = mapView.animator() + private fun addUpdateAfterAnimationListener(mapView: OwnMapView) { + val animator = mapView.animator() - if (animator != null && !animator.listeners.contains(animatorListener)) { - animator.addListener(animatorListener) + if (animator != null && !animator.listeners.contains(animatorListener)) { + animator.addListener(animatorListener) + } } - } - private val animatorListener = object : AnimatorListener { - override fun onAnimationStart(animation: Animator) { - } + private val animatorListener = + object : AnimatorListener { + override fun onAnimationStart(animation: Animator) { + } - override fun onAnimationEnd(animation: Animator) { - this@MainDataHandler.updateData() - } + override fun onAnimationEnd(animation: Animator) { + this@MainDataHandler.updateData() + } - override fun onAnimationCancel(animation: Animator) { - this@MainDataHandler.updateData() - } + override fun onAnimationCancel(animation: Animator) { + this@MainDataHandler.updateData() + } - override fun onAnimationRepeat(animation: Animator) { - } - } + override fun onAnimationRepeat(animation: Animator) { + } + } - fun calculateTotalCacheSize(): CacheSize = cache.calculateTotalSize() -} + fun calculateTotalCacheSize(): CacheSize = cache.calculateTotalSize() + } internal const val DEFAULT_GRID_SIZE = 10000 internal const val AUTO_GRID_SIZE_VALUE = "auto" diff --git a/app/src/main/java/org/blitzortung/android/data/Parameters.kt b/app/src/main/java/org/blitzortung/android/data/Parameters.kt index 64f0714b6..b145afb51 100644 --- a/app/src/main/java/org/blitzortung/android/data/Parameters.kt +++ b/app/src/main/java/org/blitzortung/android/data/Parameters.kt @@ -18,18 +18,17 @@ package org.blitzortung.android.data +import java.io.Serializable import org.blitzortung.android.data.provider.GLOBAL_REGION import org.blitzortung.android.data.provider.LOCAL_REGION -import java.io.Serializable data class Parameters( val region: Int = -1, val gridSize: Int = 0, val interval: TimeInterval = TimeInterval(), val countThreshold: Int = 0, - val dataArea: DataArea? = null + val dataArea: DataArea? = null, ) : Serializable { - val intervalDuration: Int get() = interval.duration @@ -44,7 +43,10 @@ data class Parameters( fun animationStep(history: History): Parameters = copy(interval = interval.animationStep(history)) - fun withPosition(position: Int, history: History): Parameters { + fun withPosition( + position: Int, + history: History, + ): Parameters { val offset = (-intervalMaxPosition(history) + position) * history.timeIncrement return copy(interval = interval.withOffset(offset, history)) } @@ -58,11 +60,12 @@ data class Parameters( fun intervalPosition(history: History): Int = calculatePosition(history.lowerLimit(interval) - interval.offset, history) - fun intervalMaxPosition(history: History): Int = - calculatePosition(history.lowerLimit(interval), history) + fun intervalMaxPosition(history: History): Int = calculatePosition(history.lowerLimit(interval), history) - private fun calculatePosition(value: Int, history: History): Int = - if (history.timeIncrement != 0) -value / history.timeIncrement else 0 + private fun calculatePosition( + value: Int, + history: History, + ): Int = if (history.timeIncrement != 0) -value / history.timeIncrement else 0 } data class DataArea( @@ -81,7 +84,6 @@ data class History( val range: Int = MAX_HISTORY_RANGE, val limit: Boolean = true, ) : Serializable { - fun lowerLimit(timeInterval: TimeInterval): Int { return -range + if (limit) timeInterval.duration else 0 } diff --git a/app/src/main/java/org/blitzortung/android/data/SequenceValidator.kt b/app/src/main/java/org/blitzortung/android/data/SequenceValidator.kt index 100546635..04b0e4f3a 100644 --- a/app/src/main/java/org/blitzortung/android/data/SequenceValidator.kt +++ b/app/src/main/java/org/blitzortung/android/data/SequenceValidator.kt @@ -18,28 +18,29 @@ package org.blitzortung.android.data -import org.blitzortung.android.util.isAtLeast import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject +import org.blitzortung.android.util.isAtLeast -class SequenceValidator @Inject constructor() { - - private val currentSequenceNumber = AtomicLong() - - fun isUpdate(sequenceNumber: Long?) = - sequenceNumber != null && sequenceNumber == determineUpdatedSequenceNumber(sequenceNumber) - - private fun determineUpdatedSequenceNumber(sequenceNumber: Long) = if (isAtLeast(24)) { - currentSequenceNumber.updateAndGet { previousSequenceNumber -> - if (previousSequenceNumber < sequenceNumber) sequenceNumber else previousSequenceNumber - } - } else { - synchronized(currentSequenceNumber) { - val previousSequenceNumber = currentSequenceNumber.get() - val updated = if (previousSequenceNumber < sequenceNumber) sequenceNumber else previousSequenceNumber - currentSequenceNumber.set(updated) - updated - } +class SequenceValidator + @Inject + constructor() { + private val currentSequenceNumber = AtomicLong() + + fun isUpdate(sequenceNumber: Long?) = + sequenceNumber != null && sequenceNumber == determineUpdatedSequenceNumber(sequenceNumber) + + private fun determineUpdatedSequenceNumber(sequenceNumber: Long) = + if (isAtLeast(24)) { + currentSequenceNumber.updateAndGet { previousSequenceNumber -> + if (previousSequenceNumber < sequenceNumber) sequenceNumber else previousSequenceNumber + } + } else { + synchronized(currentSequenceNumber) { + val previousSequenceNumber = currentSequenceNumber.get() + val updated = if (previousSequenceNumber < sequenceNumber) sequenceNumber else previousSequenceNumber + currentSequenceNumber.set(updated) + updated + } + } } - -} diff --git a/app/src/main/java/org/blitzortung/android/data/ServiceDataHandler.kt b/app/src/main/java/org/blitzortung/android/data/ServiceDataHandler.kt index e135d1606..fb4573679 100644 --- a/app/src/main/java/org/blitzortung/android/data/ServiceDataHandler.kt +++ b/app/src/main/java/org/blitzortung/android/data/ServiceDataHandler.kt @@ -23,6 +23,8 @@ import android.location.Location import android.os.PowerManager import android.util.Log import android.widget.Toast +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.blitzortung.android.app.Main @@ -34,76 +36,79 @@ import org.blitzortung.android.data.provider.data.DataProvider import org.blitzortung.android.data.provider.result.DataEvent import org.blitzortung.android.location.LocationEvent import org.blitzortung.android.protocol.ConsumerContainer -import javax.inject.Inject -import javax.inject.Singleton @Singleton -class ServiceDataHandler @Inject constructor( - private val context: Context, - private val wakeLock: PowerManager.WakeLock, - dataProviderFactory: DataProviderFactory, - private val localData: LocalData -) { - - private var location: Location? = null - - private var dataProvider: DataProvider? = null - - private val parameters = Parameters( - region = LOCAL_REGION, gridSize = 5000, - interval = TimeInterval.BACKGROUND, - countThreshold = 0, - ) - - private val dataConsumerContainer = object : ConsumerContainer() { - override fun addedFirstConsumer() { - Log.d(Main.LOG_TAG, "ServiceDataHandler: added first data consumer") - } - - override fun removedLastConsumer() { - Log.d(Main.LOG_TAG, "ServiceDataHandler: removed last data consumer") +class ServiceDataHandler + @Inject + constructor( + private val context: Context, + private val wakeLock: PowerManager.WakeLock, + dataProviderFactory: DataProviderFactory, + private val localData: LocalData, + ) { + private var location: Location? = null + + private var dataProvider: DataProvider? = null + + private val parameters = + Parameters( + region = LOCAL_REGION, + gridSize = 5000, + interval = TimeInterval.BACKGROUND, + countThreshold = 0, + ) + + private val dataConsumerContainer = + object : ConsumerContainer() { + override fun addedFirstConsumer() { + Log.d(Main.LOG_TAG, "ServiceDataHandler: added first data consumer") + } + + override fun removedLastConsumer() { + Log.d(Main.LOG_TAG, "ServiceDataHandler: removed last data consumer") + } + } + + val locationEventConsumer: (LocationEvent) -> Unit = { locationEvent -> + Log.v(Main.LOG_TAG, "AlertView received location ${locationEvent.location}") + location = locationEvent.location } - } - val locationEventConsumer: (LocationEvent) -> Unit = { locationEvent -> - Log.v(Main.LOG_TAG, "AlertView received location ${locationEvent.location}") - location = locationEvent.location - } + private val dataMode = DataMode(grid = true, region = false) - private val dataMode = DataMode(grid = true, region = false) - - init { - dataProvider = dataProviderFactory.getDataProviderForType(DataProviderType.RPC) - } + init { + dataProvider = dataProviderFactory.getDataProviderForType(DataProviderType.RPC) + } - fun updateData() { - dataProvider?.let { dataProvider -> - Log.v(Main.LOG_TAG, "ServiceDataHandler.updateData() $activeParameters $wakeLock") - FetchBackgroundDataTask(dataMode, dataProvider, { sendEvent(it) }, ::toast, wakeLock) - .execute(activeParameters) + fun updateData() { + dataProvider?.let { dataProvider -> + Log.v(Main.LOG_TAG, "ServiceDataHandler.updateData() $activeParameters $wakeLock") + FetchBackgroundDataTask(dataMode, dataProvider, { sendEvent(it) }, ::toast, wakeLock) + .execute(activeParameters) + } } - } - fun requestUpdates(dataConsumer: (DataEvent) -> Unit) { - dataConsumerContainer.addConsumer(dataConsumer) - } + fun requestUpdates(dataConsumer: (DataEvent) -> Unit) { + dataConsumerContainer.addConsumer(dataConsumer) + } - fun removeUpdates(dataConsumer: (DataEvent) -> Unit) { - dataConsumerContainer.removeConsumer(dataConsumer) - } + fun removeUpdates(dataConsumer: (DataEvent) -> Unit) { + dataConsumerContainer.removeConsumer(dataConsumer) + } - private val activeParameters: Parameters - get() = localData.updateParameters(parameters.copy(region = LOCAL_REGION), location) + private val activeParameters: Parameters + get() = localData.updateParameters(parameters.copy(region = LOCAL_REGION), location) - private fun sendEvent(dataEvent: DataEvent) { - dataConsumerContainer.broadcast(dataEvent) - } + private fun sendEvent(dataEvent: DataEvent) { + dataConsumerContainer.broadcast(dataEvent) + } - private suspend fun toast(stringResource: Int) = withContext(Dispatchers.Main) { - Toast.makeText(context, stringResource, Toast.LENGTH_LONG).show() - } + private suspend fun toast(stringResource: Int) = + withContext(Dispatchers.Main) { + Toast.makeText(context, stringResource, Toast.LENGTH_LONG).show() + } - companion object { - const val WAKELOCK_TIMEOUT = 5000L + companion object { + const val WAKELOCK_TIMEOUT = 5000L + } } -} diff --git a/app/src/main/java/org/blitzortung/android/data/TimeInterval.kt b/app/src/main/java/org/blitzortung/android/data/TimeInterval.kt index 9b7636733..421056aae 100644 --- a/app/src/main/java/org/blitzortung/android/data/TimeInterval.kt +++ b/app/src/main/java/org/blitzortung/android/data/TimeInterval.kt @@ -24,8 +24,10 @@ data class TimeInterval( val offset: Int = 0, val duration: Int = DEFAULT_DURATION, ) : Serializable { - - fun withOffset(offset: Int, history: History): TimeInterval { + fun withOffset( + offset: Int, + history: History, + ): TimeInterval { if (offset in history.lowerLimit(this)..0) { return copy(offset = offset) } @@ -54,17 +56,24 @@ data class TimeInterval( return copy(offset = 0) } - private fun updateIntervalOffset(history: History, operation: (Int, Int) -> Int): TimeInterval { + private fun updateIntervalOffset( + history: History, + operation: (Int, Int) -> Int, + ): TimeInterval { val intervalOffset = operation.invoke(offset, history.timeIncrement) return this.copy( - offset = alignValue( - intervalOffset.coerceIn(history.lowerLimit(this), 0), - history.timeIncrement - ) + offset = + alignValue( + intervalOffset.coerceIn(history.lowerLimit(this), 0), + history.timeIncrement, + ), ) } - private fun alignValue(value: Int, offsetIncrement: Int): Int { + private fun alignValue( + value: Int, + offsetIncrement: Int, + ): Int { return (value / offsetIncrement) * offsetIncrement } diff --git a/app/src/main/java/org/blitzortung/android/data/beans/DefaultStrike.kt b/app/src/main/java/org/blitzortung/android/data/beans/DefaultStrike.kt index 489e4dbab..3ee363e53 100644 --- a/app/src/main/java/org/blitzortung/android/data/beans/DefaultStrike.kt +++ b/app/src/main/java/org/blitzortung/android/data/beans/DefaultStrike.kt @@ -27,9 +27,8 @@ data class DefaultStrike( val altitude: Int = 0, val amplitude: Float = 0f, val stationCount: Short = 0, - val lateralError: Double = 0.0 + val lateralError: Double = 0.0, ) : Strike, Serializable { - override val multiplicity = 1 companion object { diff --git a/app/src/main/java/org/blitzortung/android/data/beans/Event.kt b/app/src/main/java/org/blitzortung/android/data/beans/Event.kt index 16f27a9dc..bad7bf383 100644 --- a/app/src/main/java/org/blitzortung/android/data/beans/Event.kt +++ b/app/src/main/java/org/blitzortung/android/data/beans/Event.kt @@ -21,4 +21,3 @@ package org.blitzortung.android.data.beans interface Event : Location { val timestamp: Long } - diff --git a/app/src/main/java/org/blitzortung/android/data/beans/GridElement.kt b/app/src/main/java/org/blitzortung/android/data/beans/GridElement.kt index 23ac96009..b076169f1 100644 --- a/app/src/main/java/org/blitzortung/android/data/beans/GridElement.kt +++ b/app/src/main/java/org/blitzortung/android/data/beans/GridElement.kt @@ -24,9 +24,8 @@ data class GridElement( override val timestamp: Long, override val longitude: Double, override val latitude: Double, - override val multiplicity: Int + override val multiplicity: Int, ) : Strike, Serializable { - companion object { private const val serialVersionUID = 6765788323616893614L } diff --git a/app/src/main/java/org/blitzortung/android/data/beans/GridParameters.kt b/app/src/main/java/org/blitzortung/android/data/beans/GridParameters.kt index a421ade1f..0c19cc707 100644 --- a/app/src/main/java/org/blitzortung/android/data/beans/GridParameters.kt +++ b/app/src/main/java/org/blitzortung/android/data/beans/GridParameters.kt @@ -20,9 +20,9 @@ package org.blitzortung.android.data.beans import android.graphics.Point import android.graphics.RectF +import java.io.Serializable import org.blitzortung.android.data.Coordsys import org.osmdroid.views.Projection -import java.io.Serializable data class GridParameters( val longitudeStart: Double, @@ -31,9 +31,8 @@ data class GridParameters( val latitudeDelta: Double, val longitudeBins: Int, val latitudeBins: Int, - val size: Int? = null + val size: Int? = null, ) : Serializable { - val rectCenterLongitude: Double = longitudeStart + longitudeDelta * longitudeBins / 2.0 val rectCenterLatitude: Double = latitudeStart - latitudeDelta * latitudeBins / 2.0 @@ -58,24 +57,31 @@ data class GridParameters( fun getRect(projection: Projection): RectF { var leftTop = Point() - leftTop = projection.toPixels( - Coordsys.toMapCoords(longitudeStart, latitudeStart), leftTop - ) + leftTop = + projection.toPixels( + Coordsys.toMapCoords(longitudeStart, latitudeStart), leftTop, + ) var bottomRight = Point() val longitudeEnd = longitudeStart + longitudeInterval val latitudeEnd = latitudeStart - latitudeInterval - bottomRight = projection.toPixels( - Coordsys.toMapCoords( - longitudeEnd, - latitudeEnd - ), bottomRight - ) + bottomRight = + projection.toPixels( + Coordsys.toMapCoords( + longitudeEnd, + latitudeEnd, + ), + bottomRight, + ) // Log.d(Main.LOG_TAG, "GridParameters.getRect() $longitudeStart - $longitudeEnd ($longitudeDelta, #$longitudeBins) $latitudeEnd - $latitudeStart ($latitudeDelta, #$latitudeBins)") return RectF(leftTop.x.toFloat(), leftTop.y.toFloat(), bottomRight.x.toFloat(), bottomRight.y.toFloat()) } - fun contains(longitude: Double, latitude: Double, inset: Double = 0.0): Boolean { + fun contains( + longitude: Double, + latitude: Double, + inset: Double = 0.0, + ): Boolean { val inLongitude = longitude in longitudeStart + inset..longitudeEnd - inset val inLatitude = latitude in latitudeStart - latitudeDelta * latitudeBins + inset..latitudeStart - inset return inLongitude && inLatitude diff --git a/app/src/main/java/org/blitzortung/android/data/beans/Location.kt b/app/src/main/java/org/blitzortung/android/data/beans/Location.kt index 577512be1..6459b0737 100644 --- a/app/src/main/java/org/blitzortung/android/data/beans/Location.kt +++ b/app/src/main/java/org/blitzortung/android/data/beans/Location.kt @@ -22,4 +22,3 @@ interface Location { val longitude: Double val latitude: Double } - diff --git a/app/src/main/java/org/blitzortung/android/data/beans/Station.kt b/app/src/main/java/org/blitzortung/android/data/beans/Station.kt index 829c3a462..204f9c17c 100644 --- a/app/src/main/java/org/blitzortung/android/data/beans/Station.kt +++ b/app/src/main/java/org/blitzortung/android/data/beans/Station.kt @@ -24,9 +24,8 @@ class Station( override val longitude: Double, override val latitude: Double, val name: String, - val offlineSince: Long + val offlineSince: Long, ) : Location, Serializable { - val state: State get() { return if (offlineSince == OFFLINE_SINCE_NOT_SET) { @@ -53,7 +52,9 @@ class Station( } enum class State { - ON, DELAYED, OFF + ON, + DELAYED, + OFF, } companion object { diff --git a/app/src/main/java/org/blitzortung/android/data/cache/DataCache.kt b/app/src/main/java/org/blitzortung/android/data/cache/DataCache.kt index 1c5e41d88..a504209f2 100644 --- a/app/src/main/java/org/blitzortung/android/data/cache/DataCache.kt +++ b/app/src/main/java/org/blitzortung/android/data/cache/DataCache.kt @@ -1,50 +1,58 @@ package org.blitzortung.android.data.cache import android.util.Log -import org.blitzortung.android.app.Main.Companion.LOG_TAG -import org.blitzortung.android.data.Parameters -import org.blitzortung.android.data.provider.result.ResultEvent import java.io.Serializable import javax.inject.Inject import javax.inject.Singleton +import org.blitzortung.android.app.Main.Companion.LOG_TAG +import org.blitzortung.android.data.Parameters +import org.blitzortung.android.data.provider.result.ResultEvent @Singleton -class DataCache @Inject constructor() { - - val cache = hashMapOf>() - - fun get(parameters: Parameters, expiryTime: Long = DEFAULT_EXPIRY_TIME): ResultEvent? { - val entry = cache[parameters] ?: return null - if (entry.timestamp < System.currentTimeMillis() - expiryTime) { - cache.remove(parameters) - return null +class DataCache + @Inject + constructor() { + val cache = hashMapOf>() + + fun get( + parameters: Parameters, + expiryTime: Long = DEFAULT_EXPIRY_TIME, + ): ResultEvent? { + val entry = cache[parameters] ?: return null + if (entry.timestamp < System.currentTimeMillis() - expiryTime) { + cache.remove(parameters) + return null + } + return entry.value } - return entry.value - } - fun put(parameters: Parameters, dataEvent: ResultEvent) { - cache[parameters] = Timestamped(dataEvent.copy(sequenceNumber = null)) - } + fun put( + parameters: Parameters, + dataEvent: ResultEvent, + ) { + cache[parameters] = Timestamped(dataEvent.copy(sequenceNumber = null)) + } - fun calculateTotalSize(): CacheSize = cache.entries.fold(CacheSize(0, 0)) { acc, entry -> - val resultEvent = entry.value.value - val strikeCount = resultEvent.strikes?.size ?: 0 - Log.v(LOG_TAG, "${entry.key} -> $strikeCount") - CacheSize(acc.entries + 1, acc.strikes + strikeCount) - } + fun calculateTotalSize(): CacheSize = + cache.entries.fold(CacheSize(0, 0)) { acc, entry -> + val resultEvent = entry.value.value + val strikeCount = resultEvent.strikes?.size ?: 0 + Log.v(LOG_TAG, "${entry.key} -> $strikeCount") + CacheSize(acc.entries + 1, acc.strikes + strikeCount) + } - fun clear() { - cache.clear() - } + fun clear() { + cache.clear() + } - companion object { - const val DEFAULT_EXPIRY_TIME: Long = 5 * 60 * 1000 + companion object { + const val DEFAULT_EXPIRY_TIME: Long = 5 * 60 * 1000 + } } -} data class Timestamped(val value: T, val timestamp: Long = System.currentTimeMillis()) : Serializable data class CacheSize( val entries: Int, val strikes: Int, -) \ No newline at end of file +) diff --git a/app/src/main/java/org/blitzortung/android/data/provider/DataProviderFactory.kt b/app/src/main/java/org/blitzortung/android/data/provider/DataProviderFactory.kt index 409072052..4d4c279b5 100644 --- a/app/src/main/java/org/blitzortung/android/data/provider/DataProviderFactory.kt +++ b/app/src/main/java/org/blitzortung/android/data/provider/DataProviderFactory.kt @@ -18,23 +18,27 @@ package org.blitzortung.android.data.provider +import javax.inject.Inject +import javax.inject.Singleton import org.blitzortung.android.data.provider.blitzortung.BlitzortungHttpDataProvider import org.blitzortung.android.data.provider.data.DataProvider import org.blitzortung.android.data.provider.standard.JsonRpcDataProvider -import javax.inject.Inject -import javax.inject.Singleton @Singleton -class DataProviderFactory @Inject constructor( - defaultProvider: JsonRpcDataProvider, - blitzortungProvider: BlitzortungHttpDataProvider -) { - private val dataProvidersByType: Map = listOf( - defaultProvider, blitzortungProvider - ).groupBy { it.type }.mapValues { it.value.first() } - - fun getDataProviderForType(providerType: DataProviderType): DataProvider { - return dataProvidersByType[providerType] - ?: throw IllegalStateException("unhandled data provider type '$providerType'") +class DataProviderFactory + @Inject + constructor( + defaultProvider: JsonRpcDataProvider, + blitzortungProvider: BlitzortungHttpDataProvider, + ) { + private val dataProvidersByType: Map = + listOf( + defaultProvider, + blitzortungProvider, + ).groupBy { it.type }.mapValues { it.value.first() } + + fun getDataProviderForType(providerType: DataProviderType): DataProvider { + return dataProvidersByType[providerType] + ?: throw IllegalStateException("unhandled data provider type '$providerType'") + } } -} diff --git a/app/src/main/java/org/blitzortung/android/data/provider/DataProviderType.kt b/app/src/main/java/org/blitzortung/android/data/provider/DataProviderType.kt index 30447a210..e38edf3b9 100644 --- a/app/src/main/java/org/blitzortung/android/data/provider/DataProviderType.kt +++ b/app/src/main/java/org/blitzortung/android/data/provider/DataProviderType.kt @@ -19,5 +19,6 @@ package org.blitzortung.android.data.provider enum class DataProviderType { - HTTP, RPC -} \ No newline at end of file + HTTP, + RPC, +} diff --git a/app/src/main/java/org/blitzortung/android/data/provider/LocalData.kt b/app/src/main/java/org/blitzortung/android/data/provider/LocalData.kt index 3dfc27031..354fe3445 100644 --- a/app/src/main/java/org/blitzortung/android/data/provider/LocalData.kt +++ b/app/src/main/java/org/blitzortung/android/data/provider/LocalData.kt @@ -2,16 +2,16 @@ package org.blitzortung.android.data.provider import android.location.Location import android.util.Log -import org.blitzortung.android.app.Main.Companion.LOG_TAG -import org.blitzortung.android.data.DataArea -import org.blitzortung.android.data.Parameters -import org.blitzortung.android.data.beans.GridParameters -import org.osmdroid.util.BoundingBox import javax.inject.Inject import javax.inject.Singleton import kotlin.math.ceil import kotlin.math.max import kotlin.math.round +import org.blitzortung.android.app.Main.Companion.LOG_TAG +import org.blitzortung.android.data.DataArea +import org.blitzortung.android.data.Parameters +import org.blitzortung.android.data.beans.GridParameters +import org.osmdroid.util.BoundingBox private const val MIN_DATA_SCALE = 5 @@ -24,109 +24,126 @@ private const val DATA_AREA_SIZE_FACTOR = 0.5 const val LOCAL_REGION_GRID_SIZE_THRESHOLD = 25000 @Singleton -class LocalData @Inject constructor() { - - var dataArea: DataArea? = null +class LocalData + @Inject + constructor() { + var dataArea: DataArea? = null + + private var gridParameters: GridParameters? = null + + fun updateParameters( + parameters: Parameters, + location: Location?, + ): Parameters { + return when (parameters.region) { + LOCAL_REGION -> { + if (location != null) { + Log.d(LOG_TAG, "LocalData.updateParameters() local") + val x = calculateLocalCoordinate(location.longitude) + val y = calculateLocalCoordinate(location.latitude) + parameters.copy(dataArea = DataArea(x, y, LOCAL_DATA_SCALE)) + } else { + Log.d(LOG_TAG, "LocalData.updateParameters() local -> global") + globalParameters(parameters) + } + } - private var gridParameters: GridParameters? = null + GLOBAL_REGION -> { + if (dataArea != null && parameters.gridSize < LOCAL_REGION_GRID_SIZE_THRESHOLD) { + Log.d(LOG_TAG, "LocalData.updateParameters() global -> local ($dataArea)") + parameters.copy(region = LOCAL_REGION, dataArea = dataArea) + } else { + Log.d(LOG_TAG, "LocalData.updateParameters() global") + globalParameters(parameters) + } + } - fun updateParameters(parameters: Parameters, location: Location?): Parameters { - return when (parameters.region) { - LOCAL_REGION -> { - if (location != null) { - Log.d(LOG_TAG, "LocalData.updateParameters() local") - val x = calculateLocalCoordinate(location.longitude) - val y = calculateLocalCoordinate(location.latitude) - parameters.copy(dataArea = DataArea(x, y, LOCAL_DATA_SCALE)) - } else { - Log.d(LOG_TAG, "LocalData.updateParameters() local -> global") - globalParameters(parameters) + else -> { + parameters } } + } - GLOBAL_REGION -> { - if (dataArea != null && parameters.gridSize < LOCAL_REGION_GRID_SIZE_THRESHOLD) { - Log.d(LOG_TAG, "LocalData.updateParameters() global -> local ($dataArea)") - parameters.copy(region = LOCAL_REGION, dataArea = dataArea) + private fun globalParameters(parameters: Parameters): Parameters = + parameters.copy( + region = GLOBAL_REGION, + dataArea = null, + gridSize = max(parameters.gridSize, LOCAL_REGION_GRID_SIZE_THRESHOLD), + ) + + fun update( + boundingBox: BoundingBox, + force: Boolean = false, + ): Boolean { + val dataArea = calculateDataArea(boundingBox) + + val gridParameters = gridParameters + val isOutside = if (gridParameters == null) false else this@LocalData.isOutside(boundingBox, gridParameters) + val isChanged = this.dataArea != dataArea + return if ( + gridParameters != null && isOutside && isChanged || + gridParameters == null && isChanged || + force + ) { + if (dataArea != null) { + Log.d( + LOG_TAG, + "LocalData.update() $dataArea from center ${round(boundingBox.centerLongitude * 100) / 100}, ${ + round(boundingBox.centerLatitude * 100) / 100 + } -> ${dataArea.x1}..${dataArea.x2} ${dataArea.y1}..${dataArea.y2}, span: ${ + round(boundingBox.longitudeSpanWithDateLine * 100) / 100 + }, ${round(boundingBox.latitudeSpan * 100) / 100} ", + ) } else { - Log.d(LOG_TAG, "LocalData.updateParameters() global") - globalParameters(parameters) + Log.d( + LOG_TAG, + "LocalData.update() disabled from center ${round(boundingBox.centerLongitude * 100) / 100}, ${ round(boundingBox.centerLatitude * 100) / 100 } ", + ) } - } - - else -> { - parameters + this.dataArea = dataArea + true + } else { + false } } - } - private fun globalParameters(parameters: Parameters): Parameters = parameters.copy( - region = GLOBAL_REGION, - dataArea = null, - gridSize = max(parameters.gridSize, LOCAL_REGION_GRID_SIZE_THRESHOLD) - ) - - fun update(boundingBox: BoundingBox, force: Boolean = false): Boolean { - val dataArea = calculateDataArea(boundingBox) - - val gridParameters = gridParameters - val isOutside = if (gridParameters == null) false else this@LocalData.isOutside(boundingBox, gridParameters) - val isChanged = this.dataArea != dataArea - return if ( - gridParameters != null && isOutside && isChanged || - gridParameters == null && isChanged || - force - ) { - if (dataArea != null) { - Log.d( - LOG_TAG, - "LocalData.update() $dataArea from center ${round(boundingBox.centerLongitude * 100) / 100}, ${ - round(boundingBox.centerLatitude * 100) / 100 - } -> ${dataArea.x1}..${dataArea.x2} ${dataArea.y1}..${dataArea.y2}, span: ${ - round(boundingBox.longitudeSpanWithDateLine * 100) / 100 - }, ${round(boundingBox.latitudeSpan * 100) / 100} " - ) + private fun calculateDataArea(boundingBox: BoundingBox): DataArea? { + val scale = calculateDataAreaScale(boundingBox) + return if (scale != null) { + val x = calculateLocalCoordinate(boundingBox.centerLongitude, scale) + val y = calculateLocalCoordinate(boundingBox.centerLatitude, scale) + DataArea(x, y, scale) } else { - Log.d( LOG_TAG, "LocalData.update() disabled from center ${round(boundingBox.centerLongitude * 100) / 100}, ${ round(boundingBox.centerLatitude * 100) / 100 } " ) + null } - this.dataArea = dataArea - true - } else { - false } - } - private fun calculateDataArea(boundingBox: BoundingBox): DataArea? { - val scale = calculateDataAreaScale(boundingBox) - return if (scale != null) { - val x = calculateLocalCoordinate(boundingBox.centerLongitude, scale) - val y = calculateLocalCoordinate(boundingBox.centerLatitude, scale) - DataArea(x, y, scale) - } else { - null + private fun calculateDataAreaScale(boundingBox: BoundingBox): Int? { + val maxExtent = max(boundingBox.longitudeSpanWithDateLine, boundingBox.latitudeSpan) + val targetValue = DATA_AREA_SIZE_FACTOR * maxExtent + val dataArea = (ceil(targetValue / MIN_DATA_SCALE) * MIN_DATA_SCALE).toInt() + return if (dataArea <= MAX_DATA_SCALE) dataArea else null } - } - private fun calculateDataAreaScale(boundingBox: BoundingBox): Int? { - val maxExtent = max(boundingBox.longitudeSpanWithDateLine, boundingBox.latitudeSpan) - val targetValue = DATA_AREA_SIZE_FACTOR * maxExtent - val dataArea = (ceil(targetValue / MIN_DATA_SCALE) * MIN_DATA_SCALE).toInt() - return if (dataArea <= MAX_DATA_SCALE) dataArea else null - } - - private fun isOutside(boundingBox: BoundingBox, gridParameters: GridParameters): Boolean { - return boundingBox.lonWest < gridParameters.longitudeStart || + private fun isOutside( + boundingBox: BoundingBox, + gridParameters: GridParameters, + ): Boolean { + return boundingBox.lonWest < gridParameters.longitudeStart || boundingBox.lonEast > gridParameters.longitudeEnd || boundingBox.latNorth > gridParameters.latitudeStart || boundingBox.latSouth < gridParameters.latitudeEnd - } + } - fun storeResult(gridParameters: GridParameters?) { - this.gridParameters = gridParameters + fun storeResult(gridParameters: GridParameters?) { + this.gridParameters = gridParameters + } } -} -fun calculateLocalCoordinate(coordinate: Double, scale: Int = LOCAL_DATA_SCALE): Int { +fun calculateLocalCoordinate( + coordinate: Double, + scale: Int = LOCAL_DATA_SCALE, +): Int { return (coordinate / scale).toInt() - if (coordinate < 0) 1 else 0 } diff --git a/app/src/main/java/org/blitzortung/android/data/provider/blitzortung/BlitzortungHttpDataProvider.kt b/app/src/main/java/org/blitzortung/android/data/provider/blitzortung/BlitzortungHttpDataProvider.kt index debf409ef..55fb46672 100644 --- a/app/src/main/java/org/blitzortung/android/data/provider/blitzortung/BlitzortungHttpDataProvider.kt +++ b/app/src/main/java/org/blitzortung/android/data/provider/blitzortung/BlitzortungHttpDataProvider.kt @@ -20,20 +20,6 @@ package org.blitzortung.android.data.provider.blitzortung import android.content.SharedPreferences import android.util.Log -import org.blitzortung.android.app.Main -import org.blitzortung.android.app.view.OnSharedPreferenceChangeListener -import org.blitzortung.android.app.view.PreferenceKey -import org.blitzortung.android.app.view.get -import org.blitzortung.android.data.Flags -import org.blitzortung.android.data.History -import org.blitzortung.android.data.Parameters -import org.blitzortung.android.data.beans.Station -import org.blitzortung.android.data.beans.Strike -import org.blitzortung.android.data.provider.DataProviderType -import org.blitzortung.android.data.provider.data.DataProvider -import org.blitzortung.android.data.provider.data.DataProvider.DataRetriever -import org.blitzortung.android.data.provider.data.initializeResult -import org.blitzortung.android.data.provider.result.ResultEvent import java.io.BufferedReader import java.io.ByteArrayInputStream import java.io.FileNotFoundException @@ -50,202 +36,234 @@ import java.util.zip.GZIPInputStream import javax.inject.Inject import javax.inject.Singleton import kotlin.math.max +import org.blitzortung.android.app.Main +import org.blitzortung.android.app.view.OnSharedPreferenceChangeListener +import org.blitzortung.android.app.view.PreferenceKey +import org.blitzortung.android.app.view.get +import org.blitzortung.android.data.Flags +import org.blitzortung.android.data.History +import org.blitzortung.android.data.Parameters +import org.blitzortung.android.data.beans.Station +import org.blitzortung.android.data.beans.Strike +import org.blitzortung.android.data.provider.DataProviderType +import org.blitzortung.android.data.provider.data.DataProvider +import org.blitzortung.android.data.provider.data.DataProvider.DataRetriever +import org.blitzortung.android.data.provider.data.initializeResult +import org.blitzortung.android.data.provider.result.ResultEvent @Singleton -class BlitzortungHttpDataProvider @Inject constructor( - preferences: SharedPreferences, - private val urlFormatter: UrlFormatter, - mapBuilderFactory: MapBuilderFactory -) : OnSharedPreferenceChangeListener, DataProvider { - - private val strikeMapBuilder: MapBuilder = mapBuilderFactory.createStrikeMapBuilder() - private val stationMapBuilder: MapBuilder = mapBuilderFactory.createStationMapBuilder() - private var latestTime: Long = 0 - private var strikes: List = emptyList() - private var parameters: Parameters? = null - - private lateinit var username: String - private lateinit var password: String - - init { - preferences.registerOnSharedPreferenceChangeListener(this) - onSharedPreferenceChanged(preferences, PreferenceKey.USERNAME, PreferenceKey.PASSWORD) - } - - private fun readFromUrl(type: Type, region: Int, intervalTime: Calendar? = null): BufferedReader { - - val useGzipCompression = type == Type.STATIONS - - val reader: BufferedReader - - val urlString = urlFormatter.getUrlFor(type, region, intervalTime, useGzipCompression) - - try { - val url = URL(urlString) - val connection = url.openConnection() - connection.connectTimeout = 40000 - connection.readTimeout = 40000 - connection.allowUserInteraction = false - var inputStream = connection.inputStream - if (useGzipCompression) { - inputStream = GZIPInputStream(inputStream) - } +class BlitzortungHttpDataProvider + @Inject + constructor( + preferences: SharedPreferences, + private val urlFormatter: UrlFormatter, + mapBuilderFactory: MapBuilderFactory, + ) : OnSharedPreferenceChangeListener, DataProvider { + private val strikeMapBuilder: MapBuilder = mapBuilderFactory.createStrikeMapBuilder() + private val stationMapBuilder: MapBuilder = mapBuilderFactory.createStationMapBuilder() + private var latestTime: Long = 0 + private var strikes: List = emptyList() + private var parameters: Parameters? = null + + private lateinit var username: String + private lateinit var password: String + + init { + preferences.registerOnSharedPreferenceChangeListener(this) + onSharedPreferenceChanged(preferences, PreferenceKey.USERNAME, PreferenceKey.PASSWORD) + } - reader = inputStream.bufferedReader() - } catch (e: Exception) { - when (e) { - is FileNotFoundException -> { - Log.w(Main.LOG_TAG, "BlitzortungHttpDataProvider.readFromUrl() $urlString not found") - return BufferedReader(InputStreamReader(ByteArrayInputStream("".toByteArray()))) + private fun readFromUrl( + type: Type, + region: Int, + intervalTime: Calendar? = null, + ): BufferedReader { + val useGzipCompression = type == Type.STATIONS + + val reader: BufferedReader + + val urlString = urlFormatter.getUrlFor(type, region, intervalTime, useGzipCompression) + + try { + val url = URL(urlString) + val connection = url.openConnection() + connection.connectTimeout = 40000 + connection.readTimeout = 40000 + connection.allowUserInteraction = false + var inputStream = connection.inputStream + if (useGzipCompression) { + inputStream = GZIPInputStream(inputStream) } - else -> { - Log.w(Main.LOG_TAG, "BlitzortungHttpDataProvider.readFromUrl() $urlString failed") - throw RuntimeException(e) + reader = inputStream.bufferedReader() + } catch (e: Exception) { + when (e) { + is FileNotFoundException -> { + Log.w(Main.LOG_TAG, "BlitzortungHttpDataProvider.readFromUrl() $urlString not found") + return BufferedReader(InputStreamReader(ByteArrayInputStream("".toByteArray()))) + } + + else -> { + Log.w(Main.LOG_TAG, "BlitzortungHttpDataProvider.readFromUrl() $urlString failed") + throw RuntimeException(e) + } } } + + Log.v(Main.LOG_TAG, "BlitzortungHttpDataProvider.readFromUrl() $urlString") + + return reader } - Log.v(Main.LOG_TAG, "BlitzortungHttpDataProvider.readFromUrl() $urlString") + /** + * Used to retrieve Data from Blitzortung + * @param readerSeq A sequence of nullable BufferedReader, which the data is read from + * @param parse A Lambda which receives a sequence of lines from a buffered reader and transforms them into a sequence of T + * @return Returns a list of parsed T's + */ + private fun retrieveData( + logMessage: String, + readerSeq: Sequence, + parse: (String) -> T?, + ): List { + var size = 0 + + val strikeSequence: Sequence = + readerSeq.filterNotNull().flatMap { reader -> + reader.lineSequence().mapNotNull { line -> + size += line.length + + parse(line) + } + } - return reader - } + val strikeList = + try { + strikeSequence.toList() + } catch (e: SocketException) { + Log.w(Main.LOG_TAG, e) + emptyList() + } catch (e: SocketTimeoutException) { + Log.w(Main.LOG_TAG, e) + emptyList() + } - /** - * Used to retrieve Data from Blitzortung - * @param readerSeq A sequence of nullable BufferedReader, which the data is read from - * @param parse A Lambda which receives a sequence of lines from a buffered reader and transforms them into a sequence of T - * @return Returns a list of parsed T's - */ - private fun retrieveData( - logMessage: String, - readerSeq: Sequence, - parse: (String) -> T? - ): List { - var size = 0 - - val strikeSequence: Sequence = readerSeq.filterNotNull().flatMap { reader -> - reader.lineSequence().mapNotNull { line -> - size += line.length - - parse(line) - } - } + Log.v(Main.LOG_TAG, logMessage.format(size, strikeList.count())) - val strikeList = try { - strikeSequence.toList() - } catch (e: SocketException) { - Log.w(Main.LOG_TAG, e) - emptyList() - } catch (e: SocketTimeoutException) { - Log.w(Main.LOG_TAG, e) - emptyList() + return strikeList } - Log.v(Main.LOG_TAG, logMessage.format(size, strikeList.count())) + override val type: DataProviderType = DataProviderType.HTTP - return strikeList - } + override fun reset() { + latestTime = 0L + strikes = emptyList() + } - override val type: DataProviderType = DataProviderType.HTTP + enum class Type { + STRIKES, + STATIONS, + } - override fun reset() { - latestTime = 0L - strikes = emptyList() - } + override fun retrieveData(retrieve: DataRetriever.() -> T): T { + return Retriever().retrieve() + } - enum class Type { - STRIKES, STATIONS - } + private inner class Retriever : DataRetriever { + override fun getStrikes( + parameters: Parameters, + history: History?, + flags: Flags, + ): ResultEvent { + var result = initializeResult(parameters, history, flags) + + if (parameters != this@BlitzortungHttpDataProvider.parameters) { + this@BlitzortungHttpDataProvider.parameters = parameters + reset() + } - override fun retrieveData(retrieve: DataRetriever.() -> T): T { - return Retriever().retrieve() - } + val intervalDuration = parameters.intervalDuration + val intervalOffset = parameters.intervalOffset + val region = parameters.region + + val tz = TimeZone.getTimeZone("UTC") + val intervalTime = GregorianCalendar(tz) + + val millisecondsPerMinute = 60 * 1000L + val endTime = System.currentTimeMillis() + intervalOffset * millisecondsPerMinute + val startTime = endTime - intervalDuration * millisecondsPerMinute + val intervalSequence = + createTimestampSequence(10 * millisecondsPerMinute, max(startTime, latestTime), endTime) + + Authenticator.setDefault(MyAuthenticator()) + + val strikes = + retrieveData( + "BlitzortungHttpDataProvider.getStrikes() read %d bytes (%d new strikes) from region $region", + intervalSequence.map { + intervalTime.timeInMillis = it + + return@map readFromUrl(Type.STRIKES, region, intervalTime) + }, + ) { strikeMapBuilder.buildFromLine(it) } + + if (latestTime > 0L) { + result = result.copy(updated = strikes.size) + val expireTime = result.referenceTime - (intervalDuration - intervalOffset) * millisecondsPerMinute + this@BlitzortungHttpDataProvider.strikes = + this@BlitzortungHttpDataProvider.strikes.filter { it.timestamp > expireTime } + this@BlitzortungHttpDataProvider.strikes += strikes + } else { + this@BlitzortungHttpDataProvider.strikes = strikes + } - private inner class Retriever : DataRetriever { - override fun getStrikes(parameters: Parameters, history: History?, flags: Flags): ResultEvent { - var result = initializeResult(parameters, history, flags) + if (strikes.isNotEmpty()) { + latestTime = endTime - millisecondsPerMinute + Log.v(Main.LOG_TAG, "BlitzortungHttpDataProvider.getStrikes() set latest time to $latestTime") + } - if (parameters != this@BlitzortungHttpDataProvider.parameters) { - this@BlitzortungHttpDataProvider.parameters = parameters - reset() - } + result = result.copy(strikes = this@BlitzortungHttpDataProvider.strikes, referenceTime = endTime) - val intervalDuration = parameters.intervalDuration - val intervalOffset = parameters.intervalOffset - val region = parameters.region - - val tz = TimeZone.getTimeZone("UTC") - val intervalTime = GregorianCalendar(tz) - - val millisecondsPerMinute = 60 * 1000L - val endTime = System.currentTimeMillis() + intervalOffset * millisecondsPerMinute - val startTime = endTime - intervalDuration * millisecondsPerMinute - val intervalSequence = - createTimestampSequence(10 * millisecondsPerMinute, max(startTime, latestTime), endTime) - - Authenticator.setDefault(MyAuthenticator()) - - val strikes = - retrieveData( - "BlitzortungHttpDataProvider.getStrikes() read %d bytes (%d new strikes) from region $region", - intervalSequence.map { - intervalTime.timeInMillis = it - - return@map readFromUrl(Type.STRIKES, region, intervalTime) - }) { strikeMapBuilder.buildFromLine(it) } - - if (latestTime > 0L) { - result = result.copy(updated = strikes.size) - val expireTime = result.referenceTime - (intervalDuration - intervalOffset) * millisecondsPerMinute - this@BlitzortungHttpDataProvider.strikes = - this@BlitzortungHttpDataProvider.strikes.filter { it.timestamp > expireTime } - this@BlitzortungHttpDataProvider.strikes += strikes - } else { - this@BlitzortungHttpDataProvider.strikes = strikes + return result } - if (strikes.isNotEmpty()) { - latestTime = endTime - millisecondsPerMinute - Log.v(Main.LOG_TAG, "BlitzortungHttpDataProvider.getStrikes() set latest time to $latestTime") + override fun getStrikesGrid( + parameters: Parameters, + history: History?, + flags: Flags, + ): ResultEvent { + return initializeResult(parameters, history, flags) } - result = result.copy(strikes = this@BlitzortungHttpDataProvider.strikes, referenceTime = endTime) - - return result - } - - override fun getStrikesGrid(parameters: Parameters, history: History?, flags: Flags): ResultEvent { - return initializeResult(parameters, history, flags) - } - - override fun getStations(region: Int): List { - - Authenticator.setDefault(MyAuthenticator()) + override fun getStations(region: Int): List { + Authenticator.setDefault(MyAuthenticator()) - return retrieveData( - "BlitzortungHttpDataProvider.getStations() read %d bytes (%d stations) from region $region", - sequenceOf(readFromUrl(Type.STATIONS, region)) - ) { - stationMapBuilder.buildFromLine(it) + return retrieveData( + "BlitzortungHttpDataProvider.getStations() read %d bytes (%d stations) from region $region", + sequenceOf(readFromUrl(Type.STATIONS, region)), + ) { + stationMapBuilder.buildFromLine(it) + } } } - } - private inner class MyAuthenticator : Authenticator() { - override fun getPasswordAuthentication(): PasswordAuthentication { - return PasswordAuthentication(username, password.toCharArray()) + private inner class MyAuthenticator : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication { + return PasswordAuthentication(username, password.toCharArray()) + } } - } - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: PreferenceKey) { - when (key) { - PreferenceKey.USERNAME -> username = sharedPreferences.get(key, "") + override fun onSharedPreferenceChanged( + sharedPreferences: SharedPreferences, + key: PreferenceKey, + ) { + when (key) { + PreferenceKey.USERNAME -> username = sharedPreferences.get(key, "") - PreferenceKey.PASSWORD -> password = sharedPreferences.get(key, "") + PreferenceKey.PASSWORD -> password = sharedPreferences.get(key, "") - else -> { + else -> { + } } } } -} diff --git a/app/src/main/java/org/blitzortung/android/data/provider/blitzortung/MapBuilder.kt b/app/src/main/java/org/blitzortung/android/data/provider/blitzortung/MapBuilder.kt index 9e1afd7b8..5de31827a 100644 --- a/app/src/main/java/org/blitzortung/android/data/provider/blitzortung/MapBuilder.kt +++ b/app/src/main/java/org/blitzortung/android/data/provider/blitzortung/MapBuilder.kt @@ -26,7 +26,6 @@ import org.blitzortung.android.app.Main import org.blitzortung.android.util.isAtLeast abstract class MapBuilder internal constructor(private val lineSplitter: (String) -> Array) { - protected val keyValueBuilderMap: HashMap) -> Unit> = HashMap() fun buildFromLine(line: String): T? { @@ -57,16 +56,17 @@ abstract class MapBuilder internal constructor(private val lineSplitter: (Str } private fun extractValues(htmlString: String): Array? { - val valueString = if (isAtLeast(Build.VERSION_CODES.N)) { - Html.fromHtml(htmlString, FROM_HTML_MODE_COMPACT).toString() - } else { - try { - Html.fromHtml(htmlString).toString() - } catch (throwable: Throwable) { - Log.w(Main.LOG_TAG, throwable) - return null + val valueString = + if (isAtLeast(Build.VERSION_CODES.N)) { + Html.fromHtml(htmlString, FROM_HTML_MODE_COMPACT).toString() + } else { + try { + Html.fromHtml(htmlString).toString() + } catch (throwable: Throwable) { + Log.w(Main.LOG_TAG, throwable) + return null + } } - } return valueString.split(SPLIT_PATTERN).dropLastWhile { it.isEmpty() }.toTypedArray() } diff --git a/app/src/main/java/org/blitzortung/android/data/provider/blitzortung/MapBuilderFactory.kt b/app/src/main/java/org/blitzortung/android/data/provider/blitzortung/MapBuilderFactory.kt index a51815c0f..f1be426bf 100644 --- a/app/src/main/java/org/blitzortung/android/data/provider/blitzortung/MapBuilderFactory.kt +++ b/app/src/main/java/org/blitzortung/android/data/provider/blitzortung/MapBuilderFactory.kt @@ -18,28 +18,26 @@ package org.blitzortung.android.data.provider.blitzortung +import java.util.regex.Pattern +import javax.inject.Inject +import javax.inject.Singleton import org.blitzortung.android.data.beans.DefaultStrike import org.blitzortung.android.data.beans.Station import org.blitzortung.android.data.beans.Strike import org.blitzortung.android.util.TimeFormat -import java.util.regex.Pattern -import javax.inject.Inject -import javax.inject.Singleton class MapBuilderFailedException(message: String) : Throwable(message) @Singleton class MapBuilderFactory( private val strikeLineSplitter: (String) -> Array, - private val stationLineSplitter: (String) -> Array + private val stationLineSplitter: (String) -> Array, ) { - @Inject constructor() : this(::lineSplitter, ::stationLineSplitter) fun createStrikeMapBuilder(): MapBuilder { return object : MapBuilder(strikeLineSplitter) { - init { setBuilderMap() } @@ -83,7 +81,7 @@ class MapBuilderFactory( altitude, amplitude, stationCount, - lateralError.toDouble() + lateralError.toDouble(), ) } } @@ -91,7 +89,6 @@ class MapBuilderFactory( fun createStationMapBuilder(): MapBuilder { return object : MapBuilder(stationLineSplitter) { - init { setBuilderMap() } @@ -140,4 +137,4 @@ fun stationLineSplitter(text: String): Array { } } return matchList.toArray(arrayOfNulls(matchList.size)) -} \ No newline at end of file +} diff --git a/app/src/main/java/org/blitzortung/android/data/provider/blitzortung/TimestampIterator.kt b/app/src/main/java/org/blitzortung/android/data/provider/blitzortung/TimestampIterator.kt index 7b27540e9..48fe43289 100644 --- a/app/src/main/java/org/blitzortung/android/data/provider/blitzortung/TimestampIterator.kt +++ b/app/src/main/java/org/blitzortung/android/data/provider/blitzortung/TimestampIterator.kt @@ -21,9 +21,8 @@ package org.blitzortung.android.data.provider.blitzortung class TimestampIterator( private val intervalLength: Long, startTime: Long, - private val endTime: Long + private val endTime: Long, ) : Iterator { - private var currentTime: Long = roundTime(startTime) private fun roundTime(time: Long): Long { @@ -49,6 +48,10 @@ class TimestampIterator( * @param intervalLength Interval length * @param startTime Start time of the sequence */ -internal fun createTimestampSequence(intervalLength: Long, startTime: Long, endTime: Long): Sequence { +internal fun createTimestampSequence( + intervalLength: Long, + startTime: Long, + endTime: Long, +): Sequence { return Sequence { TimestampIterator(intervalLength, startTime, endTime) } } diff --git a/app/src/main/java/org/blitzortung/android/data/provider/blitzortung/UrlFormatter.kt b/app/src/main/java/org/blitzortung/android/data/provider/blitzortung/UrlFormatter.kt index 1a019efda..fd263d724 100644 --- a/app/src/main/java/org/blitzortung/android/data/provider/blitzortung/UrlFormatter.kt +++ b/app/src/main/java/org/blitzortung/android/data/provider/blitzortung/UrlFormatter.kt @@ -25,21 +25,23 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class UrlFormatter @Inject constructor() { - fun getUrlFor( - type: BlitzortungHttpDataProvider.Type, - region: Int, - intervalTime: Calendar?, - useGzipCompression: Boolean - ): String { - - val localPath: String = if (type === BlitzortungHttpDataProvider.Type.STRIKES) { - "Strikes_%d/".format(region) + DateFormat.format("yyyy/MM/dd/kk/mm", intervalTime!!) + ".log" - } else { - type.name.lowercase(Locale.getDefault()) + ".txt" +class UrlFormatter + @Inject + constructor() { + fun getUrlFor( + type: BlitzortungHttpDataProvider.Type, + region: Int, + intervalTime: Calendar?, + useGzipCompression: Boolean, + ): String { + val localPath: String = + if (type === BlitzortungHttpDataProvider.Type.STRIKES) { + "Strikes_%d/".format(region) + DateFormat.format("yyyy/MM/dd/kk/mm", intervalTime!!) + ".log" + } else { + type.name.lowercase(Locale.getDefault()) + ".txt" + } + + val urlFormatString = "http://data.blitzortung.org/Data/Protected/%s%s" + return urlFormatString.format(localPath, if (useGzipCompression) ".gz" else "") } - - val urlFormatString = "http://data.blitzortung.org/Data/Protected/%s%s" - return urlFormatString.format(localPath, if (useGzipCompression) ".gz" else "") } -} diff --git a/app/src/main/java/org/blitzortung/android/data/provider/data/DataProvider.kt b/app/src/main/java/org/blitzortung/android/data/provider/data/DataProvider.kt index b58d920dd..f5429e805 100644 --- a/app/src/main/java/org/blitzortung/android/data/provider/data/DataProvider.kt +++ b/app/src/main/java/org/blitzortung/android/data/provider/data/DataProvider.kt @@ -26,7 +26,6 @@ import org.blitzortung.android.data.provider.DataProviderType import org.blitzortung.android.data.provider.result.ResultEvent interface DataProvider { - val type: DataProviderType fun reset() @@ -34,11 +33,25 @@ interface DataProvider { fun retrieveData(retrieve: DataRetriever.() -> T): T interface DataRetriever { - fun getStrikes(parameters: Parameters, history: History?, flags: Flags): ResultEvent - fun getStrikesGrid(parameters: Parameters, history: History?, flags: Flags): ResultEvent + fun getStrikes( + parameters: Parameters, + history: History?, + flags: Flags, + ): ResultEvent + + fun getStrikesGrid( + parameters: Parameters, + history: History?, + flags: Flags, + ): ResultEvent + fun getStations(region: Int): List } } -fun initializeResult(parameters: Parameters, history: History?, flags: Flags): ResultEvent = +fun initializeResult( + parameters: Parameters, + history: History?, + flags: Flags, +): ResultEvent = ResultEvent(referenceTime = System.currentTimeMillis(), parameters = parameters, history = history, flags = flags) diff --git a/app/src/main/java/org/blitzortung/android/data/provider/result/ResultEvent.kt b/app/src/main/java/org/blitzortung/android/data/provider/result/ResultEvent.kt index 66c5b4664..bc3817f28 100644 --- a/app/src/main/java/org/blitzortung/android/data/provider/result/ResultEvent.kt +++ b/app/src/main/java/org/blitzortung/android/data/provider/result/ResultEvent.kt @@ -18,13 +18,13 @@ package org.blitzortung.android.data.provider.result +import java.io.Serializable import org.blitzortung.android.data.Flags import org.blitzortung.android.data.History import org.blitzortung.android.data.Parameters import org.blitzortung.android.data.beans.GridParameters import org.blitzortung.android.data.beans.Station import org.blitzortung.android.data.beans.Strike -import java.io.Serializable data class ResultEvent( val strikes: List? = null, @@ -39,7 +39,6 @@ data class ResultEvent( val flags: Flags, val sequenceNumber: Long? = null, ) : DataEvent, Serializable { - fun containsRealtimeData(): Boolean { return parameters.isRealtime() } diff --git a/app/src/main/java/org/blitzortung/android/data/provider/standard/DataBuilder.kt b/app/src/main/java/org/blitzortung/android/data/provider/standard/DataBuilder.kt index 9ef45298d..e3ebd5f9b 100644 --- a/app/src/main/java/org/blitzortung/android/data/provider/standard/DataBuilder.kt +++ b/app/src/main/java/org/blitzortung/android/data/provider/standard/DataBuilder.kt @@ -29,8 +29,10 @@ import org.json.JSONException import org.json.JSONObject internal class DataBuilder { - - fun createDefaultStrike(referenceTimestamp: Long, jsonArray: JSONArray): Strike { + fun createDefaultStrike( + referenceTimestamp: Long, + jsonArray: JSONArray, + ): Strike { try { return DefaultStrike( timestamp = referenceTimestamp - 1000 * jsonArray.getInt(0), @@ -43,11 +45,13 @@ internal class DataBuilder { } catch (e: JSONException) { throw IllegalStateException("error with JSON format while parsing strike data", e) } - } @Throws(JSONException::class) - fun createGridParameters(response: JSONObject, gridSize: Int): GridParameters { + fun createGridParameters( + response: JSONObject, + gridSize: Int, + ): GridParameters { return GridParameters( longitudeStart = response.getDouble("x0"), latitudeStart = response.getDouble("y1"), @@ -55,7 +59,7 @@ internal class DataBuilder { latitudeDelta = response.getDouble("yd"), longitudeBins = response.getInt("xc"), latitudeBins = response.getInt("yc"), - size = gridSize + size = gridSize, ) } @@ -63,13 +67,13 @@ internal class DataBuilder { fun createGridElement( gridParameters: GridParameters, referenceTimestamp: Long, - jsonArray: JSONArray + jsonArray: JSONArray, ): GridElement { return GridElement( timestamp = referenceTimestamp + 1000 * jsonArray.getInt(3), longitude = gridParameters.getCenterLongitude(jsonArray.getInt(0)), latitude = gridParameters.getCenterLatitude(jsonArray.getInt(1)), - multiplicity = jsonArray.getInt(2) + multiplicity = jsonArray.getInt(2), ) } @@ -83,7 +87,6 @@ internal class DataBuilder { longitude = jsonArray.getDouble(3) latitude = jsonArray.getDouble(4) if (jsonArray.length() >= 6) { - val offlineSinceString = jsonArray.getString(5) if (offlineSinceString.isNotEmpty()) { offlineSince = TimeFormat.parseTimeWithMilliseconds(offlineSinceString) diff --git a/app/src/main/java/org/blitzortung/android/data/provider/standard/JsonRpcData.kt b/app/src/main/java/org/blitzortung/android/data/provider/standard/JsonRpcData.kt index cf447c08e..86d530b99 100644 --- a/app/src/main/java/org/blitzortung/android/data/provider/standard/JsonRpcData.kt +++ b/app/src/main/java/org/blitzortung/android/data/provider/standard/JsonRpcData.kt @@ -1,19 +1,18 @@ package org.blitzortung.android.data.provider.standard +import java.net.URL +import kotlin.math.max import org.blitzortung.android.data.Parameters import org.blitzortung.android.data.provider.GLOBAL_REGION import org.blitzortung.android.data.provider.LOCAL_REGION import org.blitzortung.android.data.provider.LOCAL_REGION_GRID_SIZE_THRESHOLD import org.blitzortung.android.jsonrpc.JsonRpcClient import org.blitzortung.android.jsonrpc.JsonRpcResponse -import java.net.URL -import kotlin.math.max class JsonRpcData( private val client: JsonRpcClient, private val serviceUrl: URL, ) { - fun requestData(parameters: Parameters): JsonRpcResponse { val intervalDuration = parameters.intervalDuration val intervalOffset = parameters.intervalOffset @@ -24,16 +23,17 @@ class JsonRpcData( return when (region) { GLOBAL_REGION -> { - val jsonRpcResponse: JsonRpcResponse = client.call( - serviceUrl, - "get_global_strikes_grid", - intervalDuration, - max(gridSize, LOCAL_REGION_GRID_SIZE_THRESHOLD), - intervalOffset, - countThreshold - ) + val jsonRpcResponse: JsonRpcResponse = + client.call( + serviceUrl, + "get_global_strikes_grid", + intervalDuration, + max(gridSize, LOCAL_REGION_GRID_SIZE_THRESHOLD), + intervalOffset, + countThreshold, + ) with( - jsonRpcResponse + jsonRpcResponse, ) { data.put("y1", 0.0) data.put("x0", 0.0) @@ -63,10 +63,9 @@ class JsonRpcData( gridSize, intervalOffset, region, - countThreshold + countThreshold, ) } } - } } diff --git a/app/src/main/java/org/blitzortung/android/data/provider/standard/JsonRpcDataProvider.kt b/app/src/main/java/org/blitzortung/android/data/provider/standard/JsonRpcDataProvider.kt index 61ceb3495..b914f6da1 100644 --- a/app/src/main/java/org/blitzortung/android/data/provider/standard/JsonRpcDataProvider.kt +++ b/app/src/main/java/org/blitzortung/android/data/provider/standard/JsonRpcDataProvider.kt @@ -20,6 +20,13 @@ package org.blitzortung.android.data.provider.standard import android.content.SharedPreferences import android.util.Log +import java.net.URI +import java.net.URL +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone +import javax.inject.Inject +import javax.inject.Singleton import org.blitzortung.android.app.Main import org.blitzortung.android.app.Main.Companion.LOG_TAG import org.blitzortung.android.app.view.OnSharedPreferenceChangeListener @@ -41,202 +48,217 @@ import org.blitzortung.android.util.TimeFormat import org.json.JSONArray import org.json.JSONException import org.json.JSONObject -import java.net.URI -import java.net.URL -import java.text.SimpleDateFormat -import java.util.Locale -import java.util.TimeZone -import javax.inject.Inject -import javax.inject.Singleton @Singleton -class JsonRpcDataProvider @Inject constructor( - preferences: SharedPreferences, - private val client: JsonRpcClient -) : OnSharedPreferenceChangeListener, DataProvider { - - private lateinit var serviceUrl: URL - private val dataBuilder: DataBuilder = DataBuilder() - private var nextId = 0 +class JsonRpcDataProvider + @Inject + constructor( + preferences: SharedPreferences, + private val client: JsonRpcClient, + ) : OnSharedPreferenceChangeListener, DataProvider { + private lateinit var serviceUrl: URL + private val dataBuilder: DataBuilder = DataBuilder() + private var nextId = 0 - init { - preferences.registerOnSharedPreferenceChangeListener(this) - onSharedPreferenceChanged(preferences, PreferenceKey.SERVICE_URL) - - Log.v(Main.LOG_TAG, "JsonRpcDataProvider($serviceUrl)") - } + init { + preferences.registerOnSharedPreferenceChangeListener(this) + onSharedPreferenceChanged(preferences, PreferenceKey.SERVICE_URL) - override val type: DataProviderType = DataProviderType.RPC + Log.v(Main.LOG_TAG, "JsonRpcDataProvider($serviceUrl)") + } - override fun reset() { - nextId = 0 - } + override val type: DataProviderType = DataProviderType.RPC - @Throws(JSONException::class) - private fun addStrikes(response: JsonRpcResponse, result: ResultEvent): ResultEvent { - val isIncremental = nextId != 0 - val strikes = ArrayList() - val referenceTimestamp = getReferenceTimestamp(response.data) - val strikesArray = response.data.get("s") as JSONArray - for (i in 0 until strikesArray.length()) { - strikes.add(dataBuilder.createDefaultStrike(referenceTimestamp, strikesArray.getJSONArray(i))) + override fun reset() { + nextId = 0 } - if (response.data.has("next")) { - nextId = response.data.get("next") as Int + + @Throws(JSONException::class) + private fun addStrikes( + response: JsonRpcResponse, + result: ResultEvent, + ): ResultEvent { + val isIncremental = nextId != 0 + val strikes = ArrayList() + val referenceTimestamp = getReferenceTimestamp(response.data) + val strikesArray = response.data.get("s") as JSONArray + for (i in 0 until strikesArray.length()) { + strikes.add(dataBuilder.createDefaultStrike(referenceTimestamp, strikesArray.getJSONArray(i))) + } + if (response.data.has("next")) { + nextId = response.data.get("next") as Int + } + return result.copy(strikes = strikes, updated = if (isIncremental) strikes.size else -1) } - return result.copy(strikes = strikes, updated = if (isIncremental) strikes.size else -1) - } - private fun addGridData(response: JsonRpcResponse, result: ResultEvent, size: Int): ResultEvent { - val gridParameters = dataBuilder.createGridParameters(response.data, size) - val referenceTimestamp = getReferenceTimestamp(response.data) - - val strikesArray = response.data.get("r") as JSONArray - val strikes = ArrayList() - for (i in 0 until strikesArray.length()) { - strikes.add( - dataBuilder.createGridElement( - gridParameters, - referenceTimestamp, - strikesArray.getJSONArray(i) + private fun addGridData( + response: JsonRpcResponse, + result: ResultEvent, + size: Int, + ): ResultEvent { + val gridParameters = dataBuilder.createGridParameters(response.data, size) + val referenceTimestamp = getReferenceTimestamp(response.data) + + val strikesArray = response.data.get("r") as JSONArray + val strikes = ArrayList() + for (i in 0 until strikesArray.length()) { + strikes.add( + dataBuilder.createGridElement( + gridParameters, + referenceTimestamp, + strikesArray.getJSONArray(i), + ), ) - ) - } + } - return result.copy(strikes = strikes, gridParameters = gridParameters, referenceTime = referenceTimestamp) - } + return result.copy(strikes = strikes, gridParameters = gridParameters, referenceTime = referenceTimestamp) + } - @Throws(JSONException::class) - private fun getReferenceTimestamp(response: JSONObject): Long { - return TimeFormat.parseTime(response.getString("t")) - } + @Throws(JSONException::class) + private fun getReferenceTimestamp(response: JSONObject): Long { + return TimeFormat.parseTime(response.getString("t")) + } - @Throws(JSONException::class) - private fun addStrikesHistogram(response: JSONObject, result: ResultEvent): ResultEvent { - var resultVar = result + @Throws(JSONException::class) + private fun addStrikesHistogram( + response: JSONObject, + result: ResultEvent, + ): ResultEvent { + var resultVar = result - if (response.has("h")) { - val histogramArray = response.get("h") as JSONArray + if (response.has("h")) { + val histogramArray = response.get("h") as JSONArray - val histogram = IntArray(histogramArray.length()) + val histogram = IntArray(histogramArray.length()) - for (i in 0 until histogramArray.length()) { - histogram[i] = histogramArray.getInt(i) + for (i in 0 until histogramArray.length()) { + histogram[i] = histogramArray.getInt(i) + } + resultVar = resultVar.copy(histogram = histogram) } - resultVar = resultVar.copy(histogram = histogram) - } - return resultVar - } - - override fun retrieveData(retrieve: DataRetriever.() -> T): T = Retriever(client).retrieve() + return resultVar + } + override fun retrieveData(retrieve: DataRetriever.() -> T): T = Retriever(client).retrieve() - private inner class Retriever(val client: JsonRpcClient) : DataRetriever { - override fun getStations(region: Int): List { - val stations = ArrayList() + private inner class Retriever(val client: JsonRpcClient) : DataRetriever { + override fun getStations(region: Int): List { + val stations = ArrayList() - try { - val response = client.call(serviceUrl, "get_stations") - val stationsArray = response.data.get("stations") as JSONArray + try { + val response = client.call(serviceUrl, "get_stations") + val stationsArray = response.data.get("stations") as JSONArray - for (i in 0 until stationsArray.length()) { - stations.add(dataBuilder.createStation(stationsArray.getJSONArray(i))) + for (i in 0 until stationsArray.length()) { + stations.add(dataBuilder.createStation(stationsArray.getJSONArray(i))) + } + } catch (e: Exception) { + throw RuntimeException(e) } - } catch (e: Exception) { - throw RuntimeException(e) - } - return stations - } + return stations + } - override fun getStrikesGrid(parameters: Parameters, history: History?, flags: Flags): ResultEvent { - var result = initializeResult(parameters, history, flags) + override fun getStrikesGrid( + parameters: Parameters, + history: History?, + flags: Flags, + ): ResultEvent { + var result = initializeResult(parameters, history, flags) - nextId = 0 + nextId = 0 - try { - val response = JsonRpcData(client, serviceUrl).requestData(parameters) - result = addGridData(response, result, parameters.gridSize) - result = addStrikesHistogram(response.data, result) - } catch (e: Exception) { - throw RuntimeException(e) - } + try { + val response = JsonRpcData(client, serviceUrl).requestData(parameters) + result = addGridData(response, result, parameters.gridSize) + result = addStrikesHistogram(response.data, result) + } catch (e: Exception) { + throw RuntimeException(e) + } - Log.v( - Main.LOG_TAG, - "JsonRpcDataProvider: read %d bytes (%d grid entries, region %d)".format( - client.lastNumberOfTransferredBytes, - result.strikes?.size, - parameters.region + Log.v( + Main.LOG_TAG, + "JsonRpcDataProvider: read %d bytes (%d grid entries, region %d)".format( + client.lastNumberOfTransferredBytes, + result.strikes?.size, + parameters.region, + ), ) - ) - return result - } - - override fun getStrikes(parameters: Parameters, history: History?, flags: Flags): ResultEvent { - var result = initializeResult(parameters, history, flags) - - val intervalDuration = parameters.intervalDuration - val intervalOffset = parameters.intervalOffset - if (intervalOffset < 0) { - nextId = 0 + return result } - try { - val response = client.call( - serviceUrl, - "get_strikes", - intervalDuration, - if (intervalOffset < 0) intervalOffset else nextId - ) + override fun getStrikes( + parameters: Parameters, + history: History?, + flags: Flags, + ): ResultEvent { + var result = initializeResult(parameters, history, flags) + + val intervalDuration = parameters.intervalDuration + val intervalOffset = parameters.intervalOffset + if (intervalOffset < 0) { + nextId = 0 + } - result = addStrikes(response, result) - result = addStrikesHistogram(response.data, result) - } catch (e: Exception) { - throw RuntimeException(e) - } + try { + val response = + client.call( + serviceUrl, + "get_strikes", + intervalDuration, + if (intervalOffset < 0) intervalOffset else nextId, + ) + + result = addStrikes(response, result) + result = addStrikesHistogram(response.data, result) + } catch (e: Exception) { + throw RuntimeException(e) + } - Log.v( - Main.LOG_TAG, - "JsonRpcDataProvider: read %d bytes (%d new strikes)".format( - client.lastNumberOfTransferredBytes, - result.strikes?.size + Log.v( + Main.LOG_TAG, + "JsonRpcDataProvider: read %d bytes (%d new strikes)".format( + client.lastNumberOfTransferredBytes, + result.strikes?.size, + ), ) - ) - return result + return result + } } - } - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: PreferenceKey) { - when (key) { - PreferenceKey.SERVICE_URL -> { - val serviceUrl: String = - sharedPreferences.get(PreferenceKey.SERVICE_URL, DEFAULT_SERVICE_URL.toString()) - this.serviceUrl = - toCheckedUrl(if (serviceUrl.isNotBlank()) serviceUrl.trim() else DEFAULT_SERVICE_URL.toString()) - } + override fun onSharedPreferenceChanged( + sharedPreferences: SharedPreferences, + key: PreferenceKey, + ) { + when (key) { + PreferenceKey.SERVICE_URL -> { + val serviceUrl: String = + sharedPreferences.get(PreferenceKey.SERVICE_URL, DEFAULT_SERVICE_URL.toString()) + this.serviceUrl = + toCheckedUrl(if (serviceUrl.isNotBlank()) serviceUrl.trim() else DEFAULT_SERVICE_URL.toString()) + } - else -> {} + else -> {} + } } - } - private fun toCheckedUrl(serviceUrl: String): URL { - return try { - URL(serviceUrl) - } catch (e: Exception) { - Log.e(Main.LOG_TAG, "JsonRpcDataProvider.tocheckedUrl($serviceUrl) invalid") - DEFAULT_SERVICE_URL + private fun toCheckedUrl(serviceUrl: String): URL { + return try { + URL(serviceUrl) + } catch (e: Exception) { + Log.e(Main.LOG_TAG, "JsonRpcDataProvider.tocheckedUrl($serviceUrl) invalid") + DEFAULT_SERVICE_URL + } } - } - companion object { - private val DATE_TIME_FORMATTER = SimpleDateFormat("yyyyMMdd'T'HH:mm:ss", Locale.US) - private val DEFAULT_SERVICE_URL = URI("http://bo-service.tryb.de/").toURL() + companion object { + private val DATE_TIME_FORMATTER = SimpleDateFormat("yyyyMMdd'T'HH:mm:ss", Locale.US) + private val DEFAULT_SERVICE_URL = URI("http://bo-service.tryb.de/").toURL() - init { - val tz = TimeZone.getTimeZone("UTC") - DATE_TIME_FORMATTER.timeZone = tz + init { + val tz = TimeZone.getTimeZone("UTC") + DATE_TIME_FORMATTER.timeZone = tz + } } } -} diff --git a/app/src/main/java/org/blitzortung/android/data/repository/StrikeDataRepository.kt b/app/src/main/java/org/blitzortung/android/data/repository/StrikeDataRepository.kt new file mode 100644 index 000000000..ff665048e --- /dev/null +++ b/app/src/main/java/org/blitzortung/android/data/repository/StrikeDataRepository.kt @@ -0,0 +1,136 @@ +/* + + Copyright 2015 Andreas Würl + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ + +package org.blitzortung.android.data.repository + +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import org.blitzortung.android.data.MainDataHandler +import org.blitzortung.android.data.Parameters +import org.blitzortung.android.data.cache.CacheSize +import org.blitzortung.android.data.provider.result.DataEvent + +/** + * Repository layer that wraps MainDataHandler and provides Flow-based API + * for reactive data access in ViewModels. + */ +@Singleton +class StrikeDataRepository + @Inject + constructor( + private val mainDataHandler: MainDataHandler, + ) { + /** + * Observe data events as a Flow. + * This converts the callback-based ConsumerContainer to a reactive Flow. + */ + fun observeDataEvents(): Flow = + callbackFlow { + val consumer: (DataEvent) -> Unit = { event -> + trySend(event) + } + + mainDataHandler.requestUpdates(consumer) + + awaitClose { + mainDataHandler.removeUpdates(consumer) + } + } + + /** + * Request a data update from the server + */ + fun updateData() { + mainDataHandler.updateData() + } + + /** + * Get current parameters + */ + fun getParameters(): Parameters = mainDataHandler.parameters + + /** + * Get current interval duration + */ + fun getIntervalDuration(): Int = mainDataHandler.intervalDuration + + /** + * Check if currently in realtime mode + */ + fun isRealtime(): Boolean = mainDataHandler.isRealtime + + /** + * Switch to realtime mode + * @return true if parameters changed + */ + fun goRealtime(): Boolean = mainDataHandler.goRealtime() + + /** + * Set history position + * @return true if parameters changed + */ + fun setPosition(position: Int): Boolean = mainDataHandler.setPosition(position) + + /** + * Get number of history steps available + */ + fun historySteps(): Int = mainDataHandler.historySteps() + + /** + * Start periodic data updates + */ + fun start() { + mainDataHandler.start() + } + + /** + * Stop periodic data updates + */ + fun stop() { + mainDataHandler.stop() + } + + /** + * Restart data handler in realtime mode + */ + fun restart() { + mainDataHandler.restart() + } + + /** + * Start animation mode + */ + fun startAnimation() { + mainDataHandler.startAnimation() + } + + /** + * Toggle extended data mode + */ + fun toggleExtendedMode() { + mainDataHandler.toggleExtendedMode() + } + + /** + * Calculate total cache size + */ + fun calculateTotalCacheSize(): CacheSize = mainDataHandler.calculateTotalCacheSize() + } diff --git a/app/src/main/java/org/blitzortung/android/dialogs/AlertDialog.kt b/app/src/main/java/org/blitzortung/android/dialogs/AlertDialog.kt index 8bb9e0d49..17cf9000c 100644 --- a/app/src/main/java/org/blitzortung/android/dialogs/AlertDialog.kt +++ b/app/src/main/java/org/blitzortung/android/dialogs/AlertDialog.kt @@ -31,7 +31,7 @@ class AlertDialog( context: Context, private val colorHandler: ColorHandler, private val dataHandler: MainDataHandler, - private val alertHandler: AlertHandler + private val alertHandler: AlertHandler, ) : android.app.AlertDialog(context) { private lateinit var alertView: AlertView @@ -60,7 +60,10 @@ class AlertDialog( alertHandler.removeUpdates(alertView.alertEventConsumer) } - override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { + override fun onKeyUp( + keyCode: Int, + event: KeyEvent, + ): Boolean { if (keyCode == KeyEvent.KEYCODE_MENU) { dismiss() return true diff --git a/app/src/main/java/org/blitzortung/android/dialogs/AlertDialogColorHandler.kt b/app/src/main/java/org/blitzortung/android/dialogs/AlertDialogColorHandler.kt index a4116c4ab..d7cdd65e7 100644 --- a/app/src/main/java/org/blitzortung/android/dialogs/AlertDialogColorHandler.kt +++ b/app/src/main/java/org/blitzortung/android/dialogs/AlertDialogColorHandler.kt @@ -19,12 +19,10 @@ package org.blitzortung.android.dialogs import android.content.SharedPreferences - import org.blitzortung.android.map.overlay.color.ColorTarget import org.blitzortung.android.map.overlay.color.StrikeColorHandler class AlertDialogColorHandler(preferences: SharedPreferences) : StrikeColorHandler(preferences) { - override fun getTextColor(target: ColorTarget): Int { return when (target) { ColorTarget.SATELLITE -> 0xFF000000 @@ -45,4 +43,4 @@ class AlertDialogColorHandler(preferences: SharedPreferences) : StrikeColorHandl ColorTarget.STREETMAP -> 0xff555555 }.toInt() } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/blitzortung/android/dialogs/InfoDialog.kt b/app/src/main/java/org/blitzortung/android/dialogs/InfoDialog.kt index 530ce0741..b90ebe6f9 100644 --- a/app/src/main/java/org/blitzortung/android/dialogs/InfoDialog.kt +++ b/app/src/main/java/org/blitzortung/android/dialogs/InfoDialog.kt @@ -26,15 +26,20 @@ import org.blitzortung.android.app.R import org.blitzortung.android.app.components.BuildVersion class InfoDialog(context: Context, buildVersion: BuildVersion) : AlertDialog(context) { - init { - setTitle("" + context.resources.getText(R.string.app_name) + " V" + buildVersion.versionName + " (" + buildVersion.versionCode + ")") - @SuppressLint("InflateParams") val infoDialogView = layoutInflater.inflate(R.layout.info_dialog, null, false) + setTitle( + "" + context.resources.getText(R.string.app_name) + " V" + buildVersion.versionName + " (" + buildVersion.versionCode + ")", + ) + @SuppressLint("InflateParams") + val infoDialogView = layoutInflater.inflate(R.layout.info_dialog, null, false) setView(infoDialogView) setCancelable(true) } - override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { + override fun onKeyUp( + keyCode: Int, + event: KeyEvent, + ): Boolean { if (keyCode == KeyEvent.KEYCODE_MENU) { dismiss() return true diff --git a/app/src/main/java/org/blitzortung/android/dialogs/LogDialog.kt b/app/src/main/java/org/blitzortung/android/dialogs/LogDialog.kt index 6ed6e72a4..e88e104c7 100644 --- a/app/src/main/java/org/blitzortung/android/dialogs/LogDialog.kt +++ b/app/src/main/java/org/blitzortung/android/dialogs/LogDialog.kt @@ -20,26 +20,24 @@ package org.blitzortung.android.dialogs import android.content.Context import android.content.Intent -import android.net.Uri import android.os.Build import android.os.Bundle import android.view.KeyEvent import android.widget.Button import android.widget.TextView import android.widget.Toast +import androidx.core.net.toUri import org.blitzortung.android.app.R import org.blitzortung.android.app.components.BuildVersion import org.blitzortung.android.data.cache.CacheSize import org.blitzortung.android.dialogs.log.LogProvider -import androidx.core.net.toUri class LogDialog( context: Context, private val cacheSize: CacheSize, private val buildVersion: BuildVersion, - private val logProvider: LogProvider = LogProvider() + private val logProvider: LogProvider = LogProvider(), ) : android.app.AlertDialog(context) { - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -75,11 +73,11 @@ class LogDialog( } private fun getDeviceString(): String { - val device = Build.DEVICE // Device - val model = Build.MODEL // Model - val product = Build.PRODUCT // Product + val device = Build.DEVICE // Device + val model = Build.MODEL // Model + val product = Build.PRODUCT // Product val version = System.getProperty("os.version") // OS version - val sdkInt = Build.VERSION.SDK_INT // API Level + val sdkInt = Build.VERSION.SDK_INT // API Level return "Device: $device, model: $model, Product: $product, Version: $version, SDK: $sdkInt" } @@ -95,10 +93,11 @@ class LogDialog( intent.putExtra(Intent.EXTRA_SUBJECT, context.resources.getString(R.string.app_log_subject)) intent.putExtra( Intent.EXTRA_TEXT, - if (body.length > MAX_LOG_SIZE) + if (body.length > MAX_LOG_SIZE) { body.substring(body.length - MAX_LOG_SIZE) - else + } else { body + }, ) if (intent.resolveActivity(context.packageManager) != null) { context.startActivity(intent) @@ -112,7 +111,10 @@ class LogDialog( } } - override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { + override fun onKeyUp( + keyCode: Int, + event: KeyEvent, + ): Boolean { if (keyCode == KeyEvent.KEYCODE_MENU) { dismiss() return true diff --git a/app/src/main/java/org/blitzortung/android/dialogs/QuickSettingsDialog.kt b/app/src/main/java/org/blitzortung/android/dialogs/QuickSettingsDialog.kt index 5cd902c41..ca2246187 100644 --- a/app/src/main/java/org/blitzortung/android/dialogs/QuickSettingsDialog.kt +++ b/app/src/main/java/org/blitzortung/android/dialogs/QuickSettingsDialog.kt @@ -37,51 +37,62 @@ import org.blitzortung.android.app.view.get import org.blitzortung.android.settings.putString class QuickSettingsDialog : DialogFragment() { - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val builder = Builder(activity) val layoutInflater = requireActivity().layoutInflater - @SuppressLint("InflateParams") val view = layoutInflater.inflate(R.layout.quick_settings_dialog, null, false) + + @SuppressLint("InflateParams") + val view = layoutInflater.inflate(R.layout.quick_settings_dialog, null, false) val preferences = PreferenceManager.getDefaultSharedPreferences(activity as Context) val spinnerManager = SpinnerManager(resources, view, preferences) spinnerManager.add( - R.array.regions_values, PreferenceKey.REGION, R.id.selected_region, defaultIndex = 1 + R.array.regions_values, + PreferenceKey.REGION, + R.id.selected_region, + defaultIndex = 1, ) spinnerManager.add( - R.array.grid_size_values, PreferenceKey.GRID_SIZE, R.id.selected_grid_size, + R.array.grid_size_values, + PreferenceKey.GRID_SIZE, + R.id.selected_grid_size, ) spinnerManager.add( - R.array.count_threshold_values, PreferenceKey.COUNT_THRESHOLD, R.id.selected_count_threshold, + R.array.count_threshold_values, + PreferenceKey.COUNT_THRESHOLD, + R.id.selected_count_threshold, ) spinnerManager.add( - R.array.query_period_values, PreferenceKey.QUERY_PERIOD, R.id.selected_query_period, defaultIndex = 2 + R.array.query_period_values, + PreferenceKey.QUERY_PERIOD, + R.id.selected_query_period, + defaultIndex = 2, ) spinnerManager.add( R.array.interval_duration_values, PreferenceKey.INTERVAL_DURATION, R.id.selected_interval_duration, - defaultIndex = 1 + defaultIndex = 1, ) spinnerManager.add( R.array.historic_timestep_values, PreferenceKey.HISTORIC_TIMESTEP, R.id.selected_historic_timestep, - defaultIndex = 1 + defaultIndex = 1, ) spinnerManager.add( R.array.animation_interval_duration_values, PreferenceKey.ANIMATION_INTERVAL_DURATION, R.id.selected_animation_interval_durations, - defaultIndex = 1 + defaultIndex = 1, ) return builder.setView(view) @@ -92,7 +103,9 @@ class QuickSettingsDialog : DialogFragment() { } class SpinnerManager( - val resources: Resources, val view: View, val preferences: SharedPreferences, + val resources: Resources, + val view: View, + val preferences: SharedPreferences, ) { private val entries = mutableListOf String?>>() @@ -100,7 +113,7 @@ class SpinnerManager( valuesId: Int, preferenceKey: PreferenceKey, viewId: Int, - defaultIndex: Int = 0 + defaultIndex: Int = 0, ) { val values = resources.getStringArray(valuesId) val currentValue = preferences.get(preferenceKey, values[defaultIndex]) @@ -110,7 +123,10 @@ class SpinnerManager( entries.add(Pair(preferenceKey) { values[spinner.selectedItemPosition] }) } - private fun getSelectedIndex(values: Array, currentValue: String): Int { + private fun getSelectedIndex( + values: Array, + currentValue: String, + ): Int { var selectedIndex = 0 for (regionValue in values) { if (regionValue == currentValue) { diff --git a/app/src/main/java/org/blitzortung/android/dialogs/changelog/ChangelogDialog.kt b/app/src/main/java/org/blitzortung/android/dialogs/changelog/ChangelogDialog.kt index f2cfd52c3..0490d27e2 100644 --- a/app/src/main/java/org/blitzortung/android/dialogs/changelog/ChangelogDialog.kt +++ b/app/src/main/java/org/blitzortung/android/dialogs/changelog/ChangelogDialog.kt @@ -11,9 +11,8 @@ import androidx.recyclerview.widget.RecyclerView import org.blitzortung.android.app.R class ChangelogDialog( - context: Context + context: Context, ) : android.app.AlertDialog(context) { - init { setCancelable(true) } @@ -56,41 +55,43 @@ interface ViewItem { class ReleaseEntry( val versionName: String, - val versionCode: Int + val versionCode: Int, ) : ViewItem { override val viewType = ChangeLogAdapter.Type.HEADER override val layoutId = R.layout.changelog_header } class ChangeEntry( - val description: String + val description: String, ) : ViewItem { override val viewType = ChangeLogAdapter.Type.ENTRY override val layoutId = R.layout.changelog_entry } class HeaderView( - view: View + view: View, ) : RecyclerView.ViewHolder(view) { val header = view.findViewById(R.id.changelog_header) } class EntryView( - view: View + view: View, ) : RecyclerView.ViewHolder(view) { val description = view.findViewById(R.id.changelog_description) } class ChangeLogAdapter( context: Context, - private val items: List + private val items: List, ) : RecyclerView.Adapter() { - private val inflater = LayoutInflater.from(context) enum class Type { ENTRY, HEADER } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): RecyclerView.ViewHolder { return when (viewType) { Type.HEADER.ordinal -> HeaderView(inflater.inflate(R.layout.changelog_header, parent, false)) Type.ENTRY.ordinal -> EntryView(inflater.inflate(R.layout.changelog_entry, parent, false)) @@ -102,7 +103,10 @@ class ChangeLogAdapter( return items.size } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + ) { val viewItem = items[position] when (viewItem.viewType) { @@ -114,4 +118,4 @@ class ChangeLogAdapter( override fun getItemViewType(position: Int): Int { return items[position].viewType.ordinal } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/blitzortung/android/dialogs/changelog/ChangelogParser.kt b/app/src/main/java/org/blitzortung/android/dialogs/changelog/ChangelogParser.kt index 4b7a3304b..8f5f26cb0 100644 --- a/app/src/main/java/org/blitzortung/android/dialogs/changelog/ChangelogParser.kt +++ b/app/src/main/java/org/blitzortung/android/dialogs/changelog/ChangelogParser.kt @@ -5,7 +5,10 @@ import android.util.Xml import org.xmlpull.v1.XmlPullParser class ChangelogParser { - fun readChangeLog(context: Context, changeLogFileId: Int): List { + fun readChangeLog( + context: Context, + changeLogFileId: Int, + ): List { val parser = getParser(context, changeLogFileId) return parseRoot(parser) @@ -22,7 +25,10 @@ class ChangelogParser { return releases } - private fun iterate(parser: XmlPullParser, visitor: (parser: XmlPullParser) -> Unit) { + private fun iterate( + parser: XmlPullParser, + visitor: (parser: XmlPullParser) -> Unit, + ) { while (parser.next() !in listOf(XmlPullParser.END_DOCUMENT, XmlPullParser.END_TAG)) { if (parser.eventType != XmlPullParser.START_TAG) { continue @@ -41,12 +47,13 @@ class ChangelogParser { it.next() val containedText = it.text it.nextTag() - val element = when (elementName) { - "bugfix" -> Bugfix(containedText) - "feature" -> Feature(containedText) - "improvement" -> Improvement(containedText) - else -> null - } + val element = + when (elementName) { + "bugfix" -> Bugfix(containedText) + "feature" -> Feature(containedText) + "improvement" -> Improvement(containedText) + else -> null + } if (element != null) { changes.add(element) } @@ -54,7 +61,10 @@ class ChangelogParser { return Release(versionName, versionCode, changes) } - private fun getParser(context: Context, changeLogFileId: Int): XmlPullParser { + private fun getParser( + context: Context, + changeLogFileId: Int, + ): XmlPullParser { return when (val resourceTypeName = context.resources.getResourceTypeName(changeLogFileId)) { "raw" -> { val inputStream = context.resources.openRawResource(changeLogFileId) @@ -68,13 +78,12 @@ class ChangelogParser { else -> throw IllegalArgumentException("bad changelog resource type $resourceTypeName") } } - } data class Release( val versionName: String, val versionCode: Int, - val changes: List + val changes: List, ) sealed class Change { @@ -82,13 +91,13 @@ sealed class Change { } data class Bugfix( - override val description: String + override val description: String, ) : Change() data class Feature( - override val description: String + override val description: String, ) : Change() data class Improvement( - override val description: String + override val description: String, ) : Change() diff --git a/app/src/main/java/org/blitzortung/android/dialogs/log/LogProvider.kt b/app/src/main/java/org/blitzortung/android/dialogs/log/LogProvider.kt index c7a5235f1..5a4505143 100644 --- a/app/src/main/java/org/blitzortung/android/dialogs/log/LogProvider.kt +++ b/app/src/main/java/org/blitzortung/android/dialogs/log/LogProvider.kt @@ -24,20 +24,22 @@ import java.io.InputStreamReader class LogProvider { fun getLogLines(): List { val process = Runtime.getRuntime().exec("logcat -d") - val reader = BufferedReader( - InputStreamReader(process.inputStream) - ) + val reader = + BufferedReader( + InputStreamReader(process.inputStream), + ) val lines: ArrayList = arrayListOf() reader.use { var line: String? do { line = reader.readLine() - if (line == null) + if (line == null) { break + } lines.add(line) } while (true) } return lines } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/blitzortung/android/jsonrpc/HttpServiceClient.kt b/app/src/main/java/org/blitzortung/android/jsonrpc/HttpServiceClient.kt index 3be15cfe3..6864ad066 100644 --- a/app/src/main/java/org/blitzortung/android/jsonrpc/HttpServiceClient.kt +++ b/app/src/main/java/org/blitzortung/android/jsonrpc/HttpServiceClient.kt @@ -25,8 +25,11 @@ data class HttpServiceClientResult( ) interface HttpServiceClient { - fun doRequest(baseUrl: URL, data: String): HttpServiceClientResult + fun doRequest( + baseUrl: URL, + data: String, + ): HttpServiceClientResult + var socketTimeout: Int var connectionTimeout: Int } - diff --git a/app/src/main/java/org/blitzortung/android/jsonrpc/HttpServiceClientDefault.kt b/app/src/main/java/org/blitzortung/android/jsonrpc/HttpServiceClientDefault.kt index 4d829c3d3..5468d0a9e 100644 --- a/app/src/main/java/org/blitzortung/android/jsonrpc/HttpServiceClientDefault.kt +++ b/app/src/main/java/org/blitzortung/android/jsonrpc/HttpServiceClientDefault.kt @@ -26,43 +26,47 @@ import java.util.zip.GZIPInputStream import javax.inject.Inject import javax.inject.Named -class HttpServiceClientDefault @Inject constructor( - @Named("agentSuffix") agentSuffix: String -) : HttpServiceClient { - - private val userAgentString: String = "bo-android$agentSuffix" - override var socketTimeout = 0 - override var connectionTimeout = 0 - - override fun doRequest(baseUrl: URL, data: String): HttpServiceClientResult { - val connection = baseUrl.openConnection() as HttpURLConnection - - connection.requestMethod = "POST" - - val dataBytes = data.toByteArray(Charset.forName("UTF-8")) - - connection.setRequestProperty("Content-Type", "text/json") - connection.setRequestProperty("Content-Length", dataBytes.size.toString()) - connection.setRequestProperty("User-Agent", userAgentString) - connection.setRequestProperty("Accept-Encoding", "gzip") - - connection.doOutput = true - connection.outputStream.write(dataBytes) - connection.connectTimeout = connectionTimeout - connection.readTimeout = socketTimeout - - var inputStream = connection.inputStream - - when (connection.contentEncoding) { - "gzip" -> { - inputStream = GZIPInputStream(inputStream) +class HttpServiceClientDefault + @Inject + constructor( + @Named("agentSuffix") agentSuffix: String, + ) : HttpServiceClient { + private val userAgentString: String = "bo-android$agentSuffix" + override var socketTimeout = 0 + override var connectionTimeout = 0 + + override fun doRequest( + baseUrl: URL, + data: String, + ): HttpServiceClientResult { + val connection = baseUrl.openConnection() as HttpURLConnection + + connection.requestMethod = "POST" + + val dataBytes = data.toByteArray(Charset.forName("UTF-8")) + + connection.setRequestProperty("Content-Type", "text/json") + connection.setRequestProperty("Content-Length", dataBytes.size.toString()) + connection.setRequestProperty("User-Agent", userAgentString) + connection.setRequestProperty("Accept-Encoding", "gzip") + + connection.doOutput = true + connection.outputStream.write(dataBytes) + connection.connectTimeout = connectionTimeout + connection.readTimeout = socketTimeout + + var inputStream = connection.inputStream + + when (connection.contentEncoding) { + "gzip" -> { + inputStream = GZIPInputStream(inputStream) + } } - } - // Do not disconnect the connection here as it will be potentially reused + // Do not disconnect the connection here as it will be potentially reused - return HttpServiceClientResult( - body = InputStreamReader(inputStream, "UTF-8").use { it.readText() }, - ) + return HttpServiceClientResult( + body = InputStreamReader(inputStream, "UTF-8").use { it.readText() }, + ) + } } -} diff --git a/app/src/main/java/org/blitzortung/android/jsonrpc/JsonRpcClient.kt b/app/src/main/java/org/blitzortung/android/jsonrpc/JsonRpcClient.kt index 95cb33bef..b945825a5 100644 --- a/app/src/main/java/org/blitzortung/android/jsonrpc/JsonRpcClient.kt +++ b/app/src/main/java/org/blitzortung/android/jsonrpc/JsonRpcClient.kt @@ -18,68 +18,76 @@ package org.blitzortung.android.jsonrpc -import org.json.JSONArray -import org.json.JSONObject import java.net.URL import javax.inject.Inject import javax.inject.Singleton +import org.json.JSONArray +import org.json.JSONObject data class JsonRpcResponse( val data: JSONObject, ) @Singleton -class JsonRpcClient @Inject constructor(client: HttpServiceClientDefault) : HttpServiceClient by client { - - init { - connectionTimeout = 40000 - socketTimeout = 40000 - } - - private val id = 0 +class JsonRpcClient + @Inject + constructor(client: HttpServiceClientDefault) : HttpServiceClient by client { + init { + connectionTimeout = 40000 + socketTimeout = 40000 + } - var lastNumberOfTransferredBytes: Int = 0 - private set + private val id = 0 - private fun buildParameters(parameters: Array): JSONArray { - val parameterArray = JSONArray() - parameters.forEach { parameterArray.put(it) } - return parameterArray - } + var lastNumberOfTransferredBytes: Int = 0 + private set - private fun buildRequest(methodName: String, parameters: Array): String { - val requestObject = JSONObject() + private fun buildParameters(parameters: Array): JSONArray { + val parameterArray = JSONArray() + parameters.forEach { parameterArray.put(it) } + return parameterArray + } - requestObject.put("id", id) - requestObject.put("method", methodName) - requestObject.put("params", buildParameters(parameters)) + private fun buildRequest( + methodName: String, + parameters: Array, + ): String { + val requestObject = JSONObject() - return requestObject.toString() - } + requestObject.put("id", id) + requestObject.put("method", methodName) + requestObject.put("params", buildParameters(parameters)) - fun call(baseUrl: URL, methodName: String, vararg parameters: Any): JsonRpcResponse { - val response = doRequest(baseUrl, buildRequest(methodName, parameters)) + return requestObject.toString() + } - lastNumberOfTransferredBytes = response.body.length + fun call( + baseUrl: URL, + methodName: String, + vararg parameters: Any, + ): JsonRpcResponse { + val response = doRequest(baseUrl, buildRequest(methodName, parameters)) - return if (response.body.startsWith("[")) { - JsonRpcResponse( - data = JSONArray(response.body).getJSONObject(0), - ) - } else { - val responseObject = JSONObject(response.body) + lastNumberOfTransferredBytes = response.body.length - if (responseObject.has("fault")) { - throw JsonRpcException( - "remote Exception '%s' #%s".format( - responseObject.get("faultString"), - responseObject.get("faultCode") + return if (response.body.startsWith("[")) { + JsonRpcResponse( + data = JSONArray(response.body).getJSONObject(0), + ) + } else { + val responseObject = JSONObject(response.body) + + if (responseObject.has("fault")) { + throw JsonRpcException( + "remote Exception '%s' #%s".format( + responseObject.get("faultString"), + responseObject.get("faultCode"), + ), ) + } + JsonRpcResponse( + data = responseObject, ) } - JsonRpcResponse( - data = responseObject, - ) } } -} diff --git a/app/src/main/java/org/blitzortung/android/jsonrpc/JsonRpcException.kt b/app/src/main/java/org/blitzortung/android/jsonrpc/JsonRpcException.kt index 0f66b9328..ba709a074 100644 --- a/app/src/main/java/org/blitzortung/android/jsonrpc/JsonRpcException.kt +++ b/app/src/main/java/org/blitzortung/android/jsonrpc/JsonRpcException.kt @@ -18,9 +18,7 @@ package org.blitzortung.android.jsonrpc - class JsonRpcException(msg: String) : RuntimeException(msg) { - companion object { private const val serialVersionUID = -8108432807261988215L } diff --git a/app/src/main/java/org/blitzortung/android/location/LocationEvent.kt b/app/src/main/java/org/blitzortung/android/location/LocationEvent.kt index 581b2adb5..9795c113c 100644 --- a/app/src/main/java/org/blitzortung/android/location/LocationEvent.kt +++ b/app/src/main/java/org/blitzortung/android/location/LocationEvent.kt @@ -19,7 +19,6 @@ package org.blitzortung.android.location import android.location.Location - import org.blitzortung.android.protocol.Event class LocationEvent(val location: Location? = null) : Event diff --git a/app/src/main/java/org/blitzortung/android/location/LocationHandler.kt b/app/src/main/java/org/blitzortung/android/location/LocationHandler.kt index 665fced3c..86e657948 100644 --- a/app/src/main/java/org/blitzortung/android/location/LocationHandler.kt +++ b/app/src/main/java/org/blitzortung/android/location/LocationHandler.kt @@ -27,6 +27,8 @@ import android.location.Location import android.location.LocationManager import android.util.Log import android.widget.Toast +import javax.inject.Inject +import javax.inject.Singleton import org.blitzortung.android.app.Main import org.blitzortung.android.app.R import org.blitzortung.android.app.view.OnSharedPreferenceChangeListener @@ -35,179 +37,190 @@ import org.blitzortung.android.app.view.get import org.blitzortung.android.location.provider.LocationProvider import org.blitzortung.android.location.provider.createLocationProvider import org.blitzortung.android.protocol.ConsumerContainer -import javax.inject.Inject -import javax.inject.Singleton @Singleton -class LocationHandler @Inject constructor( - private val context: Context, - sharedPreferences: SharedPreferences -) : OnSharedPreferenceChangeListener { - - init { - Log.v(Main.LOG_TAG, "LocationHandler() init") - } +class LocationHandler + @Inject + constructor( + private val context: Context, + sharedPreferences: SharedPreferences, + ) : OnSharedPreferenceChangeListener { + init { + Log.v(Main.LOG_TAG, "LocationHandler() init") + } - var location: Location? = null - private set + var location: Location? = null + private set - var backgroundMode = true - private set(value) { - field = value - updateProvider() - } + var backgroundMode = true + private set(value) { + field = value + updateProvider() + } - private var provider: LocationProvider? = null + private var provider: LocationProvider? = null - private val consumerContainer = object : ConsumerContainer() { - override fun addedFirstConsumer() { - provider?.run { - if (!isRunning) { - start() + private val consumerContainer = + object : ConsumerContainer() { + override fun addedFirstConsumer() { + provider?.run { + if (!isRunning) { + start() + } + } } - } - } - override fun removedLastConsumer() { - provider?.run { - if (isRunning) { - shutdown() + override fun removedLastConsumer() { + provider?.run { + if (isRunning) { + shutdown() + } + } } } - } - } - init { - sharedPreferences.registerOnSharedPreferenceChangeListener(this) - onSharedPreferenceChanged(sharedPreferences, PreferenceKey.LOCATION_MODE) + init { + sharedPreferences.registerOnSharedPreferenceChangeListener(this) + onSharedPreferenceChanged(sharedPreferences, PreferenceKey.LOCATION_MODE) - //We need to know when a LocationProvider is enabled/disabled - val iFilter = IntentFilter(LocationManager.PROVIDERS_CHANGED_ACTION) - context.registerReceiver(LocationProviderChangedReceiver(), iFilter) - } + // We need to know when a LocationProvider is enabled/disabled + val iFilter = IntentFilter(LocationManager.PROVIDERS_CHANGED_ACTION) + context.registerReceiver(LocationProviderChangedReceiver(), iFilter) + } - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: PreferenceKey) { - when (key) { - PreferenceKey.LOCATION_MODE -> { - val providerFactory = { type: String -> - createLocationProvider(context, backgroundMode, { location -> sendLocationUpdate(location) }, type) + override fun onSharedPreferenceChanged( + sharedPreferences: SharedPreferences, + key: PreferenceKey, + ) { + when (key) { + PreferenceKey.LOCATION_MODE -> { + val providerFactory = { type: String -> + createLocationProvider( + context, + backgroundMode, + { location -> sendLocationUpdate(location) }, + type, + ) + } + + val newProvider = providerFactory(sharedPreferences.get(key, LocationManager.PASSIVE_PROVIDER)) + + if (provider?.type != newProvider.type && provider?.isRunning == true) { + enableNewProvider(newProvider) + } else { + provider = newProvider + } } - val newProvider = providerFactory(sharedPreferences.get(key, LocationManager.PASSIVE_PROVIDER)) + else -> {} + } + } - if (provider?.type != newProvider.type && provider?.isRunning == true) { - enableNewProvider(newProvider) - } else { - provider = newProvider + private fun enableNewProvider(newProvider: LocationProvider) { + Log.v(Main.LOG_TAG, "LocationHandler.enableProvider(${newProvider.type})") + // If the current provider is not null and is Running, shut it down first + provider?.let { provider -> + if (provider.isRunning) { + provider.shutdown() } } - else -> {} + // TODO we need to tell the UI if the locationProvider is stopped/started + // Now start the new provider if it is enabled + this.provider = + newProvider.apply { + if (!this.isEnabled) { + val message = context.resources.getString(R.string.location_provider_disabled).format(newProvider.type) + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + } else { + start() + } + } } - } - private fun enableNewProvider(newProvider: LocationProvider) { - Log.v(Main.LOG_TAG, "LocationHandler.enableProvider(${newProvider.type})") - //If the current provider is not null and is Running, shut it down first - provider?.let { provider -> - if (provider.isRunning) { - provider.shutdown() - } - } + private fun sendLocationUpdate(location: Location?) { + val location = location?.takeIf { it.isValid } + this.location = location - //TODO we need to tell the UI if the locationProvider is stopped/started - //Now start the new provider if it is enabled - this.provider = newProvider.apply { - if (!this.isEnabled) { - val message = context.resources.getString(R.string.location_provider_disabled).format(newProvider.type) - Toast.makeText(context, message, Toast.LENGTH_LONG).show() + Log.i(Main.LOG_TAG, "LocationHandler.sendLocationUpdate() location $location") + val event = LocationEvent(location) + if (location != null) { + consumerContainer.storeAndBroadcast(event) } else { - start() + consumerContainer.broadcast(event) } } - } - private fun sendLocationUpdate(location: Location?) { - val location = location?.takeIf { it.isValid } - this.location = location - - Log.i(Main.LOG_TAG, "LocationHandler.sendLocationUpdate() location $location") - val event = LocationEvent(location) - if (location != null) { - consumerContainer.storeAndBroadcast(event) - } else { - consumerContainer.broadcast(event) + fun requestUpdates(locationConsumer: (LocationEvent) -> Unit) { + consumerContainer.addConsumer(locationConsumer) } - } - - fun requestUpdates(locationConsumer: (LocationEvent) -> Unit) { - consumerContainer.addConsumer(locationConsumer) - } - fun removeUpdates(locationEventConsumer: (LocationEvent) -> Unit) { - consumerContainer.removeConsumer(locationEventConsumer) - } + fun removeUpdates(locationEventConsumer: (LocationEvent) -> Unit) { + consumerContainer.removeConsumer(locationEventConsumer) + } - private val Location.isValid: Boolean - get() = !java.lang.Double.isNaN(longitude) && !java.lang.Double.isNaN(latitude) + private val Location.isValid: Boolean + get() = !java.lang.Double.isNaN(longitude) && !java.lang.Double.isNaN(latitude) - fun enableBackgroundMode() { - backgroundMode = true - } + fun enableBackgroundMode() { + backgroundMode = true + } - fun disableBackgroundMode() { - backgroundMode = false - } + fun disableBackgroundMode() { + backgroundMode = false + } - private fun updateProvider() { - provider?.run { - //Reconfigure the Provider only, when its running - if (isRunning) { - reconfigureProvider(this@LocationHandler.backgroundMode) + private fun updateProvider() { + provider?.run { + // Reconfigure the Provider only, when its running + if (isRunning) { + reconfigureProvider(this@LocationHandler.backgroundMode) + } } } - } - fun shutdown() { - provider?.run { - if (isRunning) { - shutdown() - } else { - Log.v(Main.LOG_TAG, "LocationHandler.shutdown() provider $type already stopped") + fun shutdown() { + provider?.run { + if (isRunning) { + shutdown() + } else { + Log.v(Main.LOG_TAG, "LocationHandler.shutdown() provider $type already stopped") + } } } - } - fun start() { - provider?.run { - if (!isRunning) { - start() - } else { - Log.v(Main.LOG_TAG, "LocationHandler.start() provider $type already running") + fun start() { + provider?.run { + if (!isRunning) { + start() + } else { + Log.v(Main.LOG_TAG, "LocationHandler.start() provider $type already running") + } } } - } - - fun update(preferences: SharedPreferences) { - onSharedPreferenceChanged(preferences, PreferenceKey.LOCATION_MODE) - } - private inner class LocationProviderChangedReceiver : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - //TODO we need to tell the UI if the locationProvider is stopped/started + fun update(preferences: SharedPreferences) { + onSharedPreferenceChanged(preferences, PreferenceKey.LOCATION_MODE) + } - provider?.run { - if (!this.isRunning && this.isEnabled) { - start() - } else if (this.isRunning && !this.isEnabled) { - shutdown() + private inner class LocationProviderChangedReceiver : BroadcastReceiver() { + override fun onReceive( + context: Context?, + intent: Intent?, + ) { + // TODO we need to tell the UI if the locationProvider is stopped/started + + provider?.run { + if (!this.isRunning && this.isEnabled) { + start() + } else if (this.isRunning && !this.isEnabled) { + shutdown() + } } } } - } - companion object { - const val MANUAL_PROVIDER = "manual" + companion object { + const val MANUAL_PROVIDER = "manual" + } } - -} diff --git a/app/src/main/java/org/blitzortung/android/location/LocationRepository.kt b/app/src/main/java/org/blitzortung/android/location/LocationRepository.kt new file mode 100644 index 000000000..5712b3f38 --- /dev/null +++ b/app/src/main/java/org/blitzortung/android/location/LocationRepository.kt @@ -0,0 +1,72 @@ +/* + + Copyright 2015 Andreas Würl + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ + +package org.blitzortung.android.location + +import android.location.Location +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +/** + * Repository layer that wraps LocationHandler and provides Flow-based API + * for reactive location access in ViewModels. + */ +@Singleton +class LocationRepository + @Inject + constructor( + private val locationHandler: LocationHandler, + ) { + /** + * Observe location events as a Flow + */ + fun observeLocationEvents(): Flow = + callbackFlow { + val consumer: (LocationEvent) -> Unit = { event -> + trySend(event) + } + + locationHandler.requestUpdates(consumer) + + awaitClose { + locationHandler.removeUpdates(consumer) + } + } + + /** + * Get the current location if available + */ + fun getCurrentLocation(): Location? = locationHandler.location + + /** + * Enable background mode for location updates + */ + fun enableBackgroundMode() { + locationHandler.enableBackgroundMode() + } + + /** + * Disable background mode for location updates + */ + fun disableBackgroundMode() { + locationHandler.disableBackgroundMode() + } + } diff --git a/app/src/main/java/org/blitzortung/android/location/provider/GPSLocationProvider.kt b/app/src/main/java/org/blitzortung/android/location/provider/GPSLocationProvider.kt index 77379b5a6..501e29248 100644 --- a/app/src/main/java/org/blitzortung/android/location/provider/GPSLocationProvider.kt +++ b/app/src/main/java/org/blitzortung/android/location/provider/GPSLocationProvider.kt @@ -11,15 +11,15 @@ import javax.inject.Singleton class GPSLocationProvider( context: Context, backgroundMode: Boolean, - locationUpdate: (Location?) -> Unit + locationUpdate: (Location?) -> Unit, ) : ManagerLocationProvider(context, backgroundMode, locationUpdate, LocationManager.GPS_PROVIDER) { - override val minTime: Long get() = if (isInBackground) 60000 else 1000 override val isPermissionGranted: Boolean - get() = PermissionChecker.checkSelfPermission( - context, - Manifest.permission.ACCESS_FINE_LOCATION - ) == PermissionChecker.PERMISSION_GRANTED -} \ No newline at end of file + get() = + PermissionChecker.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION, + ) == PermissionChecker.PERMISSION_GRANTED +} diff --git a/app/src/main/java/org/blitzortung/android/location/provider/LocationProvider.kt b/app/src/main/java/org/blitzortung/android/location/provider/LocationProvider.kt index 00aea0010..dbecded8f 100644 --- a/app/src/main/java/org/blitzortung/android/location/provider/LocationProvider.kt +++ b/app/src/main/java/org/blitzortung/android/location/provider/LocationProvider.kt @@ -2,8 +2,8 @@ package org.blitzortung.android.location.provider import android.location.Location import android.util.Log -import org.blitzortung.android.app.Main import java.lang.Double.isNaN +import org.blitzortung.android.app.Main abstract class LocationProvider(protected val locationUpdate: (Location?) -> Unit) { var isRunning: Boolean = false @@ -35,11 +35,11 @@ abstract class LocationProvider(protected val locationUpdate: (Location?) -> Uni open fun shutdown() { isRunning = false - //Invalidate the current location, when the provider is stopped + // Invalidate the current location, when the provider is stopped sendLocationUpdate(null) Log.v(Main.LOG_TAG, "LocationProvider.shutdown() type: $type") } abstract fun reconfigureProvider(isInBackground: Boolean) -} \ No newline at end of file +} diff --git a/app/src/main/java/org/blitzortung/android/location/provider/LocationProviderFactory.kt b/app/src/main/java/org/blitzortung/android/location/provider/LocationProviderFactory.kt index 788e6b693..03105350d 100644 --- a/app/src/main/java/org/blitzortung/android/location/provider/LocationProviderFactory.kt +++ b/app/src/main/java/org/blitzortung/android/location/provider/LocationProviderFactory.kt @@ -8,22 +8,21 @@ import android.location.LocationManager.PASSIVE_PROVIDER import androidx.preference.PreferenceManager import org.blitzortung.android.location.LocationHandler - internal fun createLocationProvider( context: Context, backgroundMode: Boolean, updateConsumer: (Location?) -> Unit, - providerName: String + providerName: String, ): LocationProvider { - return when (providerName) { GPS_PROVIDER -> GPSLocationProvider(context, backgroundMode, updateConsumer) NETWORK_PROVIDER -> NetworkLocationProvider(context, backgroundMode, updateConsumer) PASSIVE_PROVIDER -> PassiveLocationProvider(context, backgroundMode, updateConsumer) - LocationHandler.MANUAL_PROVIDER -> ManualLocationProvider( - updateConsumer, - PreferenceManager.getDefaultSharedPreferences(context) - ) + LocationHandler.MANUAL_PROVIDER -> + ManualLocationProvider( + updateConsumer, + PreferenceManager.getDefaultSharedPreferences(context), + ) else -> null } ?: throw IllegalArgumentException("Cannot find provider for name $providerName") diff --git a/app/src/main/java/org/blitzortung/android/location/provider/ManagerLocationProvider.kt b/app/src/main/java/org/blitzortung/android/location/provider/ManagerLocationProvider.kt index 73a9b16b6..ab0bc3e7a 100644 --- a/app/src/main/java/org/blitzortung/android/location/provider/ManagerLocationProvider.kt +++ b/app/src/main/java/org/blitzortung/android/location/provider/ManagerLocationProvider.kt @@ -13,9 +13,8 @@ abstract class ManagerLocationProvider( protected val context: Context, protected var isInBackground: Boolean, locationUpdate: (Location?) -> Unit, - override val type: String + override val type: String, ) : LocationProvider(locationUpdate), LocationListener { - private val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager protected open val minTime: Long @@ -26,7 +25,7 @@ abstract class ManagerLocationProvider( override fun start() { Log.v(LOG_TAG, "ManagerLocationProvider.start()") - //Don't start the LocationProvider if we dont have any permissions + // Don't start the LocationProvider if we dont have any permissions if (!this.isPermissionGranted) { Log.d(LOG_TAG, "Tried to start provider '$type' without permission granted") return @@ -36,7 +35,7 @@ abstract class ManagerLocationProvider( Log.v( LOG_TAG, - "ManagerLocationProvider.start() background: $isInBackground, type: $type, minTime: $minTime, minDistance: $minDistance" + "ManagerLocationProvider.start() background: $isInBackground, type: $type, minTime: $minTime, minDistance: $minDistance", ) if (locationManager.allProviders.contains(type)) { try { @@ -58,7 +57,7 @@ abstract class ManagerLocationProvider( updateToLastKnown() locationManager.requestLocationUpdates(type, minTime, minDistance, this) - //Now try to get the last known location from the current provider + // Now try to get the last known location from the current provider val lastKnownLocation = locationManager.getLastKnownLocation(type) if (lastKnownLocation != null) { val secondsElapsedSinceLastFix = (System.currentTimeMillis() - lastKnownLocation.time) / 1000 @@ -77,12 +76,15 @@ abstract class ManagerLocationProvider( override fun onProviderEnabled(provider: String) = Unit - override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) = Unit + override fun onStatusChanged( + provider: String?, + status: Int, + extras: Bundle?, + ) = Unit override val isEnabled: Boolean get() = locationManager.isProviderEnabled(type) - override fun shutdown() { locationManager.removeUpdates(this) @@ -120,11 +122,12 @@ abstract class ManagerLocationProvider( get() = "could not enable location manager $type" private fun updateToLastKnown() { - val location = try { - locationManager.getLastKnownLocation(type) - } catch (securityException: SecurityException) { - null - } + val location = + try { + locationManager.getLastKnownLocation(type) + } catch (securityException: SecurityException) { + null + } Log.v(LOG_TAG, "ManagerLocationProvider: last known $location") if (location != null) { onLocationChanged(location) diff --git a/app/src/main/java/org/blitzortung/android/location/provider/ManualLocationProvider.kt b/app/src/main/java/org/blitzortung/android/location/provider/ManualLocationProvider.kt index 4f1ef4d54..0b255eaed 100644 --- a/app/src/main/java/org/blitzortung/android/location/provider/ManualLocationProvider.kt +++ b/app/src/main/java/org/blitzortung/android/location/provider/ManualLocationProvider.kt @@ -13,7 +13,10 @@ class ManualLocationProvider(locationUpdate: (Location?) -> Unit, private val sh LocationProvider(locationUpdate), OnSharedPreferenceChangeListener { override val isEnabled: Boolean = true - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: PreferenceKey) { + override fun onSharedPreferenceChanged( + sharedPreferences: SharedPreferences, + key: PreferenceKey, + ) { when (key) { PreferenceKey.LOCATION_LONGITUDE, PreferenceKey.LOCATION_LATITUDE -> { sendLocationUpdate(getManualLocation(sharedPreferences)) @@ -39,13 +42,11 @@ class ManualLocationProvider(locationUpdate: (Location?) -> Unit, private val sh super.shutdown() } - override fun reconfigureProvider(isInBackground: Boolean) { /* Nothing to do here */ + override fun reconfigureProvider(isInBackground: Boolean) { // Nothing to do here } companion object { - fun getManualLocation( - sharedPreferences: SharedPreferences, - ): Location? { + fun getManualLocation(sharedPreferences: SharedPreferences): Location? { val doubleConverter = fun(x: String): Double? { try { return x.toDouble() diff --git a/app/src/main/java/org/blitzortung/android/location/provider/NetworkLocationProvider.kt b/app/src/main/java/org/blitzortung/android/location/provider/NetworkLocationProvider.kt index b5a65cb5c..966e7a57b 100644 --- a/app/src/main/java/org/blitzortung/android/location/provider/NetworkLocationProvider.kt +++ b/app/src/main/java/org/blitzortung/android/location/provider/NetworkLocationProvider.kt @@ -11,12 +11,12 @@ import javax.inject.Singleton class NetworkLocationProvider( context: Context, backgroundMode: Boolean, - locationUpdate: (Location?) -> Unit + locationUpdate: (Location?) -> Unit, ) : ManagerLocationProvider(context, backgroundMode, locationUpdate, LocationManager.NETWORK_PROVIDER) { - override val isPermissionGranted: Boolean - get() = PermissionChecker.checkSelfPermission( - context, - Manifest.permission.ACCESS_COARSE_LOCATION - ) == PermissionChecker.PERMISSION_GRANTED -} \ No newline at end of file + get() = + PermissionChecker.checkSelfPermission( + context, + Manifest.permission.ACCESS_COARSE_LOCATION, + ) == PermissionChecker.PERMISSION_GRANTED +} diff --git a/app/src/main/java/org/blitzortung/android/location/provider/PassiveLocationProvider.kt b/app/src/main/java/org/blitzortung/android/location/provider/PassiveLocationProvider.kt index 484bcfb1b..694b26254 100644 --- a/app/src/main/java/org/blitzortung/android/location/provider/PassiveLocationProvider.kt +++ b/app/src/main/java/org/blitzortung/android/location/provider/PassiveLocationProvider.kt @@ -11,12 +11,12 @@ import javax.inject.Singleton class PassiveLocationProvider( context: Context, backgroundMode: Boolean, - locationUpdate: (Location?) -> Unit + locationUpdate: (Location?) -> Unit, ) : ManagerLocationProvider(context, backgroundMode, locationUpdate, LocationManager.PASSIVE_PROVIDER) { - override val isPermissionGranted: Boolean - get() = PermissionChecker.checkSelfPermission( - context, - Manifest.permission.ACCESS_FINE_LOCATION - ) == PermissionChecker.PERMISSION_GRANTED -} \ No newline at end of file + get() = + PermissionChecker.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION, + ) == PermissionChecker.PERMISSION_GRANTED +} diff --git a/app/src/main/java/org/blitzortung/android/map/MapFragment.kt b/app/src/main/java/org/blitzortung/android/map/MapFragment.kt index ddf68b043..44717d17c 100644 --- a/app/src/main/java/org/blitzortung/android/map/MapFragment.kt +++ b/app/src/main/java/org/blitzortung/android/map/MapFragment.kt @@ -7,9 +7,12 @@ import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.content.edit import androidx.fragment.app.Fragment import androidx.preference.PreferenceManager +import kotlin.math.min import org.blitzortung.android.app.Main +import org.blitzortung.android.app.Main.Companion.LOG_TAG import org.blitzortung.android.app.helper.ViewHelper import org.blitzortung.android.app.view.OnSharedPreferenceChangeListener import org.blitzortung.android.app.view.PreferenceKey @@ -19,21 +22,19 @@ import org.osmdroid.util.GeoPoint import org.osmdroid.views.CustomZoomButtonsController import org.osmdroid.views.overlay.CopyrightOverlay import org.osmdroid.views.overlay.ScaleBarOverlay -import kotlin.math.min -import androidx.core.content.edit -import org.blitzortung.android.app.Main.Companion.LOG_TAG -import org.blitzortung.android.data.AUTO_GRID_SIZE_VALUE - class MapFragment : Fragment(), OnSharedPreferenceChangeListener { - private lateinit var mPrefs: SharedPreferences lateinit var mapView: OwnMapView private set private lateinit var mCopyrightOverlay: CopyrightOverlay private lateinit var mScaleBarOverlay: ScaleBarOverlay - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { mapView = OwnMapView(inflater.context) mapView.tileProvider.tileCache.apply { @@ -82,7 +83,7 @@ class MapFragment : Fragment(), OnSharedPreferenceChangeListener { // scales tiles to the current screen's DPI, helps with readability of labels mapView.isTilesScaledToDpi = true - //the rest of this is restoring the last map location the user looked at + // the rest of this is restoring the last map location the user looked at val zoomLevel = mPrefs.getFloat(PREFS_ZOOM_LEVEL_DOUBLE, 3.0f) mapView.controller.setZoom(zoomLevel.toDouble()) mapView.setMapOrientation(0f, false) @@ -105,7 +106,7 @@ class MapFragment : Fragment(), OnSharedPreferenceChangeListener { } override fun onPause() { - //save the current location + // save the current location mPrefs.edit { putString(PREFS_TILE_SOURCE, mapView.tileProvider.tileSource.name()) putString(PREFS_LATITUDE_STRING, mapView.mapCenter.latitude.toString()) @@ -143,11 +144,16 @@ class MapFragment : Fragment(), OnSharedPreferenceChangeListener { return zoomLevel - 1.0 } - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: PreferenceKey) { + override fun onSharedPreferenceChanged( + sharedPreferences: SharedPreferences, + key: PreferenceKey, + ) { when (key) { PreferenceKey.MAP_TYPE -> { val mapTypeString = sharedPreferences.get(key, "SATELLITE") - mapView.setTileSource(if (mapTypeString == "SATELLITE") TileSourceFactory.DEFAULT_TILE_SOURCE else TileSourceFactory.MAPNIK) + mapView.setTileSource( + if (mapTypeString == "SATELLITE") TileSourceFactory.DEFAULT_TILE_SOURCE else TileSourceFactory.MAPNIK, + ) } PreferenceKey.MAP_SCALE -> { diff --git a/app/src/main/java/org/blitzortung/android/map/OwnMapView.kt b/app/src/main/java/org/blitzortung/android/map/OwnMapView.kt index 4ed33245b..2f0b50356 100644 --- a/app/src/main/java/org/blitzortung/android/map/OwnMapView.kt +++ b/app/src/main/java/org/blitzortung/android/map/OwnMapView.kt @@ -29,6 +29,7 @@ import android.view.MotionEvent import android.view.View import androidx.appcompat.app.AlertDialog import androidx.preference.PreferenceManager +import java.util.Locale import org.blitzortung.android.app.Main import org.blitzortung.android.app.Main.Companion.LOG_TAG import org.blitzortung.android.app.R @@ -36,11 +37,8 @@ import org.blitzortung.android.app.view.PreferenceKey import org.blitzortung.android.location.LocationHandler import org.osmdroid.views.MapController import org.osmdroid.views.MapView -import java.util.Locale - class OwnMapView(context: Context) : MapView(context) { - private val gestureDetector: GestureDetector = GestureDetector(context, GestureListener()) init { @@ -49,7 +47,6 @@ class OwnMapView(context: Context) : MapView(context) { } inner class GestureListener : GestureDetector.SimpleOnGestureListener() { - private val point: Point = Point() override fun onDoubleTap(event: MotionEvent): Boolean { @@ -70,22 +67,23 @@ class OwnMapView(context: Context) : MapView(context) { val context = this@OwnMapView.context val locationText = context.resources.getString(R.string.set_manual_location) - val dialogClickListener = DialogInterface.OnClickListener { _, which -> - when (which) { - DialogInterface.BUTTON_POSITIVE -> { - val preferences = PreferenceManager.getDefaultSharedPreferences(context) - preferences.edit().apply { - putString(PreferenceKey.LOCATION_LONGITUDE.toString(), roundCoordinate(longitude)) - putString(PreferenceKey.LOCATION_LATITUDE.toString(), roundCoordinate(latitude)) - putString(PreferenceKey.LOCATION_MODE.toString(), LocationHandler.MANUAL_PROVIDER) - apply() + val dialogClickListener = + DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + preferences.edit().apply { + putString(PreferenceKey.LOCATION_LONGITUDE.toString(), roundCoordinate(longitude)) + putString(PreferenceKey.LOCATION_LATITUDE.toString(), roundCoordinate(latitude)) + putString(PreferenceKey.LOCATION_MODE.toString(), LocationHandler.MANUAL_PROVIDER) + apply() + } } - } - DialogInterface.BUTTON_NEGATIVE -> { + DialogInterface.BUTTON_NEGATIVE -> { + } } } - } AlertDialog.Builder(context) .setMessage("$locationText: %.4f %.4f?".format(longitude, latitude)) diff --git a/app/src/main/java/org/blitzortung/android/map/StrikePopup.kt b/app/src/main/java/org/blitzortung/android/map/StrikePopup.kt index ae14e072d..b328b7f43 100644 --- a/app/src/main/java/org/blitzortung/android/map/StrikePopup.kt +++ b/app/src/main/java/org/blitzortung/android/map/StrikePopup.kt @@ -8,8 +8,10 @@ import org.blitzortung.android.map.overlay.GridShape import org.blitzortung.android.map.overlay.StrikeOverlay import org.blitzortung.android.map.overlay.StrikeShape - -fun createStrikePopUp(popUp: View, strikeOverlay: StrikeOverlay): View { +fun createStrikePopUp( + popUp: View, + strikeOverlay: StrikeOverlay, +): View { var result = DateFormat.format("kk:mm:ss", strikeOverlay.timestamp) as String if (strikeOverlay.shape is GridShape) { @@ -25,4 +27,4 @@ fun createStrikePopUp(popUp: View, strikeOverlay: StrikeOverlay): View { } return popUp -} \ No newline at end of file +} diff --git a/app/src/main/java/org/blitzortung/android/map/components/LayerOverlayComponent.kt b/app/src/main/java/org/blitzortung/android/map/components/LayerOverlayComponent.kt index 10f1739cd..3af38a6cf 100644 --- a/app/src/main/java/org/blitzortung/android/map/components/LayerOverlayComponent.kt +++ b/app/src/main/java/org/blitzortung/android/map/components/LayerOverlayComponent.kt @@ -21,5 +21,5 @@ package org.blitzortung.android.map.components import org.blitzortung.android.map.overlay.LayerOverlay class LayerOverlayComponent( - override var visible: Boolean = true + override var visible: Boolean = true, ) : LayerOverlay diff --git a/app/src/main/java/org/blitzortung/android/map/overlay/FadeOverlay.kt b/app/src/main/java/org/blitzortung/android/map/overlay/FadeOverlay.kt index 16fc25bb9..7e3b42910 100644 --- a/app/src/main/java/org/blitzortung/android/map/overlay/FadeOverlay.kt +++ b/app/src/main/java/org/blitzortung/android/map/overlay/FadeOverlay.kt @@ -25,10 +25,13 @@ import org.osmdroid.views.MapView import org.osmdroid.views.overlay.Overlay class FadeOverlay(private val colorHandler: ColorHandler) : Overlay() { - private var alphaValue = 0 - override fun draw(canvas: Canvas?, mapView: MapView?, shadow: Boolean) { + override fun draw( + canvas: Canvas?, + mapView: MapView?, + shadow: Boolean, + ) { if (!shadow) { val rect = canvas!!.clipBounds val paint = Paint() diff --git a/app/src/main/java/org/blitzortung/android/map/overlay/GridShape.kt b/app/src/main/java/org/blitzortung/android/map/overlay/GridShape.kt index 6961c0cf1..eea4848de 100644 --- a/app/src/main/java/org/blitzortung/android/map/overlay/GridShape.kt +++ b/app/src/main/java/org/blitzortung/android/map/overlay/GridShape.kt @@ -23,30 +23,34 @@ import android.graphics.Paint import android.graphics.Paint.Align import android.graphics.Point import android.graphics.RectF +import kotlin.math.max +import kotlin.math.min import org.osmdroid.api.IGeoPoint import org.osmdroid.views.MapView import org.osmdroid.views.Projection -import kotlin.math.max -import kotlin.math.min class GridShape(private val center: IGeoPoint) : LightningShape { - private val size: RectF = RectF() private var color: Int = 0 private var alpha: Int = 0 private var multiplicity: Int = 0 private var textColor: Int = 0 - override fun draw(canvas: Canvas, mapView: MapView, paint: Paint) { + override fun draw( + canvas: Canvas, + mapView: MapView, + paint: Paint, + ) { val centerPoint = Point() mapView.projection.toPixels(center, centerPoint) - val rect = RectF( - centerPoint.x + size.left, - centerPoint.y + size.top, - centerPoint.x + size.right, - centerPoint.y + size.bottom - ) + val rect = + RectF( + centerPoint.x + size.left, + centerPoint.y + size.top, + centerPoint.x + size.right, + centerPoint.y + size.bottom, + ) // Only draw visible Grid-Items if (canvas.quickReject(rect, Canvas.EdgeType.BW)) { @@ -67,23 +71,32 @@ class GridShape(private val center: IGeoPoint) : LightningShape { multiplicity.toString(), centerPoint.x.toFloat(), centerPoint.y.toFloat() + textSize / 2, - paint + paint, ) } } - override fun isPointInside(tappedGeoPoint: IGeoPoint, projection: Projection): Boolean { + override fun isPointInside( + tappedGeoPoint: IGeoPoint, + projection: Projection, + ): Boolean { val shapeCenter = Point() projection.toPixels(center, shapeCenter) val tappedPoint = Point() projection.toPixels(tappedGeoPoint, tappedPoint) - return tappedPoint.x >= shapeCenter.x + size.left && tappedPoint.x <= shapeCenter.x + size.right - && tappedPoint.y >= shapeCenter.y + size.top && tappedPoint.y <= shapeCenter.y + size.bottom + return tappedPoint.x >= shapeCenter.x + size.left && tappedPoint.x <= shapeCenter.x + size.right && + tappedPoint.y >= shapeCenter.y + size.top && tappedPoint.y <= shapeCenter.y + size.bottom } - fun update(topLeft: Point, bottomRight: Point, color: Int, multiplicity: Int, textColor: Int) { + fun update( + topLeft: Point, + bottomRight: Point, + color: Int, + multiplicity: Int, + textColor: Int, + ) { val x1 = min(topLeft.x.toFloat(), -MIN_SIZE) val y1 = min(topLeft.y.toFloat(), -MIN_SIZE) val x2 = max(bottomRight.x.toFloat(), MIN_SIZE) @@ -97,7 +110,13 @@ class GridShape(private val center: IGeoPoint) : LightningShape { alpha = calculateAlphaValue(size.width(), 10, 40, 255, 100) } - private fun calculateAlphaValue(value: Float, minValue: Int, maxValue: Int, maxAlpha: Int, minAlpha: Int): Int { + private fun calculateAlphaValue( + value: Float, + minValue: Int, + maxValue: Int, + maxAlpha: Int, + minAlpha: Int, + ): Int { val targetValue = ((value - minValue) / (maxValue - minValue)).coerceIn(0.0f, 1.0f) return minAlpha + ((maxAlpha - minAlpha) * (1.0 - targetValue)).toInt() } diff --git a/app/src/main/java/org/blitzortung/android/map/overlay/LightningShape.kt b/app/src/main/java/org/blitzortung/android/map/overlay/LightningShape.kt index e63733346..e9773fea1 100644 --- a/app/src/main/java/org/blitzortung/android/map/overlay/LightningShape.kt +++ b/app/src/main/java/org/blitzortung/android/map/overlay/LightningShape.kt @@ -7,7 +7,14 @@ import org.osmdroid.views.MapView import org.osmdroid.views.Projection interface LightningShape { - fun draw(canvas: Canvas, mapView: MapView, paint: Paint) + fun draw( + canvas: Canvas, + mapView: MapView, + paint: Paint, + ) - fun isPointInside(tappedGeoPoint: IGeoPoint, projection: Projection): Boolean -} \ No newline at end of file + fun isPointInside( + tappedGeoPoint: IGeoPoint, + projection: Projection, + ): Boolean +} diff --git a/app/src/main/java/org/blitzortung/android/map/overlay/OwnLocationOverlay.kt b/app/src/main/java/org/blitzortung/android/map/overlay/OwnLocationOverlay.kt index d81782fd2..f7b23f176 100644 --- a/app/src/main/java/org/blitzortung/android/map/overlay/OwnLocationOverlay.kt +++ b/app/src/main/java/org/blitzortung/android/map/overlay/OwnLocationOverlay.kt @@ -42,10 +42,11 @@ import org.osmdroid.views.overlay.ItemizedOverlay class OwnLocationOverlay( context: Context, - private val mapView: MapView + private val mapView: MapView, ) : ItemizedOverlay(DEFAULT_DRAWABLE), OnSharedPreferenceChangeListener, - LayerOverlay, MapListener { + LayerOverlay, + MapListener { private val layerOverlayComponent: LayerOverlayComponent = LayerOverlayComponent() @@ -87,7 +88,11 @@ class OwnLocationOverlay( refresh() } - override fun draw(canvas: Canvas?, mapView: MapView?, shadow: Boolean) { + override fun draw( + canvas: Canvas?, + mapView: MapView?, + shadow: Boolean, + ) { if (!shadow) { super.draw(canvas, mapView, false) } @@ -116,11 +121,19 @@ class OwnLocationOverlay( refresh() } - override fun onSnapToItem(x: Int, y: Int, snapPoint: Point?, mapView: IMapView?): Boolean { + override fun onSnapToItem( + x: Int, + y: Int, + snapPoint: Point?, + mapView: IMapView?, + ): Boolean { return false } - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: PreferenceKey) { + override fun onSharedPreferenceChanged( + sharedPreferences: SharedPreferences, + key: PreferenceKey, + ) { if (key == PreferenceKey.SHOW_LOCATION) { val showLocation = sharedPreferences.get(key, false) @@ -130,9 +143,10 @@ class OwnLocationOverlay( disableOwnLocation() } } else if (key == PreferenceKey.OWN_LOCATION_SIZE) { - symbolSize = sharedPreferences.getAndConvert(PreferenceKey.OWN_LOCATION_SIZE, 100) { - it.toFloat() / 100 - } + symbolSize = + sharedPreferences.getAndConvert(PreferenceKey.OWN_LOCATION_SIZE, 100) { + it.toFloat() / 100 + } } } @@ -158,7 +172,6 @@ class OwnLocationOverlay( } companion object { - private val DEFAULT_DRAWABLE: Drawable init { diff --git a/app/src/main/java/org/blitzortung/android/map/overlay/OwnLocationOverlayItem.kt b/app/src/main/java/org/blitzortung/android/map/overlay/OwnLocationOverlayItem.kt index 8fb0f8123..fc595ed0f 100644 --- a/app/src/main/java/org/blitzortung/android/map/overlay/OwnLocationOverlayItem.kt +++ b/app/src/main/java/org/blitzortung/android/map/overlay/OwnLocationOverlayItem.kt @@ -19,10 +19,9 @@ package org.blitzortung.android.map.overlay import android.location.Location - import org.blitzortung.android.data.Coordsys import org.osmdroid.views.overlay.OverlayItem class OwnLocationOverlayItem( - location: Location + location: Location, ) : OverlayItem("own location", "", Coordsys.toMapCoords(location.longitude, location.latitude)) diff --git a/app/src/main/java/org/blitzortung/android/map/overlay/OwnLocationShape.kt b/app/src/main/java/org/blitzortung/android/map/overlay/OwnLocationShape.kt index 032a93074..90498b4fa 100644 --- a/app/src/main/java/org/blitzortung/android/map/overlay/OwnLocationShape.kt +++ b/app/src/main/java/org/blitzortung/android/map/overlay/OwnLocationShape.kt @@ -23,8 +23,10 @@ import android.graphics.Paint import android.graphics.drawable.shapes.Shape class OwnLocationShape(private val size: Float) : Shape() { - - override fun draw(canvas: Canvas, paint: Paint) { + override fun draw( + canvas: Canvas, + paint: Paint, + ) { paint.style = Paint.Style.FILL paint.color = -2007673515 canvas.drawCircle(0.0f, 0.0f, size / 1.3f, paint) @@ -37,5 +39,4 @@ class OwnLocationShape(private val size: Float) : Shape() { paint.color = -13391105 canvas.drawCircle(0.0f, 0.0f, size / 1.3f, paint) } - } diff --git a/app/src/main/java/org/blitzortung/android/map/overlay/ParticipantOverlayItem.kt b/app/src/main/java/org/blitzortung/android/map/overlay/ParticipantOverlayItem.kt index 1c952b192..c33e2c39a 100644 --- a/app/src/main/java/org/blitzortung/android/map/overlay/ParticipantOverlayItem.kt +++ b/app/src/main/java/org/blitzortung/android/map/overlay/ParticipantOverlayItem.kt @@ -24,9 +24,8 @@ import org.blitzortung.android.data.beans.Station.State import org.osmdroid.views.overlay.OverlayItem class ParticipantOverlayItem( - station: Station + station: Station, ) : OverlayItem(station.name, "", Coordsys.toMapCoords(station.longitude, station.latitude)) { - private val lastDataTime: Long = station.offlineSince private val participantState: State diff --git a/app/src/main/java/org/blitzortung/android/map/overlay/ParticipantShape.kt b/app/src/main/java/org/blitzortung/android/map/overlay/ParticipantShape.kt index ad77d1c0c..cb8fbe5ee 100644 --- a/app/src/main/java/org/blitzortung/android/map/overlay/ParticipantShape.kt +++ b/app/src/main/java/org/blitzortung/android/map/overlay/ParticipantShape.kt @@ -24,7 +24,6 @@ import android.graphics.RectF import android.graphics.drawable.shapes.Shape class ParticipantShape : Shape() { - private val rect: RectF = RectF() private var color: Int = 0 @@ -32,14 +31,20 @@ class ParticipantShape : Shape() { color = 0 } - override fun draw(canvas: Canvas, paint: Paint) { + override fun draw( + canvas: Canvas, + paint: Paint, + ) { paint.color = color paint.alpha = 255 paint.style = Paint.Style.FILL canvas.drawRect(rect, paint) } - fun update(size: Float, color: Int) { + fun update( + size: Float, + color: Int, + ) { val halfSize = size / 2f rect.set(-halfSize, -halfSize, halfSize, halfSize) resize(rect.width(), rect.width()) diff --git a/app/src/main/java/org/blitzortung/android/map/overlay/StrikeListOverlay.kt b/app/src/main/java/org/blitzortung/android/map/overlay/StrikeListOverlay.kt index cd794c849..01a057b22 100644 --- a/app/src/main/java/org/blitzortung/android/map/overlay/StrikeListOverlay.kt +++ b/app/src/main/java/org/blitzortung/android/map/overlay/StrikeListOverlay.kt @@ -24,6 +24,8 @@ import android.graphics.Paint.Style import android.util.Log import android.view.MotionEvent import android.view.ViewGroup +import kotlin.math.max +import kotlin.math.min import org.blitzortung.android.app.Main import org.blitzortung.android.data.Parameters import org.blitzortung.android.data.beans.GridParameters @@ -39,11 +41,11 @@ import org.osmdroid.events.ScrollEvent import org.osmdroid.events.ZoomEvent import org.osmdroid.views.MapView import org.osmdroid.views.overlay.Overlay -import kotlin.math.max -import kotlin.math.min -class StrikeListOverlay(private val mapFragment: MapFragment, val colorHandler: StrikeColorHandler) : Overlay(), - LayerOverlay, MapListener { +class StrikeListOverlay(private val mapFragment: MapFragment, val colorHandler: StrikeColorHandler) : + Overlay(), + LayerOverlay, + MapListener { private val strikeList = mutableListOf() private val layerOverlayComponent: LayerOverlayComponent = LayerOverlayComponent() @@ -57,7 +59,11 @@ class StrikeListOverlay(private val mapFragment: MapFragment, val colorHandler: zoomLevel = mapFragment.mapView.zoomLevelDouble } - override fun draw(canvas: Canvas?, mapView: MapView?, shadow: Boolean) { + override fun draw( + canvas: Canvas?, + mapView: MapView?, + shadow: Boolean, + ) { if (!shadow) { if (canvas == null || mapView == null) { return @@ -84,7 +90,11 @@ class StrikeListOverlay(private val mapFragment: MapFragment, val colorHandler: updateTotalNumberOfStrikes() } - private fun drawDataAreaRect(canvas: Canvas, mapView: MapView, paint: Paint) { + private fun drawDataAreaRect( + canvas: Canvas, + mapView: MapView, + paint: Paint, + ) { val clipBounds = canvas.clipBounds val currentGridParameters = gridParameters if (currentGridParameters != null) { @@ -96,7 +106,7 @@ class StrikeListOverlay(private val mapFragment: MapFragment, val colorHandler: max(rect.top, clipBounds.top.toFloat()), rect.left, min(rect.bottom, clipBounds.bottom.toFloat()), - paint + paint, ) } if (rect.right >= clipBounds.left && rect.right <= clipBounds.right) { @@ -105,7 +115,7 @@ class StrikeListOverlay(private val mapFragment: MapFragment, val colorHandler: max(rect.top, clipBounds.top.toFloat()), rect.right, min(rect.bottom, clipBounds.bottom.toFloat()), - paint + paint, ) } if (rect.bottom <= clipBounds.bottom && rect.bottom >= clipBounds.top) { @@ -114,7 +124,7 @@ class StrikeListOverlay(private val mapFragment: MapFragment, val colorHandler: rect.bottom, min(rect.right, clipBounds.right.toFloat()), rect.bottom, - paint + paint, ) } if (rect.top <= clipBounds.bottom && rect.top >= clipBounds.top) { @@ -123,7 +133,7 @@ class StrikeListOverlay(private val mapFragment: MapFragment, val colorHandler: rect.top, min(rect.right, clipBounds.right.toFloat()), rect.top, - paint + paint, ) } } @@ -138,7 +148,7 @@ class StrikeListOverlay(private val mapFragment: MapFragment, val colorHandler: strikeList.removeAll { it.timestamp < expireTime } Log.v( Main.LOG_TAG, - "StrikesListOverlay.expireStrikes() expired ${sizeBefore - strikeList.size} from $sizeBefore (first: $firstTime, difference: $difference, ref: $referenceTime" + "StrikesListOverlay.expireStrikes() expired ${sizeBefore - strikeList.size} from $sizeBefore (first: $firstTime, difference: $difference, ref: $referenceTime", ) updateTotalNumberOfStrikes() @@ -150,7 +160,10 @@ class StrikeListOverlay(private val mapFragment: MapFragment, val colorHandler: updateTotalNumberOfStrikes() } - override fun onSingleTapUp(e: MotionEvent?, mapView: MapView?): Boolean { + override fun onSingleTapUp( + e: MotionEvent?, + mapView: MapView?, + ): Boolean { if (e != null && mapView != null) { val point = mapView.projection.fromPixels(e.x.toInt(), e.y.toInt()) @@ -163,10 +176,15 @@ class StrikeListOverlay(private val mapFragment: MapFragment, val colorHandler: if (strikeTapped != null) { val newPopup = createStrikePopUp(popup, strikeTapped) - val mapParams = MapView.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, - point, 0, 0, MapView.LayoutParams.BOTTOM_CENTER - ) + val mapParams = + MapView.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + point, + 0, + 0, + MapView.LayoutParams.BOTTOM_CENTER, + ) mapView.addView(newPopup, mapParams) @@ -185,13 +203,16 @@ class StrikeListOverlay(private val mapFragment: MapFragment, val colorHandler: var drawable: LightningShape? = null for (item in strikeList) { - val section = colorHandler.getColorSection( - if (hasRealtimeData()) - System.currentTimeMillis() - else - referenceTime, - item.timestamp, parameters.intervalDuration - ) + val section = + colorHandler.getColorSection( + if (hasRealtimeData()) { + System.currentTimeMillis() + } else { + referenceTime + }, + item.timestamp, + parameters.intervalDuration, + ) if (usesGrid() || currentSection != section) { drawable = updateAndReturnDrawable(item, section, colorHandler) @@ -203,7 +224,11 @@ class StrikeListOverlay(private val mapFragment: MapFragment, val colorHandler: } } - private fun updateAndReturnDrawable(item: StrikeOverlay, section: Int, colorHandler: ColorHandler): LightningShape { + private fun updateAndReturnDrawable( + item: StrikeOverlay, + section: Int, + colorHandler: ColorHandler, + ): LightningShape { val projection = mapFragment.mapView.projection val color = colorHandler.getColor(section) val textColor = colorHandler.textColor @@ -228,7 +253,6 @@ class StrikeListOverlay(private val mapFragment: MapFragment, val colorHandler: return false } - fun usesGrid(): Boolean { return gridParameters != null } @@ -249,4 +273,4 @@ class StrikeListOverlay(private val mapFragment: MapFragment, val colorHandler: set(value) { layerOverlayComponent.visible = value } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/blitzortung/android/map/overlay/StrikeOverlay.kt b/app/src/main/java/org/blitzortung/android/map/overlay/StrikeOverlay.kt index 48ad58da6..c2ce6a9ac 100644 --- a/app/src/main/java/org/blitzortung/android/map/overlay/StrikeOverlay.kt +++ b/app/src/main/java/org/blitzortung/android/map/overlay/StrikeOverlay.kt @@ -18,7 +18,11 @@ class StrikeOverlay(strike: Strike) { var shape: LightningShape? = null - fun draw(canvas: Canvas, mapView: MapView, paint: Paint) { + fun draw( + canvas: Canvas, + mapView: MapView, + paint: Paint, + ) { shape?.run { draw(canvas, mapView, paint) } @@ -29,7 +33,7 @@ class StrikeOverlay(strike: Strike) { projection: Projection, color: Int, textColor: Int, - zoomLevel: Double + zoomLevel: Double, ) { var shape: LightningShape? = shape if (gridParameters != null) { @@ -44,14 +48,16 @@ class StrikeOverlay(strike: Strike) { projection.toPixels( GeoPoint( center.latitude + latDelta, - center.longitude - lonDelta - ), topLeft + center.longitude - lonDelta, + ), + topLeft, ) projection.toPixels( GeoPoint( center.latitude - latDelta, - center.longitude + lonDelta - ), bottomRight + center.longitude + lonDelta, + ), + bottomRight, ) topLeft.offset(-centerPoint.x, -centerPoint.y) bottomRight.offset(-centerPoint.x, -centerPoint.y) @@ -69,7 +75,10 @@ class StrikeOverlay(strike: Strike) { this.shape = shape } - fun pointIsInside(point: IGeoPoint, projection: Projection): Boolean { + fun pointIsInside( + point: IGeoPoint, + projection: Projection, + ): Boolean { return shape?.isPointInside(point, projection) == true } @@ -78,4 +87,4 @@ class StrikeOverlay(strike: Strike) { private val topLeft: Point = Point() private val bottomRight: Point = Point() } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/blitzortung/android/map/overlay/StrikeShape.kt b/app/src/main/java/org/blitzortung/android/map/overlay/StrikeShape.kt index 6664b5c94..924974158 100644 --- a/app/src/main/java/org/blitzortung/android/map/overlay/StrikeShape.kt +++ b/app/src/main/java/org/blitzortung/android/map/overlay/StrikeShape.kt @@ -26,30 +26,37 @@ import org.osmdroid.views.MapView import org.osmdroid.views.Projection class StrikeShape(private val center: IGeoPoint) : LightningShape { - override fun isPointInside(tappedGeoPoint: IGeoPoint, projection: Projection): Boolean { + override fun isPointInside( + tappedGeoPoint: IGeoPoint, + projection: Projection, + ): Boolean { val shapeCenter = Point() projection.toPixels(center, shapeCenter) val tappedPoint = Point() projection.toPixels(tappedGeoPoint, tappedPoint) - return tappedPoint.x >= shapeCenter.x - size / 2 && tappedPoint.x <= shapeCenter.x + size / 2 - && tappedPoint.y >= shapeCenter.y - size / 2 && tappedPoint.y <= shapeCenter.y + size / 2 + return tappedPoint.x >= shapeCenter.x - size / 2 && tappedPoint.x <= shapeCenter.x + size / 2 && + tappedPoint.y >= shapeCenter.y - size / 2 && tappedPoint.y <= shapeCenter.y + size / 2 } private var size: Float = 0.toFloat() private var color: Int = 0 - override fun draw(canvas: Canvas, mapView: MapView, paint: Paint) { + override fun draw( + canvas: Canvas, + mapView: MapView, + paint: Paint, + ) { mapView.projection.toPixels(center, centerPoint) - //Only draw it when its visible + // Only draw it when its visible if (canvas.quickReject( centerPoint.x - size / 2, centerPoint.y - size / 2, centerPoint.x + size / 2, centerPoint.y + size / 2, - Canvas.EdgeType.BW + Canvas.EdgeType.BW, ) ) { return @@ -64,7 +71,7 @@ class StrikeShape(private val center: IGeoPoint) : LightningShape { centerPoint.y.toFloat(), centerPoint.x + size / 2, centerPoint.y.toFloat(), - paint + paint, ) canvas.drawLine( @@ -72,11 +79,14 @@ class StrikeShape(private val center: IGeoPoint) : LightningShape { centerPoint.y - size / 2, centerPoint.x.toFloat(), centerPoint.y + size / 2, - paint + paint, ) } - fun update(size: Float, color: Int) { + fun update( + size: Float, + color: Int, + ) { this.size = size this.color = color } diff --git a/app/src/main/java/org/blitzortung/android/map/overlay/color/ColorHandler.kt b/app/src/main/java/org/blitzortung/android/map/overlay/color/ColorHandler.kt index b5a2a0917..12497e7ba 100644 --- a/app/src/main/java/org/blitzortung/android/map/overlay/color/ColorHandler.kt +++ b/app/src/main/java/org/blitzortung/android/map/overlay/color/ColorHandler.kt @@ -20,13 +20,12 @@ package org.blitzortung.android.map.overlay.color import android.content.SharedPreferences import android.graphics.Color -import org.blitzortung.android.app.view.PreferenceKey -import org.blitzortung.android.app.view.get import kotlin.math.max import kotlin.math.min +import org.blitzortung.android.app.view.PreferenceKey +import org.blitzortung.android.app.view.get abstract class ColorHandler(private val preferences: SharedPreferences) { - var colorScheme: ColorScheme = ColorScheme.BLITZORTUNG private set @@ -55,14 +54,22 @@ abstract class ColorHandler(private val preferences: SharedPreferences) { abstract fun getColors(target: ColorTarget): IntArray - fun getColorSection(referenceTime: Long, eventTime: Long, intervalDuration: Int): Int { + fun getColorSection( + referenceTime: Long, + eventTime: Long, + intervalDuration: Int, + ): Int { val minutesPerColor = intervalDuration / colors.size val section = if (minutesPerColor > 0) ((referenceTime - eventTime) / 1000 / 60 / minutesPerColor).toInt() else 0 return limitToValidRange(section) } - fun getColor(referenceTime: Long, eventTime: Long, intervalDuration: Int): Int { + fun getColor( + referenceTime: Long, + eventTime: Long, + intervalDuration: Int, + ): Int { return getColor(getColorSection(referenceTime, eventTime, intervalDuration)) } @@ -107,7 +114,10 @@ abstract class ColorHandler(private val preferences: SharedPreferences) { val numberOfColors: Int get() = colors.size - fun modifyBrightness(colors: IntArray, factor: Float): IntArray { + fun modifyBrightness( + colors: IntArray, + factor: Float, + ): IntArray { val hsvValues = FloatArray(3) return colors.map { @@ -116,5 +126,4 @@ abstract class ColorHandler(private val preferences: SharedPreferences) { Color.HSVToColor(hsvValues) }.toIntArray() } - -} \ No newline at end of file +} diff --git a/app/src/main/java/org/blitzortung/android/map/overlay/color/ColorScheme.kt b/app/src/main/java/org/blitzortung/android/map/overlay/color/ColorScheme.kt index 672df95c2..ccc025a26 100644 --- a/app/src/main/java/org/blitzortung/android/map/overlay/color/ColorScheme.kt +++ b/app/src/main/java/org/blitzortung/android/map/overlay/color/ColorScheme.kt @@ -26,8 +26,8 @@ enum class ColorScheme(val strikeColors: IntArray) { 0xffffe079.toInt(), 0xffffb060.toInt(), 0xfff08060.toInt(), - 0xffe05050.toInt() - ) + 0xffe05050.toInt(), + ), ), RAINBOW( intArrayOf( @@ -36,8 +36,8 @@ enum class ColorScheme(val strikeColors: IntArray) { 0xffffff00.toInt(), 0xff99ff22.toInt(), 0xff00ffff.toInt(), - 0xff6699ff.toInt() - ) + 0xff6699ff.toInt(), + ), ), TEMPERATURE( intArrayOf( @@ -46,7 +46,7 @@ enum class ColorScheme(val strikeColors: IntArray) { 0xffe0b0c0.toInt(), 0xffc0c0f0.toInt(), 0xff5080f0.toInt(), - 0xff4060ff.toInt() - ) - ); + 0xff4060ff.toInt(), + ), + ), } diff --git a/app/src/main/java/org/blitzortung/android/map/overlay/color/ColorTarget.kt b/app/src/main/java/org/blitzortung/android/map/overlay/color/ColorTarget.kt index 5fe42a48e..440637804 100644 --- a/app/src/main/java/org/blitzortung/android/map/overlay/color/ColorTarget.kt +++ b/app/src/main/java/org/blitzortung/android/map/overlay/color/ColorTarget.kt @@ -20,5 +20,5 @@ package org.blitzortung.android.map.overlay.color enum class ColorTarget { SATELLITE, - STREETMAP + STREETMAP, } diff --git a/app/src/main/java/org/blitzortung/android/map/overlay/color/ParticipantColorHandler.kt b/app/src/main/java/org/blitzortung/android/map/overlay/color/ParticipantColorHandler.kt index 01cbbd1ca..17e87499b 100644 --- a/app/src/main/java/org/blitzortung/android/map/overlay/color/ParticipantColorHandler.kt +++ b/app/src/main/java/org/blitzortung/android/map/overlay/color/ParticipantColorHandler.kt @@ -21,7 +21,6 @@ package org.blitzortung.android.map.overlay.color import android.content.SharedPreferences class ParticipantColorHandler(preferences: SharedPreferences) : ColorHandler(preferences) { - private val satelliteViewColors = intArrayOf(0xff88ff22.toInt(), 0xffff9900.toInt(), 0xffff0000.toInt()) private val mapColors = intArrayOf(0xff448811.toInt(), 0xff884400.toInt(), 0xff880000.toInt()) diff --git a/app/src/main/java/org/blitzortung/android/map/overlay/color/StrikeColorHandler.kt b/app/src/main/java/org/blitzortung/android/map/overlay/color/StrikeColorHandler.kt index af666d0fb..106f73984 100644 --- a/app/src/main/java/org/blitzortung/android/map/overlay/color/StrikeColorHandler.kt +++ b/app/src/main/java/org/blitzortung/android/map/overlay/color/StrikeColorHandler.kt @@ -21,7 +21,6 @@ package org.blitzortung.android.map.overlay.color import android.content.SharedPreferences open class StrikeColorHandler(preferences: SharedPreferences) : ColorHandler(preferences) { - private val streetmapColors = mutableMapOf() override fun getColors(target: ColorTarget): IntArray { diff --git a/app/src/main/java/org/blitzortung/android/protocol/ConsumerContainer.kt b/app/src/main/java/org/blitzortung/android/protocol/ConsumerContainer.kt index 3615abf1c..3871c93f8 100644 --- a/app/src/main/java/org/blitzortung/android/protocol/ConsumerContainer.kt +++ b/app/src/main/java/org/blitzortung/android/protocol/ConsumerContainer.kt @@ -19,7 +19,6 @@ package org.blitzortung.android.protocol abstract class ConsumerContainer

{ - private val consumers: MutableSet<(P) -> Unit> var currentPayload: P? = null diff --git a/app/src/main/java/org/blitzortung/android/settings/SettingsActivity.kt b/app/src/main/java/org/blitzortung/android/settings/SettingsActivity.kt index 437162b60..0e2bf9612 100644 --- a/app/src/main/java/org/blitzortung/android/settings/SettingsActivity.kt +++ b/app/src/main/java/org/blitzortung/android/settings/SettingsActivity.kt @@ -5,7 +5,6 @@ import androidx.appcompat.app.AppCompatActivity import androidx.core.view.WindowCompat import org.blitzortung.android.app.R - class SettingsActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -14,4 +13,4 @@ class SettingsActivity : AppCompatActivity() { setContentView(R.layout.activity_settings) } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/blitzortung/android/settings/SettingsFragment.kt b/app/src/main/java/org/blitzortung/android/settings/SettingsFragment.kt index 30f30e1f3..e64dfc227 100644 --- a/app/src/main/java/org/blitzortung/android/settings/SettingsFragment.kt +++ b/app/src/main/java/org/blitzortung/android/settings/SettingsFragment.kt @@ -11,6 +11,8 @@ import android.os.Bundle import android.provider.Settings import android.util.Log import android.view.View +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.edit import androidx.core.net.toUri import androidx.core.view.ViewCompat @@ -21,6 +23,8 @@ import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.SeekBarPreference import dagger.android.support.AndroidSupportInjection +import java.util.Locale +import javax.inject.Inject import org.blitzortung.android.app.Main.Companion.LOG_TAG import org.blitzortung.android.app.R import org.blitzortung.android.app.view.MessageListPreference @@ -29,25 +33,46 @@ import org.blitzortung.android.app.view.PreferenceKey import org.blitzortung.android.app.view.get import org.blitzortung.android.data.provider.DataProviderType import org.blitzortung.android.location.LocationHandler -import java.util.Locale -import javax.inject.Inject - -private const val REQUEST_CODE_ALERT_RINGTONE: Int = 123 class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeListener { - @set:Inject internal lateinit var preferences: SharedPreferences private val originalSummaries = mutableMapOf() + // Modern Activity Result API for ringtone picker + private val ringtonePickerLauncher: ActivityResultLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + result.data?.let { data -> + val ringtone = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, Uri::class.java) + } else { + @Suppress("DEPRECATION") + data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI) + } + if (::preferences.isInitialized) { + preferences.edit(commit = true) { + putString( + PreferenceKey.ALERT_SOUND_SIGNAL.toString(), + ringtone?.toString() ?: "", + ) + } + } else { + Log.e(LOG_TAG, "SharedPreferences not initialized when handling ringtone result") + } + } + } + override fun onAttach(context: Context) { Log.v(LOG_TAG, "SettingsFragment.onAttach()") AndroidSupportInjection.inject(this) super.onAttach(context) } - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + override fun onCreatePreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) { Log.v(LOG_TAG, "SettingsFragment.onCreatePreferences()") addPreferencesFromResource(R.xml.preferences) @@ -89,11 +114,12 @@ class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeLis val undefinedSummary = context?.getString(R.string.undefined) if (preferenceKey == PreferenceKey.PASSWORD) { - preference.summary = if (!currentValue.isNullOrEmpty()) { - "********" - } else { - originalSummary - } + preference.summary = + if (!currentValue.isNullOrEmpty()) { + "********" + } else { + originalSummary + } } else if (originalSummary.contains("%s")) { preference.summary = String.format(originalSummary, currentValue ?: "") } else { @@ -106,10 +132,17 @@ class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeLis } } - private fun extractURITitle(value: String): String = if (RingtoneManager.isDefault(value.toUri())) context?.getString(R.string.default_alarm_signal).toString() - else value.toUri().getQueryParameter("title") ?: value + private fun extractURITitle(value: String): String = + if (RingtoneManager.isDefault(value.toUri())) { + context?.getString(R.string.default_alarm_signal).toString() + } else { + value.toUri().getQueryParameter("title") ?: value + } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { super.onViewCreated(view, savedInstanceState) val recyclerView = listView ViewCompat.setOnApplyWindowInsetsListener(recyclerView) { v, insets -> @@ -119,7 +152,10 @@ class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeLis } } - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: PreferenceKey) { + override fun onSharedPreferenceChanged( + sharedPreferences: SharedPreferences, + key: PreferenceKey, + ) { when (key) { PreferenceKey.DATA_SOURCE -> configureDataSourcePreferences(sharedPreferences) PreferenceKey.LOCATION_MODE -> { @@ -139,7 +175,8 @@ class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeLis PreferenceKey.SERVICE_URL, PreferenceKey.LOCATION_LONGITUDE, PreferenceKey.LOCATION_LATITUDE, - PreferenceKey.ALERT_SOUND_SIGNAL -> { + PreferenceKey.ALERT_SOUND_SIGNAL, + -> { findPreference(key)?.let { updatePreferenceSummary(it) } } @@ -211,12 +248,13 @@ class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeLis intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true) intent.putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, Settings.System.DEFAULT_NOTIFICATION_URI) - val existingValue: String? = if (::preferences.isInitialized) { - preferences.getString(PreferenceKey.ALERT_SOUND_SIGNAL, null) - } else { - Log.e(LOG_TAG, "SharedPreferences not initialized in onPreferenceTreeClick") - null - } + val existingValue: String? = + if (::preferences.isInitialized) { + preferences.getString(PreferenceKey.ALERT_SOUND_SIGNAL, null) + } else { + Log.e(LOG_TAG, "SharedPreferences not initialized in onPreferenceTreeClick") + null + } if (existingValue != null) { if (existingValue.isEmpty()) { @@ -228,45 +266,35 @@ class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeLis intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, Settings.System.DEFAULT_NOTIFICATION_URI) } - startActivityForResult(intent, REQUEST_CODE_ALERT_RINGTONE) + ringtonePickerLauncher.launch(intent) true } else { super.onPreferenceTreeClick(preference) } } - @Deprecated("Deprecated in Java") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (requestCode == REQUEST_CODE_ALERT_RINGTONE && data != null) { - val ringtone = data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI) - if (::preferences.isInitialized) { - preferences.edit(commit = true) { - putString( - PreferenceKey.ALERT_SOUND_SIGNAL.toString(), - ringtone?.toString() ?: "" - ) - } - } else { - Log.e(LOG_TAG, "SharedPreferences not initialized in onActivityResult") - } - } else { - super.onActivityResult(requestCode, resultCode, data) - } - } } fun PreferenceFragmentCompat.findPreference(key: PreferenceKey): T? { return findPreference(key.toString()) } -fun SharedPreferences.getString(key: PreferenceKey, defValue: String?): String? = - getString(key.toString(), defValue) - -fun SharedPreferences.getInt(key: PreferenceKey, defValue: Int): Int = - getInt(key.toString(), defValue) - -fun SharedPreferences.Editor.putString(key: PreferenceKey, value: String?): SharedPreferences.Editor = - putString(key.toString(), value) - -fun SharedPreferences.Editor.putInt(key: PreferenceKey, value: Int): SharedPreferences.Editor = - putInt(key.toString(), value) +fun SharedPreferences.getString( + key: PreferenceKey, + defValue: String?, +): String? = getString(key.toString(), defValue) + +fun SharedPreferences.getInt( + key: PreferenceKey, + defValue: Int, +): Int = getInt(key.toString(), defValue) + +fun SharedPreferences.Editor.putString( + key: PreferenceKey, + value: String?, +): SharedPreferences.Editor = putString(key.toString(), value) + +fun SharedPreferences.Editor.putInt( + key: PreferenceKey, + value: Int, +): SharedPreferences.Editor = putInt(key.toString(), value) diff --git a/app/src/main/java/org/blitzortung/android/util/LogUtil.kt b/app/src/main/java/org/blitzortung/android/util/LogUtil.kt index ebb06874b..51f543f4d 100644 --- a/app/src/main/java/org/blitzortung/android/util/LogUtil.kt +++ b/app/src/main/java/org/blitzortung/android/util/LogUtil.kt @@ -9,4 +9,4 @@ object LogUtil { val timestamp: String get() = dateFormat.format(Date()) -} \ No newline at end of file +} diff --git a/app/src/main/java/org/blitzortung/android/util/MeasurementSystem.kt b/app/src/main/java/org/blitzortung/android/util/MeasurementSystem.kt index f33febda7..cad79ab1b 100644 --- a/app/src/main/java/org/blitzortung/android/util/MeasurementSystem.kt +++ b/app/src/main/java/org/blitzortung/android/util/MeasurementSystem.kt @@ -22,7 +22,8 @@ import org.blitzortung.android.app.R enum class MeasurementSystem(val unitNameString: Int, private val factor: Float) { METRIC(R.string.unit_km, 1000.0f), - IMPERIAL(R.string.unit_miles, 1609.344f); + IMPERIAL(R.string.unit_miles, 1609.344f), + ; fun calculateDistance(meters: Float): Float { return meters / factor diff --git a/app/src/main/java/org/blitzortung/android/util/Period.kt b/app/src/main/java/org/blitzortung/android/util/Period.kt index 9cfab6837..a8e9da979 100644 --- a/app/src/main/java/org/blitzortung/android/util/Period.kt +++ b/app/src/main/java/org/blitzortung/android/util/Period.kt @@ -19,7 +19,6 @@ package org.blitzortung.android.util class Period { - internal var lastUpdateTime: Long = 0L internal var updateCount: Int = 0 @@ -29,7 +28,10 @@ class Period { restart() } - fun shouldUpdate(currentTime: Long, currentPeriod: Int): Boolean { + fun shouldUpdate( + currentTime: Long, + currentPeriod: Int, + ): Boolean { return if (currentTime >= lastUpdateTime + currentPeriod) { updateCount++ lastUpdateTime = currentTime @@ -43,7 +45,10 @@ class Period { return (updateCount % countPeriod) == 0 } - fun getCurrentUpdatePeriod(currentTime: Long, currentPeriod: Int): Long { + fun getCurrentUpdatePeriod( + currentTime: Long, + currentPeriod: Int, + ): Long { return currentPeriod - (currentTime - lastUpdateTime) } diff --git a/app/src/main/java/org/blitzortung/android/util/TabletAwareView.kt b/app/src/main/java/org/blitzortung/android/util/TabletAwareView.kt index bbf71a564..3b4e52582 100644 --- a/app/src/main/java/org/blitzortung/android/util/TabletAwareView.kt +++ b/app/src/main/java/org/blitzortung/android/util/TabletAwareView.kt @@ -22,17 +22,16 @@ import android.content.Context import android.os.Build import android.util.AttributeSet import android.view.View +import androidx.core.content.withStyledAttributes import org.blitzortung.android.app.R import org.blitzortung.android.app.helper.ViewHelper.pxFromDp import org.blitzortung.android.app.helper.ViewHelper.pxFromSp -import androidx.core.content.withStyledAttributes open class TabletAwareView( context: Context, attrs: AttributeSet?, - defStyle: Int + defStyle: Int, ) : View(context, attrs, defStyle) { - protected var padding: Float = 0.0f protected var textSize: Float = 0.0f protected var sizeFactor: Float = 0.0f @@ -45,7 +44,6 @@ open class TabletAwareView( padding = pxFromDp(context, padding(scaleForTablet)) textSize = pxFromSp(context, textSize(scaleForTablet)) sizeFactor = sizeFactor(scaleForTablet) - } } @@ -96,6 +94,4 @@ open class TabletAwareView( return if (scaleForTablet) 1.4f else 1f } } - } - diff --git a/app/src/main/java/org/blitzortung/android/util/TimeFormat.kt b/app/src/main/java/org/blitzortung/android/util/TimeFormat.kt index 7092ddaa8..983fef9d7 100644 --- a/app/src/main/java/org/blitzortung/android/util/TimeFormat.kt +++ b/app/src/main/java/org/blitzortung/android/util/TimeFormat.kt @@ -24,7 +24,6 @@ import java.util.Locale import java.util.TimeZone object TimeFormat { - private val DATE_TIME_MILLISECONDS_FORMATTER = SimpleDateFormat("yyyyMMdd'T'HH:mm:ss.SSS", Locale.US) private val JSON_DATE_TIME_FORMATTER = SimpleDateFormat("yyyyMMdd'T'HH:mm:ss", Locale.US) @@ -55,6 +54,5 @@ object TimeFormat { } catch (e: ParseException) { throw IllegalArgumentException("Unable to parse time string '%s'".format(timestampString), e) } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/blitzortung/android/widget/TimeSlider.kt b/app/src/main/java/org/blitzortung/android/widget/TimeSlider.kt index 071afb46d..56e2c4e81 100644 --- a/app/src/main/java/org/blitzortung/android/widget/TimeSlider.kt +++ b/app/src/main/java/org/blitzortung/android/widget/TimeSlider.kt @@ -13,7 +13,6 @@ import org.blitzortung.android.data.History import org.blitzortung.android.data.Parameters class TimeSlider : AppCompatSeekBar { - private val timeAxisPaint = Paint() private val timeAxisLinePaint = Paint() private var pastTimePaint = Paint() @@ -49,18 +48,27 @@ class TimeSlider : AppCompatSeekBar { private var ticksPerHour: Int? = null - fun update(parameters: Parameters, history: History) { + fun update( + parameters: Parameters, + history: History, + ) { updatePositionAndRange(parameters, history) updateSecondaryPosition(parameters, history) invalidate() } - private fun updateSecondaryPosition(parameters: Parameters, history: History) { + private fun updateSecondaryPosition( + parameters: Parameters, + history: History, + ) { secondaryProgress = parameters.intervalPosition(history) } - private fun updatePositionAndRange(parameters: Parameters, history: History) { + private fun updatePositionAndRange( + parameters: Parameters, + history: History, + ) { ticksPerHour = 60 / history.timeIncrement val previousMax = max max = parameters.intervalMaxPosition(history) @@ -88,19 +96,19 @@ class TimeSlider : AppCompatSeekBar { context.resources.getString(R.string.slider_current), width - paddingRight.toFloat(), height - bottomOffset, - currentTextPaint + currentTextPaint, ) c.drawText( context.resources.getString(R.string.slider_legend), width / 2f, height - bottomOffset, - legendTextPaint + legendTextPaint, ) c.drawText( context.resources.getString(R.string.slider_past), paddingLeft.toFloat(), height - bottomOffset, - pastTextPaint + pastTextPaint, ) } @@ -118,7 +126,7 @@ class TimeSlider : AppCompatSeekBar { lineTo(secondPosition, base - distance) close() }, - timeAxisPaint + timeAxisPaint, ) } @@ -131,7 +139,7 @@ class TimeSlider : AppCompatSeekBar { base - axisWidth / 2, width - paddingRight.toFloat(), base + axisWidth / 2, - timeAxisPaint + timeAxisPaint, ) val position = calculateX(progress) c.drawRect(position, base - axisWidth / 2, width - paddingRight.toFloat(), base + axisWidth / 2, pastTimePaint) @@ -150,13 +158,14 @@ class TimeSlider : AppCompatSeekBar { val position = calculateX(i) val ticksPerHour = this.ticksPerHour - val tickLength = if (ticksPerHour != null && (max - i) % ticksPerHour == 0) { - val hour = (max - i) / ticksPerHour - c.drawText("$hour", position, textPosition, timeAxisPaint) - longTick - } else { - shortTick - } + val tickLength = + if (ticksPerHour != null && (max - i) % ticksPerHour == 0) { + val hour = (max - i) / ticksPerHour + c.drawText("$hour", position, textPosition, timeAxisPaint) + longTick + } else { + shortTick + } c.drawRect(position - unit, base - tickLength, position + unit, base + tickLength, timeAxisPaint) } @@ -165,5 +174,4 @@ class TimeSlider : AppCompatSeekBar { private fun calculateX(progress: Int): Float { return paddingLeft.toFloat() + (width - (paddingLeft.toFloat() + paddingRight.toFloat())) / max * progress } - -} \ No newline at end of file +} diff --git a/app/src/test/java/org/blitzortung/android/alert/AlertResultTest.kt b/app/src/test/java/org/blitzortung/android/alert/AlertResultTest.kt index b3deb15ed..3025c3e20 100644 --- a/app/src/test/java/org/blitzortung/android/alert/AlertResultTest.kt +++ b/app/src/test/java/org/blitzortung/android/alert/AlertResultTest.kt @@ -10,10 +10,8 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment - @RunWith(RobolectricTestRunner::class) class AlertResultTest { - private lateinit var alertParameters: AlertParameters @Before @@ -36,14 +34,15 @@ class AlertResultTest { @Test fun singleSectorResults() { - val uut = AlertResult( - listOf(AlertSector("foo", 1.0f, 2.0f, emptyList(), 10.0f)), - alertParameters, - System.currentTimeMillis() - ) + val uut = + AlertResult( + listOf(AlertSector("foo", 1.0f, 2.0f, emptyList(), 10.0f)), + alertParameters, + System.currentTimeMillis(), + ) assertThat(uut.closestStrikeDistance).isEqualTo(10.0f) assertThat(uut.bearingName).isEqualTo("foo") assertThat(uut.sectorWithClosestStrike).isNotNull } -} \ No newline at end of file +} diff --git a/app/src/test/java/org/blitzortung/android/alert/handler/AlertDataHandlerTest.kt b/app/src/test/java/org/blitzortung/android/alert/handler/AlertDataHandlerTest.kt index eba922843..27523bf85 100644 --- a/app/src/test/java/org/blitzortung/android/alert/handler/AlertDataHandlerTest.kt +++ b/app/src/test/java/org/blitzortung/android/alert/handler/AlertDataHandlerTest.kt @@ -18,7 +18,6 @@ import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) @Config(manifest = Config.NONE) class AlertDataHandlerTest { - private lateinit var strike: DefaultStrike @MockK @@ -42,17 +41,19 @@ class AlertDataHandlerTest { strike = DefaultStrike(timestamp = now, longitude = 12.0, latitude = 41.0, altitude = 0, amplitude = 20.0f) - strikeLocation = Location("").apply { - longitude = strike.longitude - latitude = strike.latitude - } + strikeLocation = + Location("").apply { + longitude = strike.longitude + latitude = strike.latitude + } - parameters = AlertParameters( - alarmInterval = 10 * 60 * 1000, - rangeSteps = listOf(2.5f, 5f), - sectorLabels = listOf("S", "N"), - measurementSystem = MeasurementSystem.METRIC - ) + parameters = + AlertParameters( + alarmInterval = 10 * 60 * 1000, + rangeSteps = listOf(2.5f, 5f), + sectorLabels = listOf("S", "N"), + measurementSystem = MeasurementSystem.METRIC, + ) now = System.currentTimeMillis() thresholdTime = now - parameters.alarmInterval @@ -144,7 +145,7 @@ class AlertDataHandlerTest { result: AlertResult, expectedSectorLabel: String, expectedRange: Float, - expectedTime: Long + expectedTime: Long, ) { val sectorWithStrike = sectorWithStrike(result) assertThat(sectorWithStrike).isNotNull diff --git a/app/src/test/java/org/blitzortung/android/alert/handler/AlertHandlerTest.kt b/app/src/test/java/org/blitzortung/android/alert/handler/AlertHandlerTest.kt index 6d2802a4d..da99fbf25 100644 --- a/app/src/test/java/org/blitzortung/android/alert/handler/AlertHandlerTest.kt +++ b/app/src/test/java/org/blitzortung/android/alert/handler/AlertHandlerTest.kt @@ -68,12 +68,13 @@ class AlertHandlerTest { val gridSize = 5000 val parameters = Parameters(interval = TimeInterval(offset = 0, duration = 60), region = LOCAL_REGION, gridSize = gridSize) - val resultEvent = ResultEvent( - strikes = listOf(GridElement(System.currentTimeMillis(), 11.0, 45.0, 5)), - gridParameters = GridParameters(10.0, 40.0, 10.0, 10.0, 20, 20, gridSize), - flags = Flags(), - parameters = parameters - ) + val resultEvent = + ResultEvent( + strikes = listOf(GridElement(System.currentTimeMillis(), 11.0, 45.0, 5)), + gridParameters = GridParameters(10.0, 40.0, 10.0, 10.0, 20, 20, gridSize), + flags = Flags(), + parameters = parameters, + ) mockAlertResult(System.currentTimeMillis(), 5.0f) uut.dataEventConsumer.invoke(resultEvent) @@ -105,17 +106,19 @@ class AlertHandlerTest { every { alertDataHandler.getLatestTimstampWithin(any(), any()) } returns currentTime - val parameters = Parameters( - interval = TimeInterval(offset = 0, duration = 60), - region = LOCAL_REGION, gridSize = gridSize - ) + val parameters = + Parameters( + interval = TimeInterval(offset = 0, duration = 60), + region = LOCAL_REGION, + gridSize = gridSize, + ) uut.dataEventConsumer.invoke( ResultEvent( strikes = strikeList, gridParameters = gridParameters, flags = Flags(), - parameters = parameters - ) + parameters = parameters, + ), ) assertThat(uut.alertEvent).isInstanceOf(AlertResultEvent::class.java) @@ -143,17 +146,19 @@ class AlertHandlerTest { every { alertDataHandler.getLatestTimstampWithin(any(), any()) } returns currentTime - val parameters = Parameters( - interval = TimeInterval(offset = 0, duration = 60), - region = LOCAL_REGION, gridSize = gridSize - ) + val parameters = + Parameters( + interval = TimeInterval(offset = 0, duration = 60), + region = LOCAL_REGION, + gridSize = gridSize, + ) uut.dataEventConsumer.invoke( ResultEvent( strikes = strikeList, gridParameters = gridParameters, flags = Flags(), - parameters = parameters - ) + parameters = parameters, + ), ) assertThat(uut.alertEvent).isInstanceOf(AlertResultEvent::class.java) @@ -178,17 +183,19 @@ class AlertHandlerTest { mockAlertResult(currentTime, 0.0f) - val parameters = Parameters( - interval = TimeInterval(offset = 0, duration = 60), - region = LOCAL_REGION, gridSize = gridSize - ) + val parameters = + Parameters( + interval = TimeInterval(offset = 0, duration = 60), + region = LOCAL_REGION, + gridSize = gridSize, + ) uut.dataEventConsumer.invoke( ResultEvent( strikes = strikeList, gridParameters = gridParameters, flags = Flags(), - parameters = parameters - ) + parameters = parameters, + ), ) assertThat(uut.alertEvent).isInstanceOf(AlertResultEvent::class.java) @@ -211,10 +218,12 @@ class AlertHandlerTest { val strikeList = listOf(GridElement(currentTime, 11.0, 45.0, 5)) val gridParameters = GridParameters(10.0, 40.0, 10.0, 10.0, 20, 20, gridSize) - val parameters = Parameters( - interval = TimeInterval(offset = -30, duration = 60), - region = LOCAL_REGION, gridSize = gridSize - ) + val parameters = + Parameters( + interval = TimeInterval(offset = -30, duration = 60), + region = LOCAL_REGION, + gridSize = gridSize, + ) var alertEvent: AlertEvent? = null uut.requestUpdates { event -> alertEvent = event } @@ -224,8 +233,8 @@ class AlertHandlerTest { strikes = strikeList, gridParameters = gridParameters, flags = Flags(), - parameters = parameters - ) + parameters = parameters, + ), ) assertThat(uut.alertEvent).isInstanceOf(AlertCancelEvent::class.java) @@ -248,10 +257,12 @@ class AlertHandlerTest { val strikeList = listOf(GridElement(currentTime, 11.0, 45.0, 5)) val gridParameters = GridParameters(10.0, 40.0, 10.0, 10.0, 20, 20, gridSize) - val parameters = Parameters( - interval = TimeInterval(offset = -30, duration = 60), - region = LOCAL_REGION, gridSize = gridSize - ) + val parameters = + Parameters( + interval = TimeInterval(offset = -30, duration = 60), + region = LOCAL_REGION, + gridSize = gridSize, + ) var alertEvent: AlertEvent? = null uut.requestUpdates { event -> alertEvent = event } @@ -261,20 +272,23 @@ class AlertHandlerTest { strikes = strikeList, gridParameters = gridParameters, flags = Flags(ignoreForAlerting = true), - parameters = parameters - ) + parameters = parameters, + ), ) assertThat(uut.alertEvent).isInstanceOf(AlertCancelEvent::class.java) assertThat(alertEvent).isNull() } - private fun mockAlertResult(currentTime: Long, closestDistance: Float) { - every { alertDataHandler.checkStrikes(any(), any(), any(), any()) } returns AlertResult( - sectors = listOf(AlertSector("foo", 0.0f, 1.0f, emptyList(), closestDistance)), - uut.alertParameters, - currentTime - ) + private fun mockAlertResult( + currentTime: Long, + closestDistance: Float, + ) { + every { alertDataHandler.checkStrikes(any(), any(), any(), any()) } returns + AlertResult( + sectors = listOf(AlertSector("foo", 0.0f, 1.0f, emptyList(), closestDistance)), + uut.alertParameters, + currentTime, + ) } - } diff --git a/app/src/test/java/org/blitzortung/android/app/components/VersionComponentTest.kt b/app/src/test/java/org/blitzortung/android/app/components/VersionComponentTest.kt index 6a25a1347..6ad109a68 100644 --- a/app/src/test/java/org/blitzortung/android/app/components/VersionComponentTest.kt +++ b/app/src/test/java/org/blitzortung/android/app/components/VersionComponentTest.kt @@ -16,7 +16,6 @@ import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) @Config(manifest = Config.NONE) class VersionComponentTest { - private lateinit var versionComponent: VersionComponent @MockK @@ -107,4 +106,3 @@ class BuildVersionTest { assertThat(buildVersion.majorVersion).isGreaterThanOrEqualTo(-1) } } - diff --git a/app/src/test/java/org/blitzortung/android/app/permission/PermissionsManagerTest.kt b/app/src/test/java/org/blitzortung/android/app/permission/PermissionsManagerTest.kt index 898c6b8f0..4896f77d9 100644 --- a/app/src/test/java/org/blitzortung/android/app/permission/PermissionsManagerTest.kt +++ b/app/src/test/java/org/blitzortung/android/app/permission/PermissionsManagerTest.kt @@ -21,7 +21,6 @@ import org.robolectric.shadows.ShadowPackageManager @RunWith(RobolectricTestRunner::class) @Config(sdk = [Build.VERSION_CODES.P]) // Target a specific SDK relevant for permissions class PermissionsManagerTest { - private class DummyActivity : Activity() private lateinit var activity: Activity @@ -50,11 +49,12 @@ class PermissionsManagerTest { shadowActivity.grantPermissions(PERMISSION_STRING) // Act - val result = permissionsSupport.request( - PERMISSION_STRING, - REQUEST_CODE, - PERMISSION_RATIONALE_STRING_ID - ) + val result = + permissionsSupport.request( + PERMISSION_STRING, + REQUEST_CODE, + PERMISSION_RATIONALE_STRING_ID, + ) assertThat(result).isFalse() assertThat(shadowActivity.lastRequestedPermission).isNull() @@ -65,11 +65,12 @@ class PermissionsManagerTest { shadowActivity.denyPermissions(PERMISSION_STRING) shadowPackageManager.setShouldShowRequestPermissionRationale(PERMISSION_STRING, false) - val result = permissionsSupport.request( - PERMISSION_STRING, - REQUEST_CODE, - PERMISSION_RATIONALE_STRING_ID - ) + val result = + permissionsSupport.request( + PERMISSION_STRING, + REQUEST_CODE, + PERMISSION_RATIONALE_STRING_ID, + ) assertThat(result).isTrue() val permissionRequest = shadowActivity.lastRequestedPermission @@ -84,11 +85,12 @@ class PermissionsManagerTest { shadowPackageManager.setShouldShowRequestPermissionRationale(PERMISSION_STRING, true) // Act - val result = permissionsSupport.request( - PERMISSION_STRING, - REQUEST_CODE, - R.string.copy - ) + val result = + permissionsSupport.request( + PERMISSION_STRING, + REQUEST_CODE, + R.string.copy, + ) // Assert assertThat(result).isTrue() // Indicates a request flow was initiated @@ -112,9 +114,10 @@ class PermissionsManagerTest { // Mock PermissionRequester for testing ensurePermissions class MockPermissionRequester( override val name: String, - private val requestResult: Boolean + private val requestResult: Boolean, ) : PermissionRequester { var requestCalled = false + override fun request(permissionsSupport: PermissionsSupport): Boolean { requestCalled = true return requestResult @@ -171,14 +174,14 @@ class PermissionsManagerTest { @Test fun `default method should return false`() { - class TestPermissionRequester : PermissionRequester { - override val name: String - get() = "test" - - override fun request(permissionsSupport: PermissionsSupport): Boolean { - TODO("Not yet implemented") - } - } + class TestPermissionRequester : PermissionRequester { + override val name: String + get() = "test" + + override fun request(permissionsSupport: PermissionsSupport): Boolean { + TODO("Not yet implemented") + } + } val requester = TestPermissionRequester() diff --git a/app/src/test/java/org/blitzortung/android/app/permission/requester/BackgroundLocationPermissionRequesterTest.kt b/app/src/test/java/org/blitzortung/android/app/permission/requester/BackgroundLocationPermissionRequesterTest.kt index 2b7e5edcb..b83fb7e6e 100644 --- a/app/src/test/java/org/blitzortung/android/app/permission/requester/BackgroundLocationPermissionRequesterTest.kt +++ b/app/src/test/java/org/blitzortung/android/app/permission/requester/BackgroundLocationPermissionRequesterTest.kt @@ -1,6 +1,5 @@ package org.blitzortung.android.app.permission.requester - import android.Manifest.permission.ACCESS_BACKGROUND_LOCATION import android.app.Activity import android.content.Context @@ -33,7 +32,6 @@ import org.robolectric.shadows.ShadowLooper @RunWith(RobolectricTestRunner::class) class BackgroundLocationPermissionRequesterTest { - @MockK private lateinit var permissionsSupport: PermissionsSupport @@ -46,9 +44,10 @@ class BackgroundLocationPermissionRequesterTest { @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) - activity = Robolectric.buildActivity(Main::class.java) - .setup() - .get() + activity = + Robolectric.buildActivity(Main::class.java) + .setup() + .get() val context = RuntimeEnvironment.getApplication() @@ -109,7 +108,7 @@ class BackgroundLocationPermissionRequesterTest { permissionsSupport.request( ACCESS_BACKGROUND_LOCATION, 102, - R.string.location_permission_background_required + R.string.location_permission_background_required, ) } assertThat(preferences.getString(PreferenceKey.BACKGROUND_QUERY_PERIOD, "")).isEqualTo("300") @@ -135,6 +134,4 @@ class BackgroundLocationPermissionRequesterTest { } assertThat(preferences.getString(PreferenceKey.BACKGROUND_QUERY_PERIOD, "")).isEqualTo("0") } - - } diff --git a/app/src/test/java/org/blitzortung/android/app/permission/requester/LocationPermissionRequesterTest.kt b/app/src/test/java/org/blitzortung/android/app/permission/requester/LocationPermissionRequesterTest.kt index a87ba68bf..5fc059925 100644 --- a/app/src/test/java/org/blitzortung/android/app/permission/requester/LocationPermissionRequesterTest.kt +++ b/app/src/test/java/org/blitzortung/android/app/permission/requester/LocationPermissionRequesterTest.kt @@ -1,6 +1,5 @@ package org.blitzortung.android.app.permission.requester - import android.Manifest.permission.ACCESS_COARSE_LOCATION import android.Manifest.permission.ACCESS_FINE_LOCATION import android.app.Activity @@ -33,7 +32,6 @@ import org.robolectric.RuntimeEnvironment @RunWith(RobolectricTestRunner::class) class LocationPermissionRequesterTest { - @MockK private lateinit var permissionsSupport: PermissionsSupport @@ -47,9 +45,10 @@ class LocationPermissionRequesterTest { fun setUp() { MockKAnnotations.init(this, relaxed = true) - activity = Robolectric.buildActivity(Main::class.java) - .setup() - .get() + activity = + Robolectric.buildActivity(Main::class.java) + .setup() + .get() val context = RuntimeEnvironment.getApplication() preferences = context.getSharedPreferences(context.packageName, Context.MODE_PRIVATE) @@ -71,7 +70,9 @@ class LocationPermissionRequesterTest { verify(exactly = 1) { permissionsSupport.request( - ACCESS_FINE_LOCATION, 1, R.string.location_permission_required + ACCESS_FINE_LOCATION, + 1, + R.string.location_permission_required, ) } } @@ -101,18 +102,21 @@ class LocationPermissionRequesterTest { verify(exactly = 1) { permissionsSupport.request( - ACCESS_COARSE_LOCATION, 2, R.string.location_permission_required + ACCESS_COARSE_LOCATION, + 2, + R.string.location_permission_required, ) } } @Test fun `onRequestPermissionsResult returns false if request code does not match`() { - val result = locationPermissionRequester.onRequestPermissionsResult( - requestCode = 123, - permissions = arrayOf(ACCESS_FINE_LOCATION), - grantResults = intArrayOf(PackageManager.PERMISSION_GRANTED) - ) + val result = + locationPermissionRequester.onRequestPermissionsResult( + requestCode = 123, + permissions = arrayOf(ACCESS_FINE_LOCATION), + grantResults = intArrayOf(PackageManager.PERMISSION_GRANTED), + ) assertThat(result).isFalse() } @@ -121,11 +125,12 @@ class LocationPermissionRequesterTest { val provider = PASSIVE_PROVIDER val providerRelation = LocationProviderRelation.byProviderName[provider]!! - val result = locationPermissionRequester.onRequestPermissionsResult( - requestCode = providerRelation.ordinal, - permissions = arrayOf(ACCESS_FINE_LOCATION), - grantResults = intArrayOf(PackageManager.PERMISSION_GRANTED) - ) + val result = + locationPermissionRequester.onRequestPermissionsResult( + requestCode = providerRelation.ordinal, + permissions = arrayOf(ACCESS_FINE_LOCATION), + grantResults = intArrayOf(PackageManager.PERMISSION_GRANTED), + ) assertThat(result).isTrue() assertThat(preferences.get(PreferenceKey.LOCATION_MODE, "")).isEqualTo(provider) @@ -136,11 +141,12 @@ class LocationPermissionRequesterTest { val provider = GPS_PROVIDER val providerRelation = LocationProviderRelation.byProviderName[provider]!! - val result = locationPermissionRequester.onRequestPermissionsResult( - requestCode = providerRelation.ordinal, - permissions = arrayOf(ACCESS_FINE_LOCATION), - grantResults = intArrayOf(PackageManager.PERMISSION_GRANTED) - ) + val result = + locationPermissionRequester.onRequestPermissionsResult( + requestCode = providerRelation.ordinal, + permissions = arrayOf(ACCESS_FINE_LOCATION), + grantResults = intArrayOf(PackageManager.PERMISSION_GRANTED), + ) assertThat(result).isTrue() assertThat(preferences.get(PreferenceKey.LOCATION_MODE, "")).isEqualTo(provider) @@ -151,11 +157,12 @@ class LocationPermissionRequesterTest { val provider = NETWORK_PROVIDER val providerRelation = LocationProviderRelation.byProviderName[provider]!! - val result = locationPermissionRequester.onRequestPermissionsResult( - requestCode = providerRelation.ordinal, - permissions = arrayOf(ACCESS_COARSE_LOCATION), - grantResults = intArrayOf(PackageManager.PERMISSION_GRANTED) - ) + val result = + locationPermissionRequester.onRequestPermissionsResult( + requestCode = providerRelation.ordinal, + permissions = arrayOf(ACCESS_COARSE_LOCATION), + grantResults = intArrayOf(PackageManager.PERMISSION_GRANTED), + ) assertThat(result).isTrue() assertThat(preferences.get(PreferenceKey.LOCATION_MODE, "")).isEqualTo(provider) @@ -169,11 +176,12 @@ class LocationPermissionRequesterTest { put(PreferenceKey.LOCATION_MODE, provider) } - val result = locationPermissionRequester.onRequestPermissionsResult( - requestCode = providerRelation.ordinal, - permissions = arrayOf(ACCESS_COARSE_LOCATION), - grantResults = intArrayOf(PackageManager.PERMISSION_DENIED) - ) + val result = + locationPermissionRequester.onRequestPermissionsResult( + requestCode = providerRelation.ordinal, + permissions = arrayOf(ACCESS_COARSE_LOCATION), + grantResults = intArrayOf(PackageManager.PERMISSION_DENIED), + ) assertThat(result).isTrue() assertThat(preferences.get(PreferenceKey.LOCATION_MODE, "")).isEqualTo(MANUAL_PROVIDER) @@ -189,11 +197,12 @@ class LocationPermissionRequesterTest { put(PreferenceKey.BACKGROUND_QUERY_PERIOD, "300") } - val result = locationPermissionRequester.onRequestPermissionsResult( - requestCode = providerRelation.ordinal, - permissions = arrayOf(ACCESS_COARSE_LOCATION), - grantResults = intArrayOf(PackageManager.PERMISSION_DENIED) - ) + val result = + locationPermissionRequester.onRequestPermissionsResult( + requestCode = providerRelation.ordinal, + permissions = arrayOf(ACCESS_COARSE_LOCATION), + grantResults = intArrayOf(PackageManager.PERMISSION_DENIED), + ) assertThat(result).isTrue() assertThat(preferences.get(PreferenceKey.LOCATION_MODE, "")).isEqualTo(MANUAL_PROVIDER) diff --git a/app/src/test/java/org/blitzortung/android/app/permission/requester/NotificationPermissionRequesterTest.kt b/app/src/test/java/org/blitzortung/android/app/permission/requester/NotificationPermissionRequesterTest.kt index 6b8f1b208..1df2366b0 100644 --- a/app/src/test/java/org/blitzortung/android/app/permission/requester/NotificationPermissionRequesterTest.kt +++ b/app/src/test/java/org/blitzortung/android/app/permission/requester/NotificationPermissionRequesterTest.kt @@ -1,6 +1,5 @@ package org.blitzortung.android.app.permission.requester - import android.Manifest.permission.POST_NOTIFICATIONS import android.app.Activity import android.content.Context @@ -29,7 +28,6 @@ import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) class NotificationPermissionRequesterTest { - @MockK private lateinit var permissionsSupport: PermissionsSupport @@ -43,9 +41,10 @@ class NotificationPermissionRequesterTest { fun setUp() { MockKAnnotations.init(this, relaxed = true) - activity = Robolectric.buildActivity(Main::class.java) - .setup() - .get() + activity = + Robolectric.buildActivity(Main::class.java) + .setup() + .get() val context = RuntimeEnvironment.getApplication() preferences = context.getSharedPreferences(context.packageName, Context.MODE_PRIVATE) @@ -93,7 +92,7 @@ class NotificationPermissionRequesterTest { permissionsSupport.request( POST_NOTIFICATIONS, 101, - R.string.post_notifications_request + R.string.post_notifications_request, ) } } @@ -113,28 +112,30 @@ class NotificationPermissionRequesterTest { permissionsSupport.request( POST_NOTIFICATIONS, 101, - R.string.post_notifications_request + R.string.post_notifications_request, ) } } @Test fun `onRequestPermissionsResult returns false if request code does not match`() { - val result = notificationPermissionRequester.onRequestPermissionsResult( - requestCode = 123, - permissions = arrayOf(POST_NOTIFICATIONS), - grantResults = intArrayOf(PackageManager.PERMISSION_GRANTED) - ) + val result = + notificationPermissionRequester.onRequestPermissionsResult( + requestCode = 123, + permissions = arrayOf(POST_NOTIFICATIONS), + grantResults = intArrayOf(PackageManager.PERMISSION_GRANTED), + ) assertThat(result).isFalse() } @Test fun `onRequestPermissionsResult returns false if grant results is empty`() { - val result = notificationPermissionRequester.onRequestPermissionsResult( - requestCode = 101, - permissions = arrayOf(POST_NOTIFICATIONS), - grantResults = intArrayOf() - ) + val result = + notificationPermissionRequester.onRequestPermissionsResult( + requestCode = 101, + permissions = arrayOf(POST_NOTIFICATIONS), + grantResults = intArrayOf(), + ) assertThat(result).isFalse() } @@ -145,11 +146,12 @@ class NotificationPermissionRequesterTest { put(PreferenceKey.BACKGROUND_QUERY_PERIOD, "300") } - val result = notificationPermissionRequester.onRequestPermissionsResult( - requestCode = 101, - permissions = arrayOf(POST_NOTIFICATIONS), - grantResults = intArrayOf(PackageManager.PERMISSION_GRANTED) - ) + val result = + notificationPermissionRequester.onRequestPermissionsResult( + requestCode = 101, + permissions = arrayOf(POST_NOTIFICATIONS), + grantResults = intArrayOf(PackageManager.PERMISSION_GRANTED), + ) assertThat(result).isTrue() @@ -163,11 +165,12 @@ class NotificationPermissionRequesterTest { put(PreferenceKey.ALERT_ENABLED, true) } - val result = notificationPermissionRequester.onRequestPermissionsResult( - requestCode = 101, - permissions = arrayOf(POST_NOTIFICATIONS), - grantResults = intArrayOf(PackageManager.PERMISSION_DENIED) - ) + val result = + notificationPermissionRequester.onRequestPermissionsResult( + requestCode = 101, + permissions = arrayOf(POST_NOTIFICATIONS), + grantResults = intArrayOf(PackageManager.PERMISSION_DENIED), + ) assertThat(result).isTrue() @@ -180,11 +183,12 @@ class NotificationPermissionRequesterTest { put(PreferenceKey.BACKGROUND_QUERY_PERIOD, "300") } - val result = notificationPermissionRequester.onRequestPermissionsResult( - requestCode = 101, - permissions = arrayOf(POST_NOTIFICATIONS), - grantResults = intArrayOf(PackageManager.PERMISSION_DENIED) - ) + val result = + notificationPermissionRequester.onRequestPermissionsResult( + requestCode = 101, + permissions = arrayOf(POST_NOTIFICATIONS), + grantResults = intArrayOf(PackageManager.PERMISSION_DENIED), + ) assertThat(result).isTrue() diff --git a/app/src/test/java/org/blitzortung/android/app/permission/requester/WakeupPermissionRequesterTest.kt b/app/src/test/java/org/blitzortung/android/app/permission/requester/WakeupPermissionRequesterTest.kt index bd6ce4fb2..ae9781135 100644 --- a/app/src/test/java/org/blitzortung/android/app/permission/requester/WakeupPermissionRequesterTest.kt +++ b/app/src/test/java/org/blitzortung/android/app/permission/requester/WakeupPermissionRequesterTest.kt @@ -1,6 +1,5 @@ package org.blitzortung.android.app.permission.requester - import android.app.Activity import android.content.Context import android.content.SharedPreferences @@ -31,7 +30,6 @@ import org.robolectric.shadows.ShadowLooper @RunWith(RobolectricTestRunner::class) class WakeupPermissionRequesterTest { - @MockK private lateinit var permissionsSupport: PermissionsSupport @@ -45,9 +43,10 @@ class WakeupPermissionRequesterTest { fun setUp() { MockKAnnotations.init(this, relaxed = true) - activity = Robolectric.buildActivity(Main::class.java) - .setup() - .get() + activity = + Robolectric.buildActivity(Main::class.java) + .setup() + .get() val context = RuntimeEnvironment.getApplication() @@ -97,7 +96,6 @@ class WakeupPermissionRequesterTest { @Test @Config(sdk = [Build.VERSION_CODES.Q]) fun `request should open the dialog if all conditions are valid and request permission on clicking Cancel`() { - val result = wakeupPermissionRequester.request(permissionsSupport) assertThat(result).isTrue() @@ -111,6 +109,4 @@ class WakeupPermissionRequesterTest { assertThat(preferences.getString(PreferenceKey.BACKGROUND_QUERY_PERIOD, "")).isEqualTo("0") verify(exactly = 0) { permissionsSupport.request(any(), any(), any()) } } - - } diff --git a/app/src/test/java/org/blitzortung/android/app/viewmodel/MainViewModelTest.kt b/app/src/test/java/org/blitzortung/android/app/viewmodel/MainViewModelTest.kt new file mode 100644 index 000000000..d165bdfaa --- /dev/null +++ b/app/src/test/java/org/blitzortung/android/app/viewmodel/MainViewModelTest.kt @@ -0,0 +1,217 @@ +/* + + Copyright 2015 Andreas Würl + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ + +package org.blitzortung.android.app.viewmodel + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import app.cash.turbine.test +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.assertj.core.api.Assertions.assertThat +import org.blitzortung.android.alert.AlertRepository +import org.blitzortung.android.data.Flags +import org.blitzortung.android.data.History +import org.blitzortung.android.data.Mode +import org.blitzortung.android.data.Parameters +import org.blitzortung.android.data.provider.result.RequestStartedEvent +import org.blitzortung.android.data.provider.result.ResultEvent +import org.blitzortung.android.data.repository.StrikeDataRepository +import org.blitzortung.android.location.LocationRepository +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class MainViewModelTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val testDispatcher = UnconfinedTestDispatcher() + + private lateinit var strikeDataRepository: StrikeDataRepository + private lateinit var locationRepository: LocationRepository + private lateinit var alertRepository: AlertRepository + private lateinit var viewModel: MainViewModel + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + + strikeDataRepository = mockk(relaxed = true) + locationRepository = mockk(relaxed = true) + alertRepository = mockk(relaxed = true) + + // Set up default flows + every { strikeDataRepository.observeDataEvents() } returns flowOf() + every { locationRepository.observeLocationEvents() } returns flowOf() + every { alertRepository.observeAlertEvents() } returns flowOf() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `initial state is not loading and has no error`() { + viewModel = createViewModel() + + assertThat(viewModel.isLoading.value).isFalse() + assertThat(viewModel.hasError.value).isFalse() + assertThat(viewModel.currentResult.value).isNull() + } + + @Test + fun `loading state updates on request started event`() = + runTest { + val event = RequestStartedEvent() + every { strikeDataRepository.observeDataEvents() } returns flowOf(event) + + viewModel = createViewModel() + + viewModel.isLoading.test { + assertThat(awaitItem()).isTrue() + } + } + + @Test + fun `result updates current result and clears loading`() = + runTest { + val resultEvent = + ResultEvent( + parameters = Parameters(), + flags = Flags(mode = Mode.DATA), + failed = false, + ) + every { strikeDataRepository.observeDataEvents() } returns flowOf(resultEvent) + + viewModel = createViewModel() + + viewModel.currentResult.test { + val result = awaitItem() + assertThat(result).isEqualTo(resultEvent) + } + + viewModel.isLoading.test { + assertThat(awaitItem()).isFalse() + } + } + + @Test + fun `failed result sets error state`() = + runTest { + val resultEvent = + ResultEvent( + parameters = Parameters(), + flags = Flags(mode = Mode.DATA), + failed = true, + ) + every { strikeDataRepository.observeDataEvents() } returns flowOf(resultEvent) + + viewModel = createViewModel() + + viewModel.hasError.test { + assertThat(awaitItem()).isTrue() + } + } + + @Test + fun `updateData delegates to repository`() { + viewModel = createViewModel() + + viewModel.updateData() + + verify { strikeDataRepository.updateData() } + } + + @Test + fun `goRealtime delegates to repository`() { + every { strikeDataRepository.goRealtime() } returns true + + viewModel = createViewModel() + + val result = viewModel.goRealtime() + + assertThat(result).isTrue() + verify { strikeDataRepository.goRealtime() } + } + + @Test + fun `setPosition delegates to repository`() { + every { strikeDataRepository.setPosition(5) } returns true + + viewModel = createViewModel() + + val result = viewModel.setPosition(5) + + assertThat(result).isTrue() + verify { strikeDataRepository.setPosition(5) } + } + + @Test + fun `start and stop delegate to repository`() { + viewModel = createViewModel() + + viewModel.start() + verify { strikeDataRepository.start() } + + viewModel.stop() + verify { strikeDataRepository.stop() } + } + + @Test + fun `clearData request updates state`() { + viewModel = createViewModel() + + viewModel.requestClearData() + + assertThat(viewModel.clearDataRequested.value).isTrue() + + viewModel.clearDataCompleted() + + assertThat(viewModel.clearDataRequested.value).isFalse() + } + + @Test + fun `onCleared stops data updates`() { + viewModel = createViewModel() + + // Trigger onCleared via reflection since it's protected + val onClearedMethod = MainViewModel::class.java.getDeclaredMethod("onCleared") + onClearedMethod.isAccessible = true + onClearedMethod.invoke(viewModel) + + verify { strikeDataRepository.stop() } + } + + private fun createViewModel(): MainViewModel = + MainViewModel( + strikeDataRepository, + locationRepository, + alertRepository, + ) +} diff --git a/app/src/test/java/org/blitzortung/android/app/viewmodel/MapViewModelTest.kt b/app/src/test/java/org/blitzortung/android/app/viewmodel/MapViewModelTest.kt new file mode 100644 index 000000000..bd0384ede --- /dev/null +++ b/app/src/test/java/org/blitzortung/android/app/viewmodel/MapViewModelTest.kt @@ -0,0 +1,103 @@ +/* + + Copyright 2015 Andreas Würl + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ + +package org.blitzortung.android.app.viewmodel + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.osmdroid.util.GeoPoint + +class MapViewModelTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private lateinit var viewModel: MapViewModel + + @Before + fun setup() { + viewModel = MapViewModel() + } + + @Test + fun `initial zoom level is 3_0`() { + assertThat(viewModel.zoomLevel.value).isEqualTo(3.0) + } + + @Test + fun `initial center position is null`() { + assertThat(viewModel.centerPosition.value).isNull() + } + + @Test + fun `initial map type is OpenStreetMap`() { + assertThat(viewModel.mapType.value).isEqualTo("OpenStreetMap") + } + + @Test + fun `initial map ready state is false`() { + assertThat(viewModel.isMapReady.value).isFalse() + } + + @Test + fun `updateZoomLevel updates zoom level state`() { + viewModel.updateZoomLevel(5.5) + + assertThat(viewModel.zoomLevel.value).isEqualTo(5.5) + } + + @Test + fun `updateCenterPosition updates center position state`() { + val geoPoint = GeoPoint(48.0, 11.0) + + viewModel.updateCenterPosition(geoPoint) + + assertThat(viewModel.centerPosition.value).isEqualTo(geoPoint) + } + + @Test + fun `updateMapType updates map type state`() { + viewModel.updateMapType("Satellite") + + assertThat(viewModel.mapType.value).isEqualTo("Satellite") + } + + @Test + fun `setMapReady updates map ready state`() { + viewModel.setMapReady(true) + + assertThat(viewModel.isMapReady.value).isTrue() + + viewModel.setMapReady(false) + + assertThat(viewModel.isMapReady.value).isFalse() + } + + @Test + fun `saveMapState updates both zoom and center`() { + val zoom = 7.0 + val center = GeoPoint(52.0, 13.0) + + viewModel.saveMapState(zoom, center) + + assertThat(viewModel.zoomLevel.value).isEqualTo(zoom) + assertThat(viewModel.centerPosition.value).isEqualTo(center) + } +} diff --git a/app/src/test/java/org/blitzortung/android/app/viewmodel/SettingsViewModelTest.kt b/app/src/test/java/org/blitzortung/android/app/viewmodel/SettingsViewModelTest.kt new file mode 100644 index 000000000..b46ab7d0b --- /dev/null +++ b/app/src/test/java/org/blitzortung/android/app/viewmodel/SettingsViewModelTest.kt @@ -0,0 +1,177 @@ +/* + + Copyright 2015 Andreas Würl + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ + +package org.blitzortung.android.app.viewmodel + +import android.content.SharedPreferences +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.assertj.core.api.Assertions.assertThat +import org.blitzortung.android.app.view.PreferenceKey +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsViewModelTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val testDispatcher = UnconfinedTestDispatcher() + + private lateinit var preferences: SharedPreferences + private lateinit var editor: SharedPreferences.Editor + private lateinit var viewModel: SettingsViewModel + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + + preferences = mockk(relaxed = true) + editor = mockk(relaxed = true) + + every { preferences.edit() } returns editor + every { editor.putString(any(), any()) } returns editor + every { editor.putInt(any(), any()) } returns editor + every { editor.putBoolean(any(), any()) } returns editor + every { editor.apply() } returns Unit + + viewModel = SettingsViewModel(preferences) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `getStringPreference returns value from preferences`() { + every { preferences.getString(PreferenceKey.DATA_SOURCE.key, "default") } returns "RPC" + + val result = viewModel.getStringPreference(PreferenceKey.DATA_SOURCE, "default") + + assertThat(result).isEqualTo("RPC") + } + + @Test + fun `getIntPreference returns value from preferences`() { + every { preferences.getInt(PreferenceKey.QUERY_PERIOD.key, 60) } returns 120 + + val result = viewModel.getIntPreference(PreferenceKey.QUERY_PERIOD, 60) + + assertThat(result).isEqualTo(120) + } + + @Test + fun `getBooleanPreference returns value from preferences`() { + every { preferences.getBoolean(PreferenceKey.SHOW_LOCATION.key, false) } returns true + + val result = viewModel.getBooleanPreference(PreferenceKey.SHOW_LOCATION, false) + + assertThat(result).isTrue() + } + + @Test + fun `getFloatPreference returns value from preferences`() { + val testKey = PreferenceKey.SHOW_LOCATION + every { preferences.getFloat(testKey.key, 1.0f) } returns 2.5f + + val result = viewModel.getFloatPreference(testKey, 1.0f) + + assertThat(result).isEqualTo(2.5f) + } + + @Test + fun `setStringPreference updates preferences`() { + viewModel.setStringPreference(PreferenceKey.DATA_SOURCE, "HTTP") + + verify { editor.putString(PreferenceKey.DATA_SOURCE.key, "HTTP") } + verify { editor.apply() } + } + + @Test + fun `setIntPreference updates preferences`() { + viewModel.setIntPreference(PreferenceKey.QUERY_PERIOD, 90) + + verify { editor.putInt(PreferenceKey.QUERY_PERIOD.key, 90) } + verify { editor.apply() } + } + + @Test + fun `setBooleanPreference updates preferences`() { + viewModel.setBooleanPreference(PreferenceKey.SHOW_LOCATION, true) + + verify { editor.putBoolean(PreferenceKey.SHOW_LOCATION.key, true) } + verify { editor.apply() } + } + + @Test + fun `preference change listener is registered`() { + verify { preferences.registerOnSharedPreferenceChangeListener(any()) } + } + + @Test + fun `preference change listener receives callbacks`() { + val listenerSlot = slot() + every { + preferences.registerOnSharedPreferenceChangeListener(capture(listenerSlot)) + } returns Unit + + viewModel = SettingsViewModel(preferences) + + // Verify listener was registered + verify { preferences.registerOnSharedPreferenceChangeListener(any()) } + } + + @Test + fun `clearPreferenceChange resets state`() { + val listenerSlot = slot() + every { + preferences.registerOnSharedPreferenceChangeListener(capture(listenerSlot)) + } returns Unit + + viewModel = SettingsViewModel(preferences) + + val listener = listenerSlot.captured + listener.onSharedPreferenceChanged(preferences, PreferenceKey.DATA_SOURCE.key) + + viewModel.clearPreferenceChange() + + assertThat(viewModel.preferenceChanged.value).isNull() + } + + @Test + fun `onCleared unregisters preference listener`() { + // Trigger onCleared via reflection since it's protected + val onClearedMethod = SettingsViewModel::class.java.getDeclaredMethod("onCleared") + onClearedMethod.isAccessible = true + onClearedMethod.invoke(viewModel) + + verify { preferences.unregisterOnSharedPreferenceChangeListener(any()) } + } +} diff --git a/app/src/test/java/org/blitzortung/android/data/GridParametersTest.kt b/app/src/test/java/org/blitzortung/android/data/GridParametersTest.kt index 60fdd731a..7a6bf9620 100644 --- a/app/src/test/java/org/blitzortung/android/data/GridParametersTest.kt +++ b/app/src/test/java/org/blitzortung/android/data/GridParametersTest.kt @@ -4,9 +4,7 @@ import org.assertj.core.api.Assertions.assertThat import org.blitzortung.android.data.beans.GridParameters import org.junit.Test - class GridParametersTest { - private val parameters = GridParameters(10.0, 45.0, 0.2, 0.2, 20, 15) @Test @@ -58,4 +56,4 @@ class GridParametersTest { assertThat(parameters.contains(13.6, 44.5, 0.5)).isFalse assertThat(parameters.contains(13.5, 44.6, 0.5)).isFalse } -} \ No newline at end of file +} diff --git a/app/src/test/java/org/blitzortung/android/data/MainDataHandlerTest.kt b/app/src/test/java/org/blitzortung/android/data/MainDataHandlerTest.kt index 4c3d75030..a24c0c406 100644 --- a/app/src/test/java/org/blitzortung/android/data/MainDataHandlerTest.kt +++ b/app/src/test/java/org/blitzortung/android/data/MainDataHandlerTest.kt @@ -35,7 +35,6 @@ import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) @Config(manifest = Config.NONE) class MainDataHandlerTest { - private lateinit var preferences: SharedPreferences @MockK @@ -52,7 +51,6 @@ class MainDataHandlerTest { private lateinit var receivedEvents: MutableList - private lateinit var uut: MainDataHandler @Before @@ -228,4 +226,3 @@ class MainDataHandlerTest { assertThat(result).isTrue } } - diff --git a/app/src/test/java/org/blitzortung/android/data/ParametersTest.kt b/app/src/test/java/org/blitzortung/android/data/ParametersTest.kt index ab576d6d5..fce725580 100644 --- a/app/src/test/java/org/blitzortung/android/data/ParametersTest.kt +++ b/app/src/test/java/org/blitzortung/android/data/ParametersTest.kt @@ -3,9 +3,7 @@ package org.blitzortung.android.data import org.assertj.core.api.Assertions.assertThat import org.junit.Test - class ParametersTest { - private val parameters = Parameters() private val history = History() @@ -38,7 +36,6 @@ class ParametersTest { assertThat(result.isRealtime()).isTrue() } - @Test fun goToHistory() { val result = parameters.animationStep(history) diff --git a/app/src/test/java/org/blitzortung/android/data/SequenceValidatorTest.kt b/app/src/test/java/org/blitzortung/android/data/SequenceValidatorTest.kt index bb6b8963b..764b15e69 100644 --- a/app/src/test/java/org/blitzortung/android/data/SequenceValidatorTest.kt +++ b/app/src/test/java/org/blitzortung/android/data/SequenceValidatorTest.kt @@ -11,7 +11,6 @@ import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) @Config(sdk = [Build.VERSION_CODES.N, Build.VERSION_CODES.M]) class SequenceValidatorTest { - @Test fun nullValueIsIgnored() { val validator = SequenceValidator() diff --git a/app/src/test/java/org/blitzortung/android/data/TimeIntervalTest.kt b/app/src/test/java/org/blitzortung/android/data/TimeIntervalTest.kt index e11a33aff..7e25a7bf2 100644 --- a/app/src/test/java/org/blitzortung/android/data/TimeIntervalTest.kt +++ b/app/src/test/java/org/blitzortung/android/data/TimeIntervalTest.kt @@ -155,4 +155,4 @@ class TimeIntervalTest { interval = updater.invoke(interval) return interval != oldParameters } -} \ No newline at end of file +} diff --git a/app/src/test/java/org/blitzortung/android/data/cache/DataCacheTest.kt b/app/src/test/java/org/blitzortung/android/data/cache/DataCacheTest.kt index c8473e5ae..be8209761 100644 --- a/app/src/test/java/org/blitzortung/android/data/cache/DataCacheTest.kt +++ b/app/src/test/java/org/blitzortung/android/data/cache/DataCacheTest.kt @@ -11,7 +11,6 @@ import org.junit.Before import org.junit.Test class DataCacheTest { - private val parameters = Parameters(interval = TimeInterval(offset = 30)) private lateinit var uut: DataCache @@ -70,21 +69,22 @@ class DataCacheTest { @Test fun cacheSizeSimple() { - val strikes = listOf( - GridElement(0, 1.0, 2.0, 3), - GridElement(2, 3.0, 4.0, 2), - GridElement(4, -3.0, -4.0, 1), - ) + val strikes = + listOf( + GridElement(0, 1.0, 2.0, 3), + GridElement(2, 3.0, 4.0, 2), + GridElement(4, -3.0, -4.0, 1), + ) val dataEvent = ResultEvent(parameters = parameters, flags = Flags(), strikes = strikes) uut.put(parameters, dataEvent) uut.put( parameters.copy(interval = TimeInterval(offset = 60)), - ResultEvent(parameters = parameters, flags = Flags()) + ResultEvent(parameters = parameters, flags = Flags()), ) val result = uut.calculateTotalSize() assertThat(result).isEqualTo(CacheSize(2, 3)) } -} \ No newline at end of file +} diff --git a/app/src/test/java/org/blitzortung/android/data/provider/LocalDataTest.kt b/app/src/test/java/org/blitzortung/android/data/provider/LocalDataTest.kt index 3971bf25a..7269dad18 100644 --- a/app/src/test/java/org/blitzortung/android/data/provider/LocalDataTest.kt +++ b/app/src/test/java/org/blitzortung/android/data/provider/LocalDataTest.kt @@ -15,7 +15,6 @@ import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) @Config(manifest = Config.NONE) class LocalDataTest { - private lateinit var uut: LocalData private lateinit var parameters: Parameters @@ -107,7 +106,7 @@ class LocalDataTest { val result = uut.updateParameters(parameters, null) assertThat(result.region).isEqualTo(LOCAL_REGION) - assertThat(result.dataArea).isEqualTo(DataArea(2,9,5)) + assertThat(result.dataArea).isEqualTo(DataArea(2, 9, 5)) assertThat(result.gridSize).isEqualTo(10000) } @@ -157,7 +156,7 @@ class LocalDataTest { val result = uut.update(boundingBox2) assertThat(result).isTrue - assertThat(uut.dataArea).isEqualTo(DataArea(2, 10,5)) + assertThat(uut.dataArea).isEqualTo(DataArea(2, 10, 5)) } @Test @@ -232,7 +231,10 @@ class LocalDataTest { assertThat(uut.dataArea).isNull() } - fun createLocation(x: Double, y: Double): Location { + fun createLocation( + x: Double, + y: Double, + ): Location { return Location("").apply { longitude = x latitude = y diff --git a/app/src/test/java/org/blitzortung/android/data/provider/blitzortung/MapBuilderFactoryTest.kt b/app/src/test/java/org/blitzortung/android/data/provider/blitzortung/MapBuilderFactoryTest.kt index 391ac11d6..daab82be1 100644 --- a/app/src/test/java/org/blitzortung/android/data/provider/blitzortung/MapBuilderFactoryTest.kt +++ b/app/src/test/java/org/blitzortung/android/data/provider/blitzortung/MapBuilderFactoryTest.kt @@ -1,3 +1,3 @@ package org.blitzortung.android.data.provider.blitzortung -class MapBuilderFactoryTest \ No newline at end of file +class MapBuilderFactoryTest diff --git a/app/src/test/java/org/blitzortung/android/data/provider/blitzortung/MapBuilderTest.kt b/app/src/test/java/org/blitzortung/android/data/provider/blitzortung/MapBuilderTest.kt index 3a04c3146..f184bf7d9 100644 --- a/app/src/test/java/org/blitzortung/android/data/provider/blitzortung/MapBuilderTest.kt +++ b/app/src/test/java/org/blitzortung/android/data/provider/blitzortung/MapBuilderTest.kt @@ -1,16 +1,15 @@ package org.blitzortung.android.data.provider.blitzortung +import java.time.Instant import org.assertj.core.api.Assertions.assertThat import org.blitzortung.android.data.beans.DefaultStrike import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -import java.time.Instant @RunWith(RobolectricTestRunner::class) class MapBuilderTest { - private lateinit var mapBuilderFactory: MapBuilderFactory @Before @@ -18,7 +17,6 @@ class MapBuilderTest { mapBuilderFactory = MapBuilderFactory() } - @Test fun parsesLine() { val line = @@ -77,4 +75,4 @@ class MapBuilderTest { assertThat(strike).isNotNull } -} \ No newline at end of file +} diff --git a/app/src/test/java/org/blitzortung/android/data/provider/blitzortung/TimestampIteratorTest.kt b/app/src/test/java/org/blitzortung/android/data/provider/blitzortung/TimestampIteratorTest.kt index 84c62c4e3..35194764b 100644 --- a/app/src/test/java/org/blitzortung/android/data/provider/blitzortung/TimestampIteratorTest.kt +++ b/app/src/test/java/org/blitzortung/android/data/provider/blitzortung/TimestampIteratorTest.kt @@ -5,10 +5,8 @@ import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.Test class TimestampIteratorTest { - @Test fun checkNormalOperation() { - assertThat(TimestampIterator(1000, 10001, 12999)) .toIterable() .containsExactly(10000L, 11000L, 12000L) @@ -36,4 +34,4 @@ class TimestampIteratorTest { assertThat(uut.next()).isEqualTo(1000) assertThatThrownBy { uut.next() }.isInstanceOf(NoSuchElementException::class.java) } -} \ No newline at end of file +} diff --git a/app/src/test/java/org/blitzortung/android/data/provider/standard/JsonRpcDataProviderTest.kt b/app/src/test/java/org/blitzortung/android/data/provider/standard/JsonRpcDataProviderTest.kt index 0226d792a..27db7eafc 100644 --- a/app/src/test/java/org/blitzortung/android/data/provider/standard/JsonRpcDataProviderTest.kt +++ b/app/src/test/java/org/blitzortung/android/data/provider/standard/JsonRpcDataProviderTest.kt @@ -4,12 +4,13 @@ import android.content.Context import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK +import java.net.URL import org.assertj.core.api.Assertions.assertThat import org.blitzortung.android.app.view.PreferenceKey import org.blitzortung.android.app.view.put +import org.blitzortung.android.data.DataArea import org.blitzortung.android.data.Flags import org.blitzortung.android.data.History -import org.blitzortung.android.data.DataArea import org.blitzortung.android.data.Parameters import org.blitzortung.android.data.TimeInterval import org.blitzortung.android.data.beans.GridElement @@ -25,13 +26,11 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment -import java.net.URL private const val SERVICE_URL = "http://service.url/" @RunWith(RobolectricTestRunner::class) class JsonRpcDataProviderTest { - private lateinit var uut: JsonRpcDataProvider @MockK @@ -51,18 +50,19 @@ class JsonRpcDataProviderTest { uut = JsonRpcDataProvider(preferences, client) } - @Test fun getsGlobalData() { - val parameters = Parameters( - region = GLOBAL_REGION, - interval = TimeInterval( - offset = 30, - duration = 60 - ), - countThreshold = 5, - gridSize = 25000 - ) + val parameters = + Parameters( + region = GLOBAL_REGION, + interval = + TimeInterval( + offset = 30, + duration = 60, + ), + countThreshold = 5, + gridSize = 25000, + ) val history = History() val flags = Flags() @@ -75,7 +75,7 @@ class JsonRpcDataProviderTest { parameters.intervalDuration, parameters.gridSize, parameters.intervalOffset, - parameters.countThreshold + parameters.countThreshold, ) } returns JsonRpcResponse(response) @@ -98,16 +98,18 @@ class JsonRpcDataProviderTest { @Test fun getsLocalData() { val dataArea = DataArea(5, 6, 5) - val parameters = Parameters( - region = LOCAL_REGION, - interval = TimeInterval( - offset = 30, - duration = 60 - ), - countThreshold = 5, - gridSize = 5000, - dataArea = dataArea - ) + val parameters = + Parameters( + region = LOCAL_REGION, + interval = + TimeInterval( + offset = 30, + duration = 60, + ), + countThreshold = 5, + gridSize = 5000, + dataArea = dataArea, + ) val history = History() val flags = Flags() @@ -148,16 +150,18 @@ class JsonRpcDataProviderTest { @Test fun getsRegionData() { val dataArea = DataArea(5, 6, 5) - val parameters = Parameters( - region = 2, - interval = TimeInterval( - offset = 30, - duration = 60 - ), - countThreshold = 5, - gridSize = 5000, - dataArea = dataArea - ) + val parameters = + Parameters( + region = 2, + interval = + TimeInterval( + offset = 30, + duration = 60, + ), + countThreshold = 5, + gridSize = 5000, + dataArea = dataArea, + ) val history = History() val flags = Flags() @@ -173,7 +177,7 @@ class JsonRpcDataProviderTest { parameters.gridSize, parameters.intervalOffset, parameters.region, - parameters.countThreshold + parameters.countThreshold, ) } returns JsonRpcResponse(response) diff --git a/app/src/test/java/org/blitzortung/android/jsonrpc/HttpServiceClientDefaultTest.kt b/app/src/test/java/org/blitzortung/android/jsonrpc/HttpServiceClientDefaultTest.kt index e981ee3a4..1d5c5cf5e 100644 --- a/app/src/test/java/org/blitzortung/android/jsonrpc/HttpServiceClientDefaultTest.kt +++ b/app/src/test/java/org/blitzortung/android/jsonrpc/HttpServiceClientDefaultTest.kt @@ -1,10 +1,6 @@ package org.blitzortung.android.jsonrpc import io.mockk.MockKAnnotations -import org.assertj.core.api.Assertions.assertThat -import org.junit.Before -import org.junit.BeforeClass -import org.junit.Test import java.io.ByteArrayOutputStream import java.io.IOException import java.io.InputStream @@ -15,7 +11,10 @@ import java.net.URLConnection import java.net.URLStreamHandler import java.net.URLStreamHandlerFactory import java.util.zip.GZIPOutputStream - +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Test class HttpServiceClientDefaultTest { private lateinit var uut: HttpServiceClientDefault @@ -51,8 +50,8 @@ class HttpServiceClientDefaultTest { Pair("Content-Type", "text/json"), Pair("Content-Length", "${data.length}"), Pair("User-Agent", "bo-android-foo"), - Pair("Accept-Encoding", "gzip") - ) + Pair("Accept-Encoding", "gzip"), + ), ) assertThat(handler.connection.outputStream.toString("UTF-8")).isEqualTo(data) @@ -102,9 +101,7 @@ class MockHttpURLConnection( val url: URL?, private val responseHeaders: MutableMap, val responseSupplier: () -> ByteArray, - - ) : HttpURLConnection(url) { - +) : HttpURLConnection(url) { val outputStream = ByteArrayOutputStream() val headers = mutableMapOf() @@ -119,6 +116,7 @@ class MockHttpURLConnection( } override fun disconnect() {} + override fun usingProxy(): Boolean { return false } @@ -127,11 +125,14 @@ class MockHttpURLConnection( return outputStream } - override fun setRequestProperty(key: String?, value: String?) { + override fun setRequestProperty( + key: String?, + value: String?, + ) { headers.put(key, value) } override fun getHeaderField(name: String?): String? { return responseHeaders.get(name) } -} \ No newline at end of file +} diff --git a/app/src/test/java/org/blitzortung/android/jsonrpc/JsonRpcClientTest.kt b/app/src/test/java/org/blitzortung/android/jsonrpc/JsonRpcClientTest.kt index 61305fa63..8bd82b299 100644 --- a/app/src/test/java/org/blitzortung/android/jsonrpc/JsonRpcClientTest.kt +++ b/app/src/test/java/org/blitzortung/android/jsonrpc/JsonRpcClientTest.kt @@ -4,6 +4,7 @@ import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.verify +import java.net.URL import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy import org.json.JSONArray @@ -12,7 +13,6 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -import java.net.URL @RunWith(RobolectricTestRunner::class) class JsonRpcClientTest { @@ -69,7 +69,7 @@ class JsonRpcClientTest { every { httpClient.doRequest( any(), - any() + any(), ) } answers { HttpServiceClientResult("{\"fault\":true,\"faultString\": \"foo\",\"faultCode\":\"1234\"}") } @@ -80,4 +80,4 @@ class JsonRpcClientTest { uut.call(baseUrl, methodName) }.isInstanceOf(JsonRpcException::class.java).hasMessage("remote Exception 'foo' #1234") } -} \ No newline at end of file +} diff --git a/app/src/test/java/org/blitzortung/android/location/provider/ManualLocationProviderTest.kt b/app/src/test/java/org/blitzortung/android/location/provider/ManualLocationProviderTest.kt index 92bd24c69..1accab243 100644 --- a/app/src/test/java/org/blitzortung/android/location/provider/ManualLocationProviderTest.kt +++ b/app/src/test/java/org/blitzortung/android/location/provider/ManualLocationProviderTest.kt @@ -14,7 +14,6 @@ import org.robolectric.RuntimeEnvironment @RunWith(RobolectricTestRunner::class) class ManualLocationProviderTest { - private lateinit var preferences: SharedPreferences @Before diff --git a/app/src/test/java/org/blitzortung/android/map/overlay/color/StrikeColorHandlerTest.kt b/app/src/test/java/org/blitzortung/android/map/overlay/color/StrikeColorHandlerTest.kt index 8d6c611d0..2311c27db 100644 --- a/app/src/test/java/org/blitzortung/android/map/overlay/color/StrikeColorHandlerTest.kt +++ b/app/src/test/java/org/blitzortung/android/map/overlay/color/StrikeColorHandlerTest.kt @@ -12,7 +12,6 @@ import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) @Config(manifest = Config.NONE) class StrikeColorHandlerTest { - lateinit var strikeColorHandler: StrikeColorHandler @Before @@ -21,7 +20,6 @@ class StrikeColorHandlerTest { val preferences = context.getSharedPreferences(context.packageName, Context.MODE_PRIVATE) strikeColorHandler = StrikeColorHandler(preferences) - } @Test @@ -58,4 +56,4 @@ class StrikeColorHandlerTest { assertThat(textColor).isEqualTo(0xffffffff.toInt()) } -} \ No newline at end of file +} diff --git a/app/src/test/java/org/blitzortung/android/protocol/ConsumerContainerTest.kt b/app/src/test/java/org/blitzortung/android/protocol/ConsumerContainerTest.kt index 2591da375..bb2ccdca9 100644 --- a/app/src/test/java/org/blitzortung/android/protocol/ConsumerContainerTest.kt +++ b/app/src/test/java/org/blitzortung/android/protocol/ConsumerContainerTest.kt @@ -1,13 +1,12 @@ package org.blitzortung.android.protocol +import java.util.concurrent.atomic.AtomicInteger import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.Before import org.junit.Test -import java.util.concurrent.atomic.AtomicInteger class ConsumerContainerTest { - private lateinit var testConsumerContainer: TestConsumerContainer @Before @@ -126,7 +125,6 @@ class ConsumerContainerTest { } class TestConsumerContainer : ConsumerContainer() { - val firstConsumersAdded = AtomicInteger() val lastConsumersRemoved = AtomicInteger() @@ -138,4 +136,4 @@ class TestConsumerContainer : ConsumerContainer() { override fun removedLastConsumer() { lastConsumersRemoved.incrementAndGet() } -} \ No newline at end of file +} diff --git a/app/src/test/java/org/blitzortung/android/util/MeasurementSystemTest.kt b/app/src/test/java/org/blitzortung/android/util/MeasurementSystemTest.kt index f917157d6..fbfade7e0 100644 --- a/app/src/test/java/org/blitzortung/android/util/MeasurementSystemTest.kt +++ b/app/src/test/java/org/blitzortung/android/util/MeasurementSystemTest.kt @@ -10,7 +10,6 @@ import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) @Config(manifest = Config.NONE) class MeasurementSystemTest { - @Test fun testMetricFactor() { val metricDistance = MeasurementSystem.METRIC.calculateDistance(123456f) diff --git a/app/src/test/java/org/blitzortung/android/util/PeriodTest.kt b/app/src/test/java/org/blitzortung/android/util/PeriodTest.kt index 35d970a74..e4fb0d682 100644 --- a/app/src/test/java/org/blitzortung/android/util/PeriodTest.kt +++ b/app/src/test/java/org/blitzortung/android/util/PeriodTest.kt @@ -5,7 +5,6 @@ import org.junit.Before import org.junit.Test class PeriodTest { - private val currentTime = Period.currentTime private val currentPeriod = 60 @@ -76,4 +75,4 @@ class PeriodTest { assertThat(period.lastUpdateTime).isEqualTo(0) assertThat(period.updateCount).isEqualTo(0) } -} \ No newline at end of file +} diff --git a/app/src/test/java/org/blitzortung/android/util/TimeFormatTest.kt b/app/src/test/java/org/blitzortung/android/util/TimeFormatTest.kt index 4b99ff091..f149f461b 100644 --- a/app/src/test/java/org/blitzortung/android/util/TimeFormatTest.kt +++ b/app/src/test/java/org/blitzortung/android/util/TimeFormatTest.kt @@ -5,7 +5,6 @@ import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.Test class TimeFormatTest { - @Test fun testParseTimeWithMilliseconds() { val result = TimeFormat.parseTimeWithMilliseconds("20120901T20:10:05.123") diff --git a/build.gradle b/build.gradle deleted file mode 100644 index 3e1c60118..000000000 --- a/build.gradle +++ /dev/null @@ -1,20 +0,0 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. -plugins { - id 'com.android.application' version '8.13.0' apply false - id 'com.android.library' version '8.13.0' apply false - id 'org.jetbrains.kotlin.android' version '2.2.10' apply false - id "org.sonarqube" version "6.2.0.5505" -} - -task clean(type: Delete) { - delete rootProject.buildDir -} - -sonar { - properties { - property "sonar.projectKey", "wuan_bo-android" - property "sonar.organization", "wuan" - property "sonar.host.url", "https://sonarcloud.io" - property "sonar.androidLint.reportPaths", "app/build/reports/lint-results-debug.xml" - } -} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 000000000..3d2664fa4 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,65 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + id("com.android.application") version "8.13.1" apply false + id("com.android.library") version "8.13.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.10" apply false + id("org.sonarqube") version "6.2.0.5505" + id("io.gitlab.arturbosch.detekt") version "1.23.7" + id("org.jlleitschuh.gradle.ktlint") version "12.1.2" +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} + +sonar { + properties { + property("sonar.projectKey", "wuan_bo-android") + property("sonar.organization", "wuan") + property("sonar.host.url", "https://sonarcloud.io") + property("sonar.androidLint.reportPaths", "app/build/reports/lint-results-debug.xml") + } +} + +// Detekt configuration +detekt { + buildUponDefaultConfig = true + allRules = false + config.setFrom(files("$rootDir/config/detekt/detekt.yml")) + baseline = file("$rootDir/config/detekt/baseline.xml") + + reports { + html.required.set(true) + xml.required.set(true) + txt.required.set(false) + sarif.required.set(true) + } +} + +// Apply detekt to all subprojects +subprojects { + apply(plugin = "io.gitlab.arturbosch.detekt") + apply(plugin = "org.jlleitschuh.gradle.ktlint") + + detekt { + buildUponDefaultConfig = true + config.setFrom(files("$rootDir/config/detekt/detekt.yml")) + } + + ktlint { + android.set(true) + ignoreFailures.set(false) + reporters { + reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.PLAIN) + reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE) + reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.HTML) + } + } +} + +// Task to run all static analysis checks +tasks.register("staticAnalysis") { + group = "verification" + description = "Runs all static analysis tools (detekt, ktlint, lint)" + dependsOn("detekt", "ktlintCheck", ":app:lint") +} diff --git a/gradle.properties b/gradle.properties index 5e7effc7e..6285431b8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,23 +4,63 @@ # any settings specified in this file. # For more details on how to configure your build environment visit # http://www.gradle.org/docs/current/userguide/build_environment.html + +# ===== JVM Settings ===== # Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -# When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. More details, visit -# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects -# org.gradle.parallel=true +# Increased heap size for better performance with large projects +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1024m + +# ===== Gradle Daemon ===== +# Keep daemon alive longer to avoid restart overhead +org.gradle.daemon=true + +# ===== Parallel Builds ===== +# Run independent modules in parallel (safe for this project structure) +org.gradle.parallel=true + +# ===== Build Cache ===== +# Reuse outputs from previous builds to speed up incremental builds +org.gradle.caching=true +# ===== Android Settings ===== # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app"s APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true android.enableJetifier=true -# Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official + # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true android.nonFinalResIds=false -org.gradle.configuration-cache=true \ No newline at end of file + +# ===== Kotlin Settings ===== +# Kotlin code style for this project: "official" or "obsolete" +kotlin.code.style=official + +# Enable Kotlin incremental compilation (faster rebuilds) +kotlin.incremental=true +kotlin.incremental.java=true +kotlin.incremental.js=false + +# Kotlin compiler optimizations +kotlin.caching.enabled=true +kotlin.parallel.tasks.in.project=true + +# ===== Kapt Settings ===== +# Enable incremental annotation processing for Kapt (Dagger) +kapt.incremental.apt=true +kapt.use.worker.api=true +kapt.include.compile.classpath=false + +# ===== Configuration Cache ===== +# Reuse configuration phase results (faster builds) +org.gradle.configuration-cache=true +org.gradle.configuration-cache.problems=warn + +# ===== Other Optimizations ===== +# On-demand configuration (only configure projects that are needed) +org.gradle.configureondemand=false + +# Use relative paths for better caching across different machines +org.gradle.unsafe.isolated-projects=false diff --git a/settings.gradle b/settings.gradle.kts similarity index 57% rename from settings.gradle rename to settings.gradle.kts index bb7b4c98f..50f507620 100644 --- a/settings.gradle +++ b/settings.gradle.kts @@ -5,6 +5,7 @@ pluginManagement { mavenCentral() } } + dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { @@ -12,5 +13,15 @@ dependencyResolutionManagement { mavenCentral() } } + +// Enable build cache for faster incremental builds +buildCache { + local { + isEnabled = true + directory = File(rootDir, ".gradle/build-cache") + removeUnusedEntriesAfterDays = 30 + } +} + rootProject.name = "bo-android" -include ':app' +include(":app")