mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-16 19:26:12 -04:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a48171541 | ||
|
|
9b6fd7aa57 | ||
|
|
5d7062171d |
@@ -1 +0,0 @@
|
|||||||
worktrees/
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# CAPod - Companion for AirPods
|
|
||||||
|
|
||||||
Android app that detects and monitors AirPods via Bluetooth LE. Displays battery levels, triggers popup notifications on case open, and provides home screen widgets.
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
| Module | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `app/` | Main Android app (FOSS and Google Play flavors) |
|
|
||||||
| `app-common/` | Shared code between phone and Wear OS apps |
|
|
||||||
|
|
||||||
## Build Flavors
|
|
||||||
|
|
||||||
- **FOSS** (`foss`): Open-source, no Google Play dependencies
|
|
||||||
- **Google Play** (`gplay`): Includes billing client for IAP
|
|
||||||
|
|
||||||
Quick build check: `./gradlew assembleFossDebug`
|
|
||||||
|
|
||||||
## Key Locations
|
|
||||||
|
|
||||||
| Path | Contains |
|
|
||||||
|------|----------|
|
|
||||||
| `app/src/main/java/` | Main app source (activities, fragments, services) |
|
|
||||||
| `app-common/src/main/java/` | Shared logic (monitor, bluetooth, models) |
|
|
||||||
| `app/src/main/res/` | Layouts, drawables, strings |
|
|
||||||
| `app-common/src/test/` | Unit tests |
|
|
||||||
| `app/build.gradle.kts` | App build config, dependencies, flavors |
|
|
||||||
| `app/src/debug/java/.../screenshots/` | Play Store screenshot content composables |
|
|
||||||
| `fastlane/` | Screenshot generation scripts, Play Store metadata |
|
|
||||||
|
|
||||||
## Development Tips
|
|
||||||
|
|
||||||
- Use `assembleFossDebug` as the fastest build variant for iteration
|
|
||||||
- Shared code goes in `app-common/`, app-specific code in `app/`
|
|
||||||
- Follow existing patterns — the codebase uses MVVM + Hilt + Coroutines
|
|
||||||
- Always use string resources for user-facing text (see localization rules)
|
|
||||||
- Check `git log --oneline -20` for commit message style before committing
|
|
||||||
|
|
||||||
## Rules Reference
|
|
||||||
|
|
||||||
Detailed guidelines are in `.claude/rules/`:
|
|
||||||
|
|
||||||
- `architecture.md` — Module structure, key components, data flow, dependencies
|
|
||||||
- `build-commands.md` — Build, test, lint, and release commands
|
|
||||||
- `localization.md` — String resource naming conventions
|
|
||||||
- `commit-guidelines.md` — Commit message format and prefixes
|
|
||||||
- `pull-requests.md` — PR title and description conventions
|
|
||||||
- `agent-instructions.md` — Sub-agent delegation and critical thinking
|
|
||||||
- `screenshots.md` — Play Store screenshot pipeline, commands, adding new screens
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
---
|
|
||||||
description: Instructions for Claude Code sub-agents and task delegation
|
|
||||||
globs:
|
|
||||||
- "**"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Agent Instructions
|
|
||||||
|
|
||||||
## Critical Thinking
|
|
||||||
|
|
||||||
- Do not blindly accept information at face value
|
|
||||||
- Verify assumptions against actual code before proceeding
|
|
||||||
- When encountering unexpected behavior, investigate root causes rather than applying workarounds
|
|
||||||
- If something seems wrong, it probably is — dig deeper
|
|
||||||
|
|
||||||
## Explore vs. Implement
|
|
||||||
|
|
||||||
- **Explore first**: Before making changes, understand the existing code structure and patterns
|
|
||||||
- **Read before writing**: Always read relevant files before modifying them
|
|
||||||
- **Follow existing patterns**: Match the code style and architecture already in use
|
|
||||||
- **Minimal changes**: Only change what's necessary to accomplish the task
|
|
||||||
|
|
||||||
## Sub-Agent Delegation
|
|
||||||
|
|
||||||
When using Task tool to spawn sub-agents:
|
|
||||||
|
|
||||||
- Provide complete context — sub-agents don't share your conversation history unless noted
|
|
||||||
- Be specific about what you need: research only, or research + implementation
|
|
||||||
- Use `Explore` agent type for codebase investigation
|
|
||||||
- Use `Bash` agent type for running builds and tests
|
|
||||||
- Parallelize independent sub-agent tasks for efficiency
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
- Don't create new files when editing existing ones would suffice
|
|
||||||
- Don't add features beyond what was requested
|
|
||||||
- Don't refactor surrounding code when fixing a bug
|
|
||||||
- Don't add comments or documentation to code you didn't change
|
|
||||||
- Don't guess at file paths — use Glob/Grep to find them
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
---
|
|
||||||
description: Architecture overview, module structure, key components, data flow, and dependencies
|
|
||||||
globs:
|
|
||||||
- "app/**/*.kt"
|
|
||||||
- "app-common/**/*.kt"
|
|
||||||
- "**/*.gradle.kts"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Architecture
|
|
||||||
|
|
||||||
## Multi-Module Structure
|
|
||||||
|
|
||||||
- **app/**: Main Android application with FOSS and Google Play flavors
|
|
||||||
- **app-common/**: Shared code between main app and Wear OS app
|
|
||||||
|
|
||||||
## Core Patterns
|
|
||||||
|
|
||||||
- **MVVM**: ViewModels with LiveData/StateFlow for UI state management
|
|
||||||
- **Dependency Injection**: Hilt/Dagger for dependency management
|
|
||||||
- **Coroutines**: Kotlin coroutines for async operations
|
|
||||||
- **Repository Pattern**: Data layer abstraction for monitoring and settings
|
|
||||||
|
|
||||||
## Key Components
|
|
||||||
|
|
||||||
### PodMonitor System
|
|
||||||
|
|
||||||
- `PodMonitor`: Core service that detects and tracks AirPods via Bluetooth LE
|
|
||||||
- `MonitorControl`: Manages MonitorService lifecycle
|
|
||||||
- `MonitorService`: Foreground service that continuously scans for AirPods
|
|
||||||
- `BluetoothEventReceiver`: Handles system Bluetooth events
|
|
||||||
|
|
||||||
### Reaction System
|
|
||||||
|
|
||||||
- `ReactionSettingsFragment`: Configuration for popup notifications
|
|
||||||
- `PopUpWindow`: Displays AirPods status when case is opened
|
|
||||||
- `PopUpPodViewFactory`: Creates UI components for different pod models
|
|
||||||
|
|
||||||
### Common Utilities
|
|
||||||
|
|
||||||
- `EdgeToEdgeHelper`: Handles Android edge-to-edge display insets
|
|
||||||
|
|
||||||
## Build Configuration
|
|
||||||
|
|
||||||
### Flavors
|
|
||||||
|
|
||||||
- **FOSS**: Open-source version without Google Play dependencies
|
|
||||||
- **Google Play (gplay)**: Version with billing client for in-app purchases
|
|
||||||
|
|
||||||
### Build Types
|
|
||||||
|
|
||||||
- **debug**: Unobfuscated, full logging, no minification
|
|
||||||
- **beta**: Obfuscated, production-ready with strict lint checks
|
|
||||||
- **release**: Fully optimized for production distribution
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
The app follows a unidirectional data flow:
|
|
||||||
|
|
||||||
1. `BluetoothEventReceiver` detects Bluetooth events
|
|
||||||
2. `MonitorService` scans for AirPods beacon data
|
|
||||||
3. `PodMonitor` processes and stores device information
|
|
||||||
4. ViewModels observe monitor data via repositories
|
|
||||||
5. UI components react to ViewModel state changes
|
|
||||||
6. `ReactionSystem` triggers popups and notifications
|
|
||||||
|
|
||||||
## Bluetooth LE Implementation
|
|
||||||
|
|
||||||
The app uses Android's Bluetooth LE APIs to scan for Apple device advertisements. The core scanning logic is in `MonitorService` which runs as a foreground service.
|
|
||||||
|
|
||||||
## Multi-Platform Considerations
|
|
||||||
|
|
||||||
Code shared between phone and Wear OS apps is placed in `app-common`. When modifying shared functionality, ensure compatibility across both platforms.
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
- **Unit Tests**: Located in `app-common/src/test/` for shared logic
|
|
||||||
- **Test Flavors**: Separate test configurations for FOSS and Google Play variants
|
|
||||||
|
|
||||||
## Key Dependencies
|
|
||||||
|
|
||||||
- **Hilt**: Dependency injection framework
|
|
||||||
- **AndroidX Navigation**: Fragment navigation with SafeArgs
|
|
||||||
- **kotlinx.serialization**: JSON serialization for configuration and caching
|
|
||||||
- **Material Design**: UI components following Material Design guidelines
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
---
|
|
||||||
description: Build, test, lint, and release commands for Gradle
|
|
||||||
globs:
|
|
||||||
- "**/*.gradle.kts"
|
|
||||||
- "**/*.gradle"
|
|
||||||
- "gradle/**"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Build Commands
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build debug version
|
|
||||||
./gradlew assembleDebug
|
|
||||||
|
|
||||||
# Build all variants (FOSS and Google Play flavors)
|
|
||||||
./gradlew assemble
|
|
||||||
|
|
||||||
# Build specific flavor and type
|
|
||||||
./gradlew assembleFossDebug
|
|
||||||
./gradlew assembleGplayRelease
|
|
||||||
|
|
||||||
# Build app bundles for Play Store
|
|
||||||
./gradlew bundleGplayRelease
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all unit tests
|
|
||||||
./gradlew test
|
|
||||||
|
|
||||||
# Run unit tests for specific variant
|
|
||||||
./gradlew testFossDebugUnitTest
|
|
||||||
|
|
||||||
# Run instrumentation tests (requires connected device/emulator)
|
|
||||||
./gradlew connectedAndroidTest
|
|
||||||
./gradlew connectedFossDebugAndroidTest
|
|
||||||
|
|
||||||
# Run all checks (lint + tests)
|
|
||||||
./gradlew check
|
|
||||||
```
|
|
||||||
|
|
||||||
## Code Quality
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run lint for all variants
|
|
||||||
./gradlew lint
|
|
||||||
|
|
||||||
# Run lint for specific variant
|
|
||||||
./gradlew lintFossDebug
|
|
||||||
|
|
||||||
# Auto-fix lint issues where possible
|
|
||||||
./gradlew lintFix
|
|
||||||
|
|
||||||
# Update lint baseline
|
|
||||||
./gradlew updateLintBaseline
|
|
||||||
```
|
|
||||||
|
|
||||||
## Release
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew assembleFossRelease assembleGplayRelease
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Use `assembleFossDebug` as the default quick-check build (fastest variant)
|
|
||||||
- Run `./gradlew check` before submitting changes to catch lint and test issues
|
|
||||||
- Instrumentation tests require a connected device or running emulator
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
---
|
|
||||||
description: Git commit message format and conventions
|
|
||||||
globs:
|
|
||||||
- "**"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Commit Guidelines
|
|
||||||
|
|
||||||
## Format
|
|
||||||
|
|
||||||
```
|
|
||||||
<prefix>: <Short summary>
|
|
||||||
```
|
|
||||||
|
|
||||||
Summary line should be concise and describe the change. No period at the end.
|
|
||||||
|
|
||||||
## Prefixes
|
|
||||||
|
|
||||||
Use the existing commit history as reference. Common prefixes:
|
|
||||||
|
|
||||||
- **fix**: Bug fixes (e.g., `fix: Handle display cutouts in landscape mode`)
|
|
||||||
- **feat**: New features
|
|
||||||
- **refactor**: Code restructuring without behavior change
|
|
||||||
- **chore**: Maintenance, dependency updates, build config
|
|
||||||
- **docs**: Documentation changes
|
|
||||||
|
|
||||||
## Component Scope (optional)
|
|
||||||
|
|
||||||
When a change is scoped to a specific area, include it after the prefix:
|
|
||||||
|
|
||||||
- `fix(widget): Fix layout for devices with single charge detection`
|
|
||||||
- `feat(monitor): Add battery level caching`
|
|
||||||
- `refactor(popup): Extract pod view factory`
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- Keep the summary line under 72 characters
|
|
||||||
- Use imperative mood ("Add feature" not "Added feature")
|
|
||||||
- Reference issue numbers when applicable
|
|
||||||
- Do not include `Co-authored-by` trailers
|
|
||||||
- Look at recent `git log` output to match the project's existing style
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
---
|
|
||||||
description: Guidelines for adding and naming Android string resources
|
|
||||||
globs:
|
|
||||||
- "**/res/values*/strings.xml"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Localization Guidelines
|
|
||||||
|
|
||||||
When adding new user-facing strings:
|
|
||||||
|
|
||||||
- **Always use string resources**: Never hardcode user-facing text in layouts or code
|
|
||||||
- **Follow naming conventions**: Use descriptive, hierarchical naming (e.g., `profiles_name_default`, `settings_bluetooth_enabled`)
|
|
||||||
- **Provide context**: String names should indicate usage and location
|
|
||||||
- **Consider pluralization**: Use Android plural resources (`<plurals>`) when quantities vary
|
|
||||||
|
|
||||||
## Naming Examples
|
|
||||||
|
|
||||||
- `profiles_create_title` (screen title)
|
|
||||||
- `profiles_name_label` (form field label)
|
|
||||||
- `profiles_delete_confirmation` (dialog message)
|
|
||||||
- `error_network_unavailable` (error message)
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
---
|
|
||||||
description: Pull request naming and description conventions
|
|
||||||
globs:
|
|
||||||
- "**"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Pull Request Guidelines
|
|
||||||
|
|
||||||
## PR Title Format
|
|
||||||
|
|
||||||
```
|
|
||||||
<Category>: <Short user-facing summary>
|
|
||||||
```
|
|
||||||
|
|
||||||
PR titles appear in auto-generated changelogs and are read by users. Use **ELI5, user-facing language** — no internal class names, library names, or implementation details.
|
|
||||||
|
|
||||||
## Category Prefixes
|
|
||||||
|
|
||||||
| Prefix | Covers |
|
|
||||||
|--------|--------|
|
|
||||||
| **Widget** | Home screen widget |
|
|
||||||
| **Reaction** | Case-open popup, auto-play/pause, notification triggers |
|
|
||||||
| **Device** | AirPods detection, compatibility, Bluetooth scanning, battery reading, device profiles |
|
|
||||||
| **General** | Dashboard, settings, notifications, themes, onboarding, support, app-wide UI |
|
|
||||||
| **Fix** | Bug fixes spanning multiple areas |
|
|
||||||
|
|
||||||
### Title Examples
|
|
||||||
|
|
||||||
- `Widget: Add color themes and transparency slider`
|
|
||||||
- `Reaction: Fix popup appearing twice when opening AirPods case`
|
|
||||||
- `Device: Add support for AirPods 4 with ANC`
|
|
||||||
- `General: Add dark mode and color theme settings`
|
|
||||||
- `Fix: Fix battery display stuck at 0% after reconnecting`
|
|
||||||
|
|
||||||
### Bad Titles (too technical)
|
|
||||||
|
|
||||||
- `refactor(settings): Migrate preferences to AndroidX DataStore`
|
|
||||||
- `feat(widget): Migrate to Jetpack Glance`
|
|
||||||
- `refactor(ui): Migrate from Fragments to Jetpack Compose`
|
|
||||||
|
|
||||||
## PR Description Format
|
|
||||||
|
|
||||||
### What changed
|
|
||||||
|
|
||||||
User-friendly explanation of what this PR does. Describe the problem that was fixed or the feature that was added from the user's perspective. No internal class or method names.
|
|
||||||
|
|
||||||
For non-user-facing PRs (refactors, tests, CI, dependency bumps): write "No user-facing behavior change" followed by a brief internal description.
|
|
||||||
|
|
||||||
### Technical Context
|
|
||||||
|
|
||||||
Explain what's hard to extract from the diff alone. Focus on:
|
|
||||||
|
|
||||||
- **Why** this approach was chosen (and alternatives considered/rejected)
|
|
||||||
- **Root cause** for bug fixes (the diff shows the fix, not what caused it)
|
|
||||||
- **Non-obvious side effects** or behavioral changes not apparent from reading the code
|
|
||||||
- **Review guidance** — what's tricky or deserves close attention
|
|
||||||
|
|
||||||
Keep it scannable with bullet points. Don't restate what's visible in the diff (file names, class renames, line-level changes).
|
|
||||||
|
|
||||||
### Example
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## What changed
|
|
||||||
|
|
||||||
Fixed a crash that could happen when the AirPods case is opened while Bluetooth is turning off.
|
|
||||||
|
|
||||||
## Technical Context
|
|
||||||
|
|
||||||
- Root cause: `MonitorService` continued processing scan results during Bluetooth adapter state change, hitting a null adapter reference
|
|
||||||
- Chose to gate on adapter state in the scan callback rather than adding a separate BroadcastReceiver, since the service already observes adapter state for restart logic
|
|
||||||
- The timing window is ~200ms between ACTION_STATE_CHANGING and ACTION_STATE_OFF — only reproducible on Pixel devices with aggressive Bluetooth power management
|
|
||||||
```
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
- **Issue references**: Use "Closes #123", "Fixes #123", or "Resolves #123"
|
|
||||||
- **Breaking changes**: Mark with "BREAKING:" prefix if applicable
|
|
||||||
- **No `Co-authored-by` trailers** (per project convention)
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# Play Store Screenshot Pipeline
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Localized screenshots are generated using Compose Preview Screenshot Testing (alpha), rendered offline (no device needed), and sorted into fastlane metadata directories for Play Store upload.
|
|
||||||
|
|
||||||
## Pipeline
|
|
||||||
|
|
||||||
```
|
|
||||||
ScreenshotContent.kt (mock data + composables)
|
|
||||||
→ PlayStoreScreenshots.kt (@PreviewTest entry points)
|
|
||||||
→ PlayStoreLocales.kt (multi-preview locale annotations, auto-generated per batch)
|
|
||||||
→ generate_screenshots.sh (batched Gradle runs to avoid OOM)
|
|
||||||
→ copy_screenshots.sh (sort PNGs into fastlane locale dirs)
|
|
||||||
→ fastlane/metadata/android/{locale}/images/phoneScreenshots/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `app/src/debug/java/.../screenshots/ScreenshotContent.kt` | Mock data composables for each screen (8 screens) |
|
|
||||||
| `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreScreenshots.kt` | `@PreviewTest` functions that wire content to locale annotations |
|
|
||||||
| `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreLocales.kt` | Multi-preview annotations (auto-generated by batch script) |
|
|
||||||
| `fastlane/generate_screenshots.sh` | Batched generation across 68 locales |
|
|
||||||
| `fastlane/copy_screenshots.sh` | Copies rendered PNGs into fastlane structure |
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Full run — all 68 locales, ~12 batches, ~7 minutes
|
|
||||||
./fastlane/generate_screenshots.sh
|
|
||||||
|
|
||||||
# Smoke test — 6 locales (en, de, ja, ar, zh-CN, pt-BR), single batch
|
|
||||||
./fastlane/generate_screenshots.sh --smoke
|
|
||||||
|
|
||||||
# Copy into fastlane directories (run after generate)
|
|
||||||
./fastlane/copy_screenshots.sh
|
|
||||||
|
|
||||||
# Clean copy (removes old screenshots first)
|
|
||||||
./fastlane/copy_screenshots.sh --clean
|
|
||||||
```
|
|
||||||
|
|
||||||
## Adding a New Screenshot
|
|
||||||
|
|
||||||
1. Add a composable content function in `ScreenshotContent.kt` (e.g. `NewScreenContent()`)
|
|
||||||
2. Add a `@PreviewTest` function in `PlayStoreScreenshots.kt` that calls it
|
|
||||||
3. Add the function name → filename mapping in `copy_screenshots.sh` `SCREEN_MAP`
|
|
||||||
4. Update the expected count in `generate_screenshots.sh` (composables per locale)
|
|
||||||
5. Run the full pipeline: `generate_screenshots.sh` then `copy_screenshots.sh`
|
|
||||||
|
|
||||||
## After UI Changes
|
|
||||||
|
|
||||||
When modifying a screen that appears in screenshots (check `ScreenshotContent.kt`), regenerate:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./fastlane/generate_screenshots.sh
|
|
||||||
./fastlane/copy_screenshots.sh --clean
|
|
||||||
```
|
|
||||||
|
|
||||||
## Technical Notes
|
|
||||||
|
|
||||||
- Batch size defaults to 2 locales (16 renders) to avoid layoutlib memory leak (~10MB/image)
|
|
||||||
- Gradle daemon is stopped between batches to release memory
|
|
||||||
- `PlayStoreLocales.kt` is temporarily rewritten per batch and restored via trap
|
|
||||||
- Device spec: 1080x2400px @ 428 DPI (Pixel-class phone)
|
|
||||||
- Uses `com.android.compose.screenshot` plugin v0.0.1-alpha13
|
|
||||||
- Output: `app/src/screenshotTestGplayDebug/reference/`
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"mcp__ide__getDiagnostics",
|
|
||||||
"WebSearch"
|
|
||||||
],
|
|
||||||
"deny": []
|
|
||||||
},
|
|
||||||
"enabledPlugins": {
|
|
||||||
"android-translation@claude-code-cafe": true,
|
|
||||||
"debugbadger@claude-code-cafe": true,
|
|
||||||
"jvm-tools@claude-code-cafe": true,
|
|
||||||
"frontend-design@claude-plugins-official": true,
|
|
||||||
"kotlin-lsp@claude-plugins-official": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ runs:
|
|||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 #v5.2.0
|
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 #v4.7.0
|
||||||
with:
|
with:
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
@@ -14,7 +14,7 @@ runs:
|
|||||||
run: chmod +x gradlew
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Cache Gradle Wrapper
|
- name: Cache Gradle Wrapper
|
||||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/wrapper
|
~/.gradle/wrapper
|
||||||
@@ -24,7 +24,7 @@ runs:
|
|||||||
${{ runner.os }}-gradle-wrapper-
|
${{ runner.os }}-gradle-wrapper-
|
||||||
|
|
||||||
- name: Cache Gradle Dependencies
|
- name: Cache Gradle Dependencies
|
||||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -33,7 +33,7 @@ runs:
|
|||||||
${{ runner.os }}-gradle-caches-
|
${{ runner.os }}-gradle-caches-
|
||||||
|
|
||||||
- name: Cache Android Global Build-Cache
|
- name: Cache Android Global Build-Cache
|
||||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.android/build-cache
|
~/.android/build-cache
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||||
- name: Setup project and build environment
|
- name: Setup project and build environment
|
||||||
uses: ./.github/actions/common-setup
|
uses: ./.github/actions/common-setup
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||||
- name: Setup project and build environment
|
- name: Setup project and build environment
|
||||||
uses: ./.github/actions/common-setup
|
uses: ./.github/actions/common-setup
|
||||||
|
|
||||||
@@ -48,23 +48,14 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
variant: [ Debug ]
|
variant: [ Debug,Beta,Release ]
|
||||||
flavor: [ testFoss,testGplay ]
|
flavor: [ testFoss,testGplay ]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||||
- name: Setup project and build environment
|
- name: Setup project and build environment
|
||||||
uses: ./.github/actions/common-setup
|
uses: ./.github/actions/common-setup
|
||||||
|
|
||||||
- name: Test modules
|
- name: Test modules
|
||||||
run: ./gradlew ${{ matrix.flavor }}${{ matrix.variant }}UnitTest
|
run: ./gradlew ${{ matrix.flavor }}${{ matrix.variant }}UnitTest
|
||||||
|
|
||||||
check-fastlane-metadata:
|
|
||||||
name: Fastlane metadata
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
steps:
|
|
||||||
- name: Checkout source code
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
|
||||||
- name: Validate metadata lengths
|
|
||||||
run: bash fastlane/check_metadata_length.sh
|
|
||||||
@@ -13,5 +13,5 @@ jobs:
|
|||||||
name: "Validation"
|
name: "Validation"
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||||
- uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c #v5.0.2
|
- uses: gradle/actions/wrapper-validation@06832c7b30a0129d7fb559bcc6e43d26f6374244 #v4.3.1
|
||||||
|
|||||||
@@ -4,12 +4,6 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
dry_run:
|
|
||||||
description: 'Build only, skip release/upload'
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release-github:
|
release-github:
|
||||||
@@ -30,15 +24,23 @@ jobs:
|
|||||||
echo "STORE_PATH=$(echo $TMP_KEYSTORE_FILE_PATH)" >> $GITHUB_ENV
|
echo "STORE_PATH=$(echo $TMP_KEYSTORE_FILE_PATH)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup project and build environment
|
- name: Setup project and build environment
|
||||||
uses: ./.github/actions/common-setup
|
uses: ./.github/actions/common-setup
|
||||||
|
|
||||||
|
- name: Get the version
|
||||||
|
id: tagger
|
||||||
|
uses: jimschubert/query-tag-action@0b288a5fff630fea2e96d61b99047ed823ca19dc #v2.2
|
||||||
|
with:
|
||||||
|
skip-unshallow: 'true'
|
||||||
|
abbrev: false
|
||||||
|
commit-ish: HEAD
|
||||||
|
|
||||||
- name: Assemble beta APK
|
- name: Assemble beta APK
|
||||||
if: contains(github.ref_name, '-beta')
|
if: contains(steps.tagger.outputs.tag, '-beta')
|
||||||
run: ./gradlew assembleFossBeta
|
run: ./gradlew assembleFossBeta
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ github.ref }}
|
VERSION: ${{ github.ref }}
|
||||||
@@ -47,7 +49,7 @@ jobs:
|
|||||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
|
||||||
- name: Assemble production APK
|
- name: Assemble production APK
|
||||||
if: "!contains(github.ref_name, '-beta')"
|
if: "!contains(steps.tagger.outputs.tag, '-beta')"
|
||||||
run: ./gradlew assembleFossRelease
|
run: ./gradlew assembleFossRelease
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ github.ref }}
|
VERSION: ${{ github.ref }}
|
||||||
@@ -56,26 +58,28 @@ jobs:
|
|||||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
|
||||||
- name: Create pre-release
|
- name: Create pre-release
|
||||||
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
|
if: contains(steps.tagger.outputs.tag, '-beta')
|
||||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b #v2.5.0
|
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda #v2.2.1
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ steps.tagger.outputs.tag }}
|
||||||
name: ${{ github.ref_name }}
|
name: ${{ steps.tagger.outputs.tag }}
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
files: app/build/outputs/apk/foss/beta/eu.darken.capod-*.apk
|
files: |
|
||||||
|
app/build/outputs/apk/foss/beta/*.apk
|
||||||
|
app-wear/build/outputs/apk/foss/beta/*.apk
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
if: "!contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
|
if: "!contains(steps.tagger.outputs.tag, '-beta')"
|
||||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b #v2.5.0
|
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda #v2.2.1
|
||||||
with:
|
with:
|
||||||
prerelease: false
|
prerelease: false
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ steps.tagger.outputs.tag }}
|
||||||
name: ${{ github.ref_name }}
|
name: ${{ steps.tagger.outputs.tag }}
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
files: app/build/outputs/apk/foss/release/eu.darken.capod-*.apk
|
files: app/build/outputs/apk/foss/release/*.apk
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
@@ -105,29 +109,53 @@ jobs:
|
|||||||
echo "SUPPLY_JSON_KEY=$(echo $TMP_SERVICEKEY_FILE_PATH)" >> $GITHUB_ENV
|
echo "SUPPLY_JSON_KEY=$(echo $TMP_SERVICEKEY_FILE_PATH)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup project and build environment
|
- name: Setup project and build environment
|
||||||
uses: ./.github/actions/common-setup
|
uses: ./.github/actions/common-setup
|
||||||
|
|
||||||
|
- name: Get the version
|
||||||
|
id: tagger
|
||||||
|
uses: jimschubert/query-tag-action@0b288a5fff630fea2e96d61b99047ed823ca19dc #v2.2
|
||||||
|
with:
|
||||||
|
skip-unshallow: 'true'
|
||||||
|
abbrev: false
|
||||||
|
commit-ish: HEAD
|
||||||
|
|
||||||
- name: Set up ruby env
|
- name: Set up ruby env
|
||||||
uses: ruby/setup-ruby@4eb9f110bac952a8b68ecf92e3b5c7a987594ba6 #v1.292.0
|
uses: ruby/setup-ruby@354a1ad156761f5ee2b7b13fa8e09943a5e8d252 #v1.229.0
|
||||||
with:
|
with:
|
||||||
ruby-version: 3.3.6
|
ruby-version: 3.3.6
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
|
|
||||||
|
# - name: Assemble WearOS beta and upload to Google Play
|
||||||
|
# if: contains(steps.tagger.outputs.tag, '-beta')
|
||||||
|
# run: bundle exec fastlane beta_wearos
|
||||||
|
# env:
|
||||||
|
# STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
||||||
|
# KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||||
|
# KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
|
||||||
- name: Assemble beta and upload to Google Play
|
- name: Assemble beta and upload to Google Play
|
||||||
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
|
if: contains(steps.tagger.outputs.tag, '-beta')
|
||||||
run: bundle exec fastlane beta
|
run: bundle exec fastlane beta
|
||||||
env:
|
env:
|
||||||
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
||||||
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
|
||||||
|
# - name: Assemble WearOS production and upload to Google Play
|
||||||
|
# if: "!contains(steps.tagger.outputs.tag, '-beta')"
|
||||||
|
# run: bundle exec fastlane production_wearos
|
||||||
|
# env:
|
||||||
|
# STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
||||||
|
# KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||||
|
# KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
|
||||||
- name: Assemble production and upload to Google Play
|
- name: Assemble production and upload to Google Play
|
||||||
if: "!contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
|
if: "!contains(steps.tagger.outputs.tag, '-beta')"
|
||||||
run: bundle exec fastlane production
|
run: bundle exec fastlane production
|
||||||
env:
|
env:
|
||||||
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
||||||
|
|||||||
+1
-5
@@ -12,8 +12,4 @@
|
|||||||
*.jks
|
*.jks
|
||||||
.local/*
|
.local/*
|
||||||
/fastlane/report.xml
|
/fastlane/report.xml
|
||||||
/fastlane/Appfile
|
/fastlane/Appfile
|
||||||
/fastlane/README.md
|
|
||||||
.kotlin
|
|
||||||
# Screenshot test reference images (ephemeral, regenerated on demand)
|
|
||||||
app/src/screenshotTest*/reference/
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
---
|
|
||||||
layout: plain
|
|
||||||
permalink: /changelog
|
|
||||||
title: "Changelog"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Changelog for CAPod
|
|
||||||
|
|
||||||
{% for release in site.github.releases %}
|
|
||||||
|
|
||||||
## {{ release.tag_name }} - {{ release.published_at | date: "%B %d, %Y" }}
|
|
||||||
|
|
||||||
{% assign clean_body = release.body | strip %}
|
|
||||||
{% assign no_comments = clean_body | replace: "<!-- Release notes generated using configuration in .github/release.yml", "" %}
|
|
||||||
{% assign no_comments = no_comments | split: "-->" %}
|
|
||||||
{% if no_comments.size > 1 %}
|
|
||||||
{% assign clean_content = no_comments[1] | strip %}
|
|
||||||
{% else %}
|
|
||||||
{% assign clean_content = no_comments[0] | strip %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% comment %} Make links clickable {% endcomment %}
|
|
||||||
{% assign lines = clean_content | split: "
|
|
||||||
" %}
|
|
||||||
{% assign processed_lines = "" %}
|
|
||||||
{% for line in lines %}
|
|
||||||
{% if line contains "**Full Changelog**:" %}
|
|
||||||
{% comment %} Handle Full Changelog links {% endcomment %}
|
|
||||||
{% assign parts = line | split: ": " %}
|
|
||||||
{% if parts.size > 1 %}
|
|
||||||
{% assign url = parts[1] | strip %}
|
|
||||||
{% assign clickable_line = "**[View Changes](" | append: url | append: ")**" %}
|
|
||||||
{% assign processed_lines = processed_lines | append: clickable_line | append: "
|
|
||||||
" %}
|
|
||||||
{% else %}
|
|
||||||
{% assign processed_lines = processed_lines | append: line | append: "
|
|
||||||
" %}
|
|
||||||
{% endif %}
|
|
||||||
{% elsif line contains " in https://github.com/" and line contains "/pull/" %}
|
|
||||||
{% comment %} Handle pull request links {% endcomment %}
|
|
||||||
{% assign pr_parts = line | split: " in https://github.com/" %}
|
|
||||||
{% if pr_parts.size > 1 %}
|
|
||||||
{% assign before_url = pr_parts[0] %}
|
|
||||||
{% assign after_url = pr_parts[1] %}
|
|
||||||
{% assign url = "https://github.com/" | append: after_url %}
|
|
||||||
{% assign pr_number = after_url | split: "/pull/" %}
|
|
||||||
{% if pr_number.size > 1 %}
|
|
||||||
{% assign pr_num = pr_number[1] | split: " " | first %}
|
|
||||||
{% assign clickable_line = before_url | append: " in [#" | append: pr_num | append: "](" | append: url | append: ")" %}
|
|
||||||
{% assign processed_lines = processed_lines | append: clickable_line | append: "
|
|
||||||
" %}
|
|
||||||
{% else %}
|
|
||||||
{% assign processed_lines = processed_lines | append: line | append: "
|
|
||||||
" %}
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
{% assign processed_lines = processed_lines | append: line | append: "
|
|
||||||
" %}
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
{% assign processed_lines = processed_lines | append: line | append: "
|
|
||||||
" %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% comment %} Add proper spacing between sections and bullet points {% endcomment %}
|
|
||||||
{% assign final_content = processed_lines | replace: "
|
|
||||||
### ", "
|
|
||||||
|
|
||||||
### " %}
|
|
||||||
{% assign final_content = final_content | replace: "
|
|
||||||
## ", "
|
|
||||||
|
|
||||||
## " %}
|
|
||||||
{% assign final_content = final_content | replace: "
|
|
||||||
- ", "
|
|
||||||
|
|
||||||
- " %}
|
|
||||||
|
|
||||||
{% comment %} Check if there are any bullet points (actual release notes) {% endcomment %}
|
|
||||||
{% if final_content contains "## " or final_content contains "- " %}
|
|
||||||
{{ final_content | markdownify }}
|
|
||||||
{% else %}
|
|
||||||
*No release notes available.*
|
|
||||||
|
|
||||||
{{ final_content | markdownify }}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
---
|
|
||||||
{% endfor %}
|
|
||||||
+100
-122
@@ -1,49 +1,43 @@
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
CFPropertyList (3.0.8)
|
CFPropertyList (3.0.6)
|
||||||
abbrev (0.1.2)
|
rexml
|
||||||
addressable (2.8.8)
|
addressable (2.8.4)
|
||||||
public_suffix (>= 2.0.2, < 8.0)
|
public_suffix (>= 2.0.2, < 6.0)
|
||||||
artifactory (3.0.17)
|
artifactory (3.0.15)
|
||||||
atomos (0.1.3)
|
atomos (0.1.3)
|
||||||
aws-eventstream (1.4.0)
|
aws-eventstream (1.2.0)
|
||||||
aws-partitions (1.1216.0)
|
aws-partitions (1.784.0)
|
||||||
aws-sdk-core (3.242.0)
|
aws-sdk-core (3.177.0)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
aws-partitions (~> 1, >= 1.651.0)
|
||||||
aws-sigv4 (~> 1.9)
|
aws-sigv4 (~> 1.5)
|
||||||
base64
|
|
||||||
bigdecimal
|
|
||||||
jmespath (~> 1, >= 1.6.1)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
logger
|
aws-sdk-kms (1.70.0)
|
||||||
aws-sdk-kms (1.122.0)
|
aws-sdk-core (~> 3, >= 3.177.0)
|
||||||
aws-sdk-core (~> 3, >= 3.241.4)
|
aws-sigv4 (~> 1.1)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sdk-s3 (1.128.0)
|
||||||
aws-sdk-s3 (1.213.0)
|
aws-sdk-core (~> 3, >= 3.177.0)
|
||||||
aws-sdk-core (~> 3, >= 3.241.4)
|
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.6)
|
||||||
aws-sigv4 (1.12.1)
|
aws-sigv4 (1.6.0)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
babosa (1.0.4)
|
babosa (1.0.4)
|
||||||
base64 (0.2.0)
|
|
||||||
benchmark (0.5.0)
|
|
||||||
bigdecimal (4.0.1)
|
|
||||||
claide (1.1.0)
|
claide (1.1.0)
|
||||||
colored (1.2)
|
colored (1.2)
|
||||||
colored2 (3.1.2)
|
colored2 (3.1.2)
|
||||||
commander (4.6.0)
|
commander (4.6.0)
|
||||||
highline (~> 2.0.0)
|
highline (~> 2.0.0)
|
||||||
csv (3.3.5)
|
|
||||||
declarative (0.0.20)
|
declarative (0.0.20)
|
||||||
digest-crc (0.7.0)
|
digest-crc (0.6.5)
|
||||||
rake (>= 12.0.0, < 14.0.0)
|
rake (>= 12.0.0, < 14.0.0)
|
||||||
domain_name (0.6.20240107)
|
domain_name (0.5.20190701)
|
||||||
|
unf (>= 0.0.5, < 1.0.0)
|
||||||
dotenv (2.8.1)
|
dotenv (2.8.1)
|
||||||
emoji_regex (3.2.3)
|
emoji_regex (3.2.3)
|
||||||
excon (0.112.0)
|
excon (0.100.0)
|
||||||
faraday (1.10.5)
|
faraday (1.10.3)
|
||||||
faraday-em_http (~> 1.0)
|
faraday-em_http (~> 1.0)
|
||||||
faraday-em_synchrony (~> 1.0)
|
faraday-em_synchrony (~> 1.0)
|
||||||
faraday-excon (~> 1.1)
|
faraday-excon (~> 1.1)
|
||||||
@@ -55,36 +49,32 @@ GEM
|
|||||||
faraday-rack (~> 1.0)
|
faraday-rack (~> 1.0)
|
||||||
faraday-retry (~> 1.0)
|
faraday-retry (~> 1.0)
|
||||||
ruby2_keywords (>= 0.0.4)
|
ruby2_keywords (>= 0.0.4)
|
||||||
faraday-cookie_jar (0.0.8)
|
faraday-cookie_jar (0.0.7)
|
||||||
faraday (>= 0.8.0)
|
faraday (>= 0.8.0)
|
||||||
http-cookie (>= 1.0.0)
|
http-cookie (~> 1.0.0)
|
||||||
faraday-em_http (1.0.0)
|
faraday-em_http (1.0.0)
|
||||||
faraday-em_synchrony (1.0.1)
|
faraday-em_synchrony (1.0.0)
|
||||||
faraday-excon (1.1.0)
|
faraday-excon (1.1.0)
|
||||||
faraday-httpclient (1.0.1)
|
faraday-httpclient (1.0.1)
|
||||||
faraday-multipart (1.2.0)
|
faraday-multipart (1.0.4)
|
||||||
multipart-post (~> 2.0)
|
multipart-post (~> 2)
|
||||||
faraday-net_http (1.0.2)
|
faraday-net_http (1.0.1)
|
||||||
faraday-net_http_persistent (1.2.0)
|
faraday-net_http_persistent (1.2.0)
|
||||||
faraday-patron (1.0.0)
|
faraday-patron (1.0.0)
|
||||||
faraday-rack (1.0.0)
|
faraday-rack (1.0.0)
|
||||||
faraday-retry (1.0.3)
|
faraday-retry (1.0.3)
|
||||||
faraday_middleware (1.2.1)
|
faraday_middleware (1.2.0)
|
||||||
faraday (~> 1.0)
|
faraday (~> 1.0)
|
||||||
fastimage (2.4.0)
|
fastimage (2.2.7)
|
||||||
fastlane (2.232.1)
|
fastlane (2.213.0)
|
||||||
CFPropertyList (>= 2.3, < 4.0.0)
|
CFPropertyList (>= 2.3, < 4.0.0)
|
||||||
abbrev (~> 0.1.2)
|
|
||||||
addressable (>= 2.8, < 3.0.0)
|
addressable (>= 2.8, < 3.0.0)
|
||||||
artifactory (~> 3.0)
|
artifactory (~> 3.0)
|
||||||
aws-sdk-s3 (~> 1.197)
|
aws-sdk-s3 (~> 1.0)
|
||||||
babosa (>= 1.0.3, < 2.0.0)
|
babosa (>= 1.0.3, < 2.0.0)
|
||||||
base64 (~> 0.2.0)
|
bundler (>= 1.12.0, < 3.0.0)
|
||||||
benchmark (>= 0.1.0)
|
colored
|
||||||
bundler (>= 1.17.3, < 5.0.0)
|
|
||||||
colored (~> 1.2)
|
|
||||||
commander (~> 4.6)
|
commander (~> 4.6)
|
||||||
csv (~> 3.3)
|
|
||||||
dotenv (>= 2.1.1, < 3.0.0)
|
dotenv (>= 2.1.1, < 3.0.0)
|
||||||
emoji_regex (>= 0.1, < 4.0)
|
emoji_regex (>= 0.1, < 4.0)
|
||||||
excon (>= 0.71.0, < 1.0.0)
|
excon (>= 0.71.0, < 1.0.0)
|
||||||
@@ -92,145 +82,133 @@ GEM
|
|||||||
faraday-cookie_jar (~> 0.0.6)
|
faraday-cookie_jar (~> 0.0.6)
|
||||||
faraday_middleware (~> 1.0)
|
faraday_middleware (~> 1.0)
|
||||||
fastimage (>= 2.1.0, < 3.0.0)
|
fastimage (>= 2.1.0, < 3.0.0)
|
||||||
fastlane-sirp (>= 1.0.0)
|
|
||||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||||
google-apis-androidpublisher_v3 (~> 0.3)
|
google-apis-androidpublisher_v3 (~> 0.3)
|
||||||
google-apis-playcustomapp_v1 (~> 0.1)
|
google-apis-playcustomapp_v1 (~> 0.1)
|
||||||
google-cloud-env (>= 1.6.0, <= 2.1.1)
|
|
||||||
google-cloud-storage (~> 1.31)
|
google-cloud-storage (~> 1.31)
|
||||||
highline (~> 2.0)
|
highline (~> 2.0)
|
||||||
http-cookie (~> 1.0.5)
|
|
||||||
json (< 3.0.0)
|
json (< 3.0.0)
|
||||||
jwt (>= 2.1.0, < 3)
|
jwt (>= 2.1.0, < 3)
|
||||||
logger (>= 1.6, < 2.0)
|
|
||||||
mini_magick (>= 4.9.4, < 5.0.0)
|
mini_magick (>= 4.9.4, < 5.0.0)
|
||||||
multipart-post (>= 2.0.0, < 3.0.0)
|
multipart-post (>= 2.0.0, < 3.0.0)
|
||||||
mutex_m (~> 0.3.0)
|
|
||||||
naturally (~> 2.2)
|
naturally (~> 2.2)
|
||||||
nkf (~> 0.2.0)
|
optparse (~> 0.1.1)
|
||||||
optparse (>= 0.1.1, < 1.0.0)
|
|
||||||
ostruct (>= 0.1.0)
|
|
||||||
plist (>= 3.1.0, < 4.0.0)
|
plist (>= 3.1.0, < 4.0.0)
|
||||||
rubyzip (>= 2.0.0, < 3.0.0)
|
rubyzip (>= 2.0.0, < 3.0.0)
|
||||||
security (= 0.1.5)
|
security (= 0.1.3)
|
||||||
simctl (~> 1.6.3)
|
simctl (~> 1.6.3)
|
||||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||||
terminal-table (~> 3)
|
terminal-table (>= 1.4.5, < 2.0.0)
|
||||||
tty-screen (>= 0.6.3, < 1.0.0)
|
tty-screen (>= 0.6.3, < 1.0.0)
|
||||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||||
word_wrap (~> 1.0.0)
|
word_wrap (~> 1.0.0)
|
||||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||||
xcpretty (~> 0.4.1)
|
xcpretty (~> 0.3.0)
|
||||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
xcpretty-travis-formatter (>= 0.0.3)
|
||||||
fastlane-sirp (1.0.0)
|
|
||||||
sysrandom (~> 1.0)
|
|
||||||
gh_inspector (1.1.3)
|
gh_inspector (1.1.3)
|
||||||
google-apis-androidpublisher_v3 (0.96.0)
|
google-apis-androidpublisher_v3 (0.45.0)
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
google-apis-core (>= 0.11.0, < 2.a)
|
||||||
google-apis-core (0.18.0)
|
google-apis-core (0.11.0)
|
||||||
addressable (~> 2.5, >= 2.5.1)
|
addressable (~> 2.5, >= 2.5.1)
|
||||||
googleauth (~> 1.9)
|
googleauth (>= 0.16.2, < 2.a)
|
||||||
httpclient (>= 2.8.3, < 3.a)
|
httpclient (>= 2.8.1, < 3.a)
|
||||||
mini_mime (~> 1.0)
|
mini_mime (~> 1.0)
|
||||||
mutex_m
|
|
||||||
representable (~> 3.0)
|
representable (~> 3.0)
|
||||||
retriable (>= 2.0, < 4.a)
|
retriable (>= 2.0, < 4.a)
|
||||||
google-apis-iamcredentials_v1 (0.26.0)
|
rexml
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
webrick
|
||||||
google-apis-playcustomapp_v1 (0.17.0)
|
google-apis-iamcredentials_v1 (0.17.0)
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
google-apis-core (>= 0.11.0, < 2.a)
|
||||||
google-apis-storage_v1 (0.60.0)
|
google-apis-playcustomapp_v1 (0.13.0)
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
google-apis-core (>= 0.11.0, < 2.a)
|
||||||
google-cloud-core (1.8.0)
|
google-apis-storage_v1 (0.19.0)
|
||||||
google-cloud-env (>= 1.0, < 3.a)
|
google-apis-core (>= 0.9.0, < 2.a)
|
||||||
|
google-cloud-core (1.6.0)
|
||||||
|
google-cloud-env (~> 1.0)
|
||||||
google-cloud-errors (~> 1.0)
|
google-cloud-errors (~> 1.0)
|
||||||
google-cloud-env (2.1.1)
|
google-cloud-env (1.6.0)
|
||||||
faraday (>= 1.0, < 3.a)
|
faraday (>= 0.17.3, < 3.0)
|
||||||
google-cloud-errors (1.5.0)
|
google-cloud-errors (1.3.1)
|
||||||
google-cloud-storage (1.58.0)
|
google-cloud-storage (1.44.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
digest-crc (~> 0.4)
|
digest-crc (~> 0.4)
|
||||||
google-apis-core (>= 0.18, < 2)
|
google-apis-iamcredentials_v1 (~> 0.1)
|
||||||
google-apis-iamcredentials_v1 (~> 0.18)
|
google-apis-storage_v1 (~> 0.19.0)
|
||||||
google-apis-storage_v1 (>= 0.42)
|
|
||||||
google-cloud-core (~> 1.6)
|
google-cloud-core (~> 1.6)
|
||||||
googleauth (~> 1.9)
|
googleauth (>= 0.16.2, < 2.a)
|
||||||
mini_mime (~> 1.0)
|
mini_mime (~> 1.0)
|
||||||
googleauth (1.11.2)
|
googleauth (1.6.0)
|
||||||
faraday (>= 1.0, < 3.a)
|
faraday (>= 0.17.3, < 3.a)
|
||||||
google-cloud-env (~> 2.1)
|
|
||||||
jwt (>= 1.4, < 3.0)
|
jwt (>= 1.4, < 3.0)
|
||||||
|
memoist (~> 0.16)
|
||||||
multi_json (~> 1.11)
|
multi_json (~> 1.11)
|
||||||
os (>= 0.9, < 2.0)
|
os (>= 0.9, < 2.0)
|
||||||
signet (>= 0.16, < 2.a)
|
signet (>= 0.16, < 2.a)
|
||||||
highline (2.0.3)
|
highline (2.0.3)
|
||||||
http-cookie (1.0.8)
|
http-cookie (1.0.5)
|
||||||
domain_name (~> 0.5)
|
domain_name (~> 0.5)
|
||||||
httpclient (2.9.0)
|
httpclient (2.8.3)
|
||||||
mutex_m
|
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.18.1)
|
json (2.6.3)
|
||||||
jwt (2.10.2)
|
jwt (2.7.1)
|
||||||
base64
|
memoist (0.16.2)
|
||||||
logger (1.7.0)
|
mini_magick (4.12.0)
|
||||||
mini_magick (4.13.2)
|
mini_mime (1.1.2)
|
||||||
mini_mime (1.1.5)
|
multi_json (1.15.0)
|
||||||
multi_json (1.19.1)
|
multipart-post (2.3.0)
|
||||||
multipart-post (2.4.1)
|
nanaimo (0.3.0)
|
||||||
mutex_m (0.3.0)
|
naturally (2.2.1)
|
||||||
nanaimo (0.4.0)
|
optparse (0.1.1)
|
||||||
naturally (2.3.0)
|
|
||||||
nkf (0.2.0)
|
|
||||||
optparse (0.8.1)
|
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
ostruct (0.6.3)
|
plist (3.7.0)
|
||||||
plist (3.7.2)
|
public_suffix (5.0.1)
|
||||||
public_suffix (7.0.2)
|
rake (13.0.6)
|
||||||
rake (13.3.1)
|
|
||||||
representable (3.2.0)
|
representable (3.2.0)
|
||||||
declarative (< 0.1.0)
|
declarative (< 0.1.0)
|
||||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||||
uber (< 0.2.0)
|
uber (< 0.2.0)
|
||||||
retriable (3.2.0)
|
retriable (3.1.2)
|
||||||
rexml (3.4.4)
|
rexml (3.2.5)
|
||||||
rouge (3.28.0)
|
rouge (2.0.7)
|
||||||
ruby2_keywords (0.0.5)
|
ruby2_keywords (0.0.5)
|
||||||
rubyzip (2.4.1)
|
rubyzip (2.3.2)
|
||||||
security (0.1.5)
|
security (0.1.3)
|
||||||
signet (0.21.0)
|
signet (0.17.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
faraday (>= 0.17.5, < 3.a)
|
faraday (>= 0.17.5, < 3.a)
|
||||||
jwt (>= 1.5, < 4.0)
|
jwt (>= 1.5, < 3.0)
|
||||||
multi_json (~> 1.10)
|
multi_json (~> 1.10)
|
||||||
simctl (1.6.10)
|
simctl (1.6.10)
|
||||||
CFPropertyList
|
CFPropertyList
|
||||||
naturally
|
naturally
|
||||||
sysrandom (1.0.5)
|
|
||||||
terminal-notifier (2.0.0)
|
terminal-notifier (2.0.0)
|
||||||
terminal-table (3.0.2)
|
terminal-table (1.8.0)
|
||||||
unicode-display_width (>= 1.1.1, < 3)
|
unicode-display_width (~> 1.1, >= 1.1.1)
|
||||||
trailblazer-option (0.1.2)
|
trailblazer-option (0.1.2)
|
||||||
tty-cursor (0.7.1)
|
tty-cursor (0.7.1)
|
||||||
tty-screen (0.8.2)
|
tty-screen (0.8.1)
|
||||||
tty-spinner (0.9.3)
|
tty-spinner (0.9.3)
|
||||||
tty-cursor (~> 0.7)
|
tty-cursor (~> 0.7)
|
||||||
uber (0.1.0)
|
uber (0.1.0)
|
||||||
unicode-display_width (2.6.0)
|
unf (0.1.4)
|
||||||
|
unf_ext
|
||||||
|
unf_ext (0.0.8.2)
|
||||||
|
unicode-display_width (1.8.0)
|
||||||
|
webrick (1.8.1)
|
||||||
word_wrap (1.0.0)
|
word_wrap (1.0.0)
|
||||||
xcodeproj (1.27.0)
|
xcodeproj (1.22.0)
|
||||||
CFPropertyList (>= 2.3.3, < 4.0)
|
CFPropertyList (>= 2.3.3, < 4.0)
|
||||||
atomos (~> 0.1.3)
|
atomos (~> 0.1.3)
|
||||||
claide (>= 1.0.2, < 2.0)
|
claide (>= 1.0.2, < 2.0)
|
||||||
colored2 (~> 3.1)
|
colored2 (~> 3.1)
|
||||||
nanaimo (~> 0.4.0)
|
nanaimo (~> 0.3.0)
|
||||||
rexml (>= 3.3.6, < 4.0)
|
rexml (~> 3.2.4)
|
||||||
xcpretty (0.4.1)
|
xcpretty (0.3.0)
|
||||||
rouge (~> 3.28.0)
|
rouge (~> 2.0.7)
|
||||||
xcpretty-travis-formatter (1.0.1)
|
xcpretty-travis-formatter (1.0.1)
|
||||||
xcpretty (~> 0.2, >= 0.0.7)
|
xcpretty (~> 0.2, >= 0.0.7)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
universal-darwin-24
|
|
||||||
x86_64-linux
|
x86_64-linux
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
# Companion App for AirPods (CAPod)
|
# Companion App for AirPods (CAPod)
|
||||||
|
|
||||||
[](https://github.com/d4rken-org/capod/releases/latest)
|
[](https://github.com/d4rken-org/capod/releases/latest)
|
||||||
[](https://shields.rbtlog.dev/eu.darken.capod)
|
|
||||||
[](https://github.com/d4rken/capod/actions/workflows/code-checks.yml)
|
[](https://github.com/d4rken/capod/actions/workflows/code-checks.yml)
|
||||||
[](https://crowdin.com/project/capod)
|
[](https://crowdin.com/project/capod)
|
||||||
[](https://github.com/d4rken-org/capod/edit/main/README.md#download)
|
[](https://github.com/d4rken-org/capod/edit/main/README.md#download)
|
||||||
@@ -18,6 +17,7 @@ A companion app that adds support for AirPod specific features to Android:
|
|||||||
* Ear detection with automatic play/pause.
|
* Ear detection with automatic play/pause.
|
||||||
* Automatically connect phone & AirPods.
|
* Automatically connect phone & AirPods.
|
||||||
* Show popup when case is opened.
|
* Show popup when case is opened.
|
||||||
|
* Support for Wear OS
|
||||||
* Widgets
|
* Widgets
|
||||||
|
|
||||||
CAPod is ad-free. Some additional features require an in-app purchase.
|
CAPod is ad-free. Some additional features require an in-app purchase.
|
||||||
@@ -32,7 +32,6 @@ Currently supported models:
|
|||||||
* AirPods Pro 1. Generation
|
* AirPods Pro 1. Generation
|
||||||
* AirPods Pro 2. Generation
|
* AirPods Pro 2. Generation
|
||||||
* AirPods Pro 2. Generation (USB-C)
|
* AirPods Pro 2. Generation (USB-C)
|
||||||
* AirPods Pro 3. Generation
|
|
||||||
* AirPods Max
|
* AirPods Max
|
||||||
* Power Beats Pro
|
* Power Beats Pro
|
||||||
* Power Beats 3
|
* Power Beats 3
|
||||||
@@ -52,12 +51,11 @@ Currently supported models:
|
|||||||
|
|
||||||
| Source | Status |
|
| Source | Status |
|
||||||
|------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod) | [](https://play.google.com/store/apps/details?id=eu.darken.capod) |
|
| [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod) | [](https://play.google.com/store/apps/details?id=eu.darken.capod) |
|
||||||
| [Google Play Beta](https://play.google.com/apps/testing/eu.darken.capod) | [](https://play.google.com/apps/testing/eu.darken.capod) | |
|
| [Google Play Beta](https://play.google.com/apps/testing/eu.darken.capod) | [](https://play.google.com/apps/testing/eu.darken.capod) | |
|
||||||
| [Github (Release)](https://github.com/d4rken-org/capod/releases) | )  |
|
| [Github Releases](https://github.com/d4rken-org/capod/releases) | [](https://github.com/d4rken-org/capod/releases/latest) |
|
||||||
| [Github (Pre-Release)](https://github.com/d4rken-org/capod/releases) | )  |
|
| [F-Droid](https://f-droid.org/en/packages/eu.darken.capod/) | [](https://f-droid.org/en/packages/eu.darken.capod/) |
|
||||||
| [F-Droid](https://f-droid.org/en/packages/eu.darken.capod/) | [](https://f-droid.org/en/packages/eu.darken.capod/) |
|
| [F-Droid (IzzyOnDroid)](https://apt.izzysoft.de/packages/eu.darken.capod/) | [](https://apt.izzysoft.de/packages/eu.darken.capod/) |
|
||||||
| [F-Droid (IzzyOnDroid)](https://apt.izzysoft.de/packages/eu.darken.capod/) | [](https://apt.izzysoft.de/packages/eu.darken.capod/) |
|
|
||||||
|
|
||||||
## Support the project
|
## Support the project
|
||||||
|
|
||||||
|
|||||||
+1
-6
@@ -1,9 +1,6 @@
|
|||||||
theme: minima
|
theme: minima
|
||||||
plugins:
|
plugins:
|
||||||
- jekyll-relative-links
|
- jekyll-relative-links
|
||||||
- jekyll-github-metadata
|
|
||||||
- jemoji
|
|
||||||
|
|
||||||
relative_links:
|
relative_links:
|
||||||
enabled: true
|
enabled: true
|
||||||
collections: true
|
collections: true
|
||||||
@@ -14,8 +11,6 @@ author: "by Matthias Urhahn"
|
|||||||
|
|
||||||
include:
|
include:
|
||||||
- PRIVACY_POLICY.md
|
- PRIVACY_POLICY.md
|
||||||
- README.md
|
|
||||||
- CHANGELOG.md
|
|
||||||
exclude:
|
exclude:
|
||||||
- buildSrc
|
- buildSrc
|
||||||
- gradle/wrapper
|
- gradle/wrapper
|
||||||
@@ -25,4 +20,4 @@ exclude:
|
|||||||
- crowdin*
|
- crowdin*
|
||||||
- app
|
- app
|
||||||
- app-common
|
- app-common
|
||||||
- CONTRIBUTING.md
|
- app-wear
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.library")
|
||||||
|
id("kotlin-android")
|
||||||
|
id("com.google.devtools.ksp")
|
||||||
|
id("kotlin-kapt")
|
||||||
|
id("kotlin-parcelize")
|
||||||
|
}
|
||||||
|
apply(plugin = "dagger.hilt.android.plugin")
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdk = ProjectConfig.compileSdk
|
||||||
|
namespace = "${ProjectConfig.packageName}.common"
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = ProjectConfig.minSdk
|
||||||
|
targetSdk = ProjectConfig.targetSdk
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
consumerProguardFiles("consumer-rules.pro")
|
||||||
|
|
||||||
|
buildConfigField("Long", "VERSION_CODE", "${ProjectConfig.Version.code}L")
|
||||||
|
buildConfigField("String", "VERSION_NAME", "\"${ProjectConfig.Version.name}\"")
|
||||||
|
buildConfigField("String", "APPLICATION_ID", "\"${ProjectConfig.packageName}\"")
|
||||||
|
buildConfigField("String", "GITSHA", "\"${lastCommitHash()}\"")
|
||||||
|
buildConfigField("String", "BUILDTIME", "\"${buildTime()}\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
viewBinding = true
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
freeCompilerArgs = freeCompilerArgs + listOf(
|
||||||
|
"-opt-in=kotlin.ExperimentalStdlibApi",
|
||||||
|
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||||
|
"-opt-in=kotlin.time.ExperimentalTime",
|
||||||
|
"-opt-in=kotlin.ExperimentalUnsignedTypes",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
flavorDimensions.add("version")
|
||||||
|
productFlavors {
|
||||||
|
create("foss") {
|
||||||
|
dimension = "version"
|
||||||
|
}
|
||||||
|
create("gplay") {
|
||||||
|
dimension = "version"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
val customProguardRules = fileTree(File("../proguard")) {
|
||||||
|
include("*.pro")
|
||||||
|
}
|
||||||
|
debug {
|
||||||
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||||
|
proguardFiles(*customProguardRules.toList().toTypedArray())
|
||||||
|
proguardFiles("proguard-rules-debug.pro")
|
||||||
|
}
|
||||||
|
create("beta") {
|
||||||
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||||
|
proguardFiles(*customProguardRules.toList().toTypedArray())
|
||||||
|
}
|
||||||
|
release {
|
||||||
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||||
|
proguardFiles(*customProguardRules.toList().toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests {
|
||||||
|
isIncludeAndroidResources = true
|
||||||
|
}
|
||||||
|
tasks.withType<Test> {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||||
|
|
||||||
|
addBaseAndroid()
|
||||||
|
addBaseAndroidUi()
|
||||||
|
addBaseKotlin()
|
||||||
|
addDagger()
|
||||||
|
addMoshi()
|
||||||
|
addBaseWorkManager()
|
||||||
|
addNavigation()
|
||||||
|
|
||||||
|
addTesting()
|
||||||
|
|
||||||
|
}
|
||||||
Vendored
+21
@@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
+1
@@ -5,6 +5,7 @@ import dagger.Module
|
|||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
|
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
|
||||||
|
import eu.darken.capod.debug.autoreport.FossAutoReporting
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest>
|
||||||
|
|
||||||
|
<application>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
+1
-2
@@ -12,7 +12,6 @@ import eu.darken.capod.common.debug.logging.log
|
|||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import eu.darken.capod.common.datastore.valueBlocking
|
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class GplayAutoReporting @Inject constructor(
|
class GplayAutoReporting @Inject constructor(
|
||||||
@@ -22,7 +21,7 @@ class GplayAutoReporting @Inject constructor(
|
|||||||
) : AutomaticBugReporter {
|
) : AutomaticBugReporter {
|
||||||
|
|
||||||
override fun setup(application: Application) {
|
override fun setup(application: Application) {
|
||||||
val isEnabled = debugSettings.isAutoReportingEnabled.valueBlocking
|
val isEnabled = debugSettings.isAutoReportingEnabled.value
|
||||||
log(TAG) { "setup(): isEnabled=$isEnabled" }
|
log(TAG) { "setup(): isEnabled=$isEnabled" }
|
||||||
|
|
||||||
if (!isEnabled) return
|
if (!isEnabled) return
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="eu.darken.capod.common">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||||
|
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.BLUETOOTH"
|
||||||
|
android:maxSdkVersion="30" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.BLUETOOTH_ADMIN"
|
||||||
|
android:maxSdkVersion="30" />
|
||||||
|
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.ACCESS_COARSE_LOCATION"
|
||||||
|
android:maxSdkVersion="30" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||||
|
android:maxSdkVersion="30" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.BLUETOOTH_SCAN"
|
||||||
|
android:usesPermissionFlags="neverForLocation" />
|
||||||
|
|
||||||
|
<application>
|
||||||
|
<receiver
|
||||||
|
android:name=".bluetooth.BleScanResultReceiver"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="eu.darken.capod.bluetooth.DELIVER_SCAN_RESULTS" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package eu.darken.capod.common
|
||||||
|
|
||||||
|
|
||||||
|
// Can't be const because that prevents them from being mocked in tests
|
||||||
|
@Suppress("MayBeConstant")
|
||||||
|
object BuildConfigWrap {
|
||||||
|
val DEBUG: Boolean = BuildConfig.DEBUG
|
||||||
|
|
||||||
|
val BUILD_TYPE: BuildType = when (val typ = BuildConfig.BUILD_TYPE) {
|
||||||
|
"debug" -> BuildType.DEV
|
||||||
|
"beta" -> BuildType.BETA
|
||||||
|
"release" -> BuildType.RELEASE
|
||||||
|
else -> throw IllegalArgumentException("Unknown buildtype: $typ")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class BuildType {
|
||||||
|
DEV,
|
||||||
|
BETA,
|
||||||
|
RELEASE,
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
val FLAVOR: Flavor = when (val flav = BuildConfig.FLAVOR) {
|
||||||
|
"gplay" -> Flavor.GPLAY
|
||||||
|
"foss" -> Flavor.FOSS
|
||||||
|
else -> throw IllegalStateException("Unknown flavor: $flav")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Flavor {
|
||||||
|
GPLAY,
|
||||||
|
FOSS,
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
val APPLICATION_ID: String = BuildConfig.APPLICATION_ID
|
||||||
|
|
||||||
|
val VERSION_CODE: Long = BuildConfig.VERSION_CODE.toLong()
|
||||||
|
val VERSION_NAME: String = BuildConfig.VERSION_NAME
|
||||||
|
val GIT_SHA: String = BuildConfig.GITSHA
|
||||||
|
val BUILDTIME: String = BuildConfig.BUILDTIME
|
||||||
|
|
||||||
|
val VERSION_DESCRIPTION_LONG: String = "v$VERSION_NAME ($VERSION_CODE) [$GIT_SHA] ${FLAVOR}_$BUILD_TYPE"
|
||||||
|
val VERSION_DESCRIPTION_SHORT: String = "v$VERSION_NAME [$GIT_SHA] $FLAVOR"
|
||||||
|
val VERSION_DESCRIPTION_TINY: String = "v$VERSION_NAME"
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package eu.darken.capod.common
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.res.TypedArray
|
||||||
|
import androidx.annotation.AttrRes
|
||||||
|
import androidx.annotation.ColorInt
|
||||||
|
import androidx.annotation.ColorRes
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
|
||||||
|
|
||||||
|
@ColorInt
|
||||||
|
fun Context.getColorForAttr(@AttrRes attrId: Int): Int {
|
||||||
|
var typedArray: TypedArray? = null
|
||||||
|
try {
|
||||||
|
typedArray = this.theme.obtainStyledAttributes(intArrayOf(attrId))
|
||||||
|
return typedArray.getColor(0, 0)
|
||||||
|
} finally {
|
||||||
|
typedArray?.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ColorInt
|
||||||
|
fun Fragment.getColorForAttr(@AttrRes attrId: Int): Int = requireContext().getColorForAttr(attrId)
|
||||||
|
|
||||||
|
@ColorInt
|
||||||
|
fun Context.getCompatColor(@ColorRes attrId: Int): Int {
|
||||||
|
return ContextCompat.getColor(this, attrId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ColorInt
|
||||||
|
fun Fragment.getCompatColor(@ColorRes attrId: Int): Int = requireContext().getCompatColor(attrId)
|
||||||
|
|
||||||
|
@SuppressLint("NewApi")
|
||||||
|
fun Context.startServiceCompat(intent: Intent): ComponentName? {
|
||||||
|
return if (hasApiLevel(26)) startForegroundService(intent) else startService(intent)
|
||||||
|
}
|
||||||
+1
-1
@@ -5,7 +5,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.UUID
|
import java.util.*
|
||||||
import java.util.regex.Pattern
|
import java.util.regex.Pattern
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
+8
-10
@@ -3,21 +3,19 @@ package eu.darken.capod.common.bluetooth
|
|||||||
import android.bluetooth.le.ScanResult
|
import android.bluetooth.le.ScanResult
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.core.util.forEach
|
import androidx.core.util.forEach
|
||||||
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
|
import com.squareup.moshi.Json
|
||||||
import eu.darken.capod.common.serialization.MapIntByteArrayBase64Serializer
|
import com.squareup.moshi.JsonClass
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
@Serializable
|
@JsonClass(generateAdapter = true)
|
||||||
data class BleScanResult(
|
data class BleScanResult(
|
||||||
@SerialName("receivedAt") @Serializable(with = InstantEpochMillisSerializer::class) val receivedAt: Instant,
|
@Json(name = "receivedAt") val receivedAt: Instant,
|
||||||
@SerialName("address") val address: String,
|
@Json(name = "address") val address: String,
|
||||||
@SerialName("rssi") val rssi: Int,
|
@Json(name = "rssi") val rssi: Int,
|
||||||
@SerialName("generatedAtNanos") val generatedAtNanos: Long,
|
@Json(name = "generatedAtNanos") val generatedAtNanos: Long,
|
||||||
@SerialName("manufacturerSpecificData") @Serializable(with = MapIntByteArrayBase64Serializer::class) val manufacturerSpecificData: Map<Int, ByteArray>
|
@Json(name = "manufacturerSpecificData") val manufacturerSpecificData: Map<Int, ByteArray>
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
|
|
||||||
fun getManufacturerSpecificData(id: Int): ByteArray? = manufacturerSpecificData[id]
|
fun getManufacturerSpecificData(id: Int): ByteArray? = manufacturerSpecificData[id]
|
||||||
+34
-131
@@ -12,8 +12,8 @@ import android.content.Intent
|
|||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.HandlerThread
|
import android.os.HandlerThread
|
||||||
|
import android.os.ParcelUuid
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import eu.darken.capod.common.coroutine.AppScope
|
|
||||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||||
import eu.darken.capod.common.debug.Bugs
|
import eu.darken.capod.common.debug.Bugs
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||||
@@ -21,38 +21,27 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
|||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
|
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.retryWhen
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.plus
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import java.io.IOException
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class BluetoothManager2 @Inject constructor(
|
class BluetoothManager2 @Inject constructor(
|
||||||
@AppScope private val appScope: CoroutineScope,
|
|
||||||
private val dispatcherProvider: DispatcherProvider,
|
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
private val manager: BluetoothManager,
|
private val manager: BluetoothManager,
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val dispatcherProvider: DispatcherProvider,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val adapter: BluetoothAdapter?
|
val adapter: BluetoothAdapter?
|
||||||
@@ -100,7 +89,7 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
|
|
||||||
override fun onServiceDisconnected(profile: Int) {
|
override fun onServiceDisconnected(profile: Int) {
|
||||||
log(TAG, WARN) { "onServiceDisconnected(profile=$profile)" }
|
log(TAG, WARN) { "onServiceDisconnected(profile=$profile)" }
|
||||||
close() // Close gracefully without exception to prevent crash
|
close(IOException("BluetoothProfile service disconnected (profile=$profile)"))
|
||||||
}
|
}
|
||||||
|
|
||||||
}, profile)
|
}, profile)
|
||||||
@@ -114,118 +103,59 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun monitorProfile(
|
private fun monitorDevicesForProfile(
|
||||||
profile: Int = BluetoothProfile.HEADSET
|
profile: Int = BluetoothProfile.HEADSET
|
||||||
): Flow<Set<BluetoothDevice>> = getBluetoothProfile(profile).flatMapLatest { bluetoothProfile ->
|
): Flow<Set<BluetoothDevice>> = getBluetoothProfile(profile).flatMapLatest { bluetoothProfile ->
|
||||||
callbackFlow {
|
callbackFlow {
|
||||||
log(TAG, VERBOSE) { "monitorProfile(): for profile=$profile starting" }
|
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) starting" }
|
||||||
|
trySend(bluetoothProfile.connectedDevices)
|
||||||
try {
|
|
||||||
trySend(bluetoothProfile.connectedDevices)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log(TAG, ERROR) { "monitorProfile(): Error querying initial connected devices: $e" }
|
|
||||||
close(e)
|
|
||||||
return@callbackFlow
|
|
||||||
}
|
|
||||||
|
|
||||||
val filter = IntentFilter().apply {
|
val filter = IntentFilter().apply {
|
||||||
addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)
|
addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
|
||||||
|
addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
|
||||||
}
|
}
|
||||||
|
|
||||||
val handlerThread = HandlerThread("BluetoothEventReceiver").apply { start() }
|
val handlerThread = HandlerThread("BluetoothEventReceiver").apply {
|
||||||
|
start()
|
||||||
|
}
|
||||||
val handler = Handler(handlerThread.looper)
|
val handler = Handler(handlerThread.looper)
|
||||||
|
|
||||||
val receiver: BroadcastReceiver = object : BroadcastReceiver() {
|
val receiver: BroadcastReceiver = object : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
log(TAG, VERBOSE) { "monitorProfile(): Bluetooth event (intent=$intent, extras=${intent.extras})" }
|
log(TAG, VERBOSE) { "Bluetooth event (intent=$intent, extras=${intent.extras})" }
|
||||||
|
val action = intent.action
|
||||||
if (intent.action == null) {
|
if (action == null) {
|
||||||
log(TAG, ERROR) { "monitorProfile(): Bluetooth event without action?" }
|
log(TAG, ERROR) { "Bluetooth event without action, how did we get this?" }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val device = intent.getParcelableExtra<BluetoothDevice?>(BluetoothDevice.EXTRA_DEVICE)
|
val device = intent.getParcelableExtra<BluetoothDevice?>(BluetoothDevice.EXTRA_DEVICE)
|
||||||
if (device == null) {
|
if (device == null) {
|
||||||
log(TAG, ERROR) { "monitorProfile(): Event is missing EXTRA_DEVICE" }
|
log(TAG, ERROR) { "Connection event is missing EXTRA_DEVICE: ${intent.extras}" }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this@callbackFlow.launch {
|
this@callbackFlow.launch {
|
||||||
if (intent.action != BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) {
|
val currentDevices = bluetoothProfile.connectedDevices
|
||||||
log(TAG, WARN) { "Unknown action: ${intent.action}" }
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
// Profile connection changed - query actual state from proxy
|
when (action) {
|
||||||
val statePrevious = intent.getIntExtra(BluetoothProfile.EXTRA_PREVIOUS_STATE, -1)
|
BluetoothDevice.ACTION_ACL_CONNECTED -> {
|
||||||
log(TAG) { "monitorProfile(): HEADSET profile state changed for $device - previous: $statePrevious" }
|
log(TAG) { "Adding $device to current devices $currentDevices" }
|
||||||
|
trySend(currentDevices.plus(device))
|
||||||
val stateNow = intent.getIntExtra(BluetoothProfile.EXTRA_STATE, -1)
|
|
||||||
log(TAG) { "monitorProfile(): HEADSET profile state changed for $device - now: $stateNow" }
|
|
||||||
|
|
||||||
val currentDevices = try {
|
|
||||||
bluetoothProfile.connectedDevices
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log(TAG, ERROR) { "monitorProfile(): Error handling profile event: $e" }
|
|
||||||
// Log but continue - don't kill the whole Flow for one bad event
|
|
||||||
emptySet()
|
|
||||||
}.toMutableSet()
|
|
||||||
log(TAG) { "monitorProfile(): currentDevices: $currentDevices" }
|
|
||||||
|
|
||||||
when (stateNow) {
|
|
||||||
BluetoothProfile.STATE_CONNECTING -> {
|
|
||||||
log(TAG) { "monitorProfile(): Currently connecting $device" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
BluetoothProfile.STATE_CONNECTED -> {
|
BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
|
||||||
log(TAG) { "monitorProfile(): Device has connected $device" }
|
log(TAG) { "Removing $device from current devices $currentDevices" }
|
||||||
if (!currentDevices.contains(device)) {
|
trySend(currentDevices.minus(device))
|
||||||
log(
|
|
||||||
TAG,
|
|
||||||
VERBOSE
|
|
||||||
) { "monitorProfile(): $device not in proxy yet, adding manually" }
|
|
||||||
currentDevices.add(device)
|
|
||||||
}
|
|
||||||
trySend(currentDevices)
|
|
||||||
}
|
|
||||||
|
|
||||||
BluetoothProfile.STATE_DISCONNECTING -> {
|
|
||||||
log(TAG) { "monitorProfile(): Currently DISconnecting $device" }
|
|
||||||
}
|
|
||||||
|
|
||||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
|
||||||
log(TAG) { "monitorProfile(): Device has disconnected $device" }
|
|
||||||
if (currentDevices.contains(device)) {
|
|
||||||
log(
|
|
||||||
TAG,
|
|
||||||
VERBOSE
|
|
||||||
) { "monitorProfile(): $device still in proxy, removing manually" }
|
|
||||||
currentDevices.remove(device)
|
|
||||||
}
|
|
||||||
trySend(currentDevices)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
context.registerReceiver(receiver, filter, null, handler)
|
||||||
try {
|
|
||||||
context.registerReceiver(receiver, filter, null, handler)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log(TAG, ERROR) { "monitorProfile(): Failed to register receiver: $e" }
|
|
||||||
handlerThread.quitSafely()
|
|
||||||
close(e)
|
|
||||||
return@callbackFlow
|
|
||||||
}
|
|
||||||
|
|
||||||
awaitClose {
|
awaitClose {
|
||||||
log(TAG, VERBOSE) { "monitorProfile(): profile=$profile closed." }
|
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) closed." }
|
||||||
try {
|
context.unregisterReceiver(receiver)
|
||||||
context.unregisterReceiver(receiver)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log(TAG, ERROR) { "monitorProfile(): Error unregistering receiver: $e" }
|
|
||||||
} finally {
|
|
||||||
handlerThread.quitSafely()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,11 +163,10 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
private val seenDevicesLock = Mutex()
|
private val seenDevicesLock = Mutex()
|
||||||
private val seenDevicesCache = mutableMapOf<String, Instant>()
|
private val seenDevicesCache = mutableMapOf<String, Instant>()
|
||||||
|
|
||||||
val connectedDevices: Flow<List<BluetoothDevice2>> = isBluetoothEnabled
|
fun connectedDevices(
|
||||||
.flatMapLatest { enabled ->
|
featureFilter: Set<ParcelUuid> = ContinuityProtocol.BLE_FEATURE_UUIDS
|
||||||
if (enabled) monitorProfile(BluetoothProfile.HEADSET)
|
): Flow<List<BluetoothDevice2>> = isBluetoothEnabled
|
||||||
else flowOf(emptySet()) // Return empty when Bluetooth is off
|
.flatMapLatest { monitorDevicesForProfile(BluetoothProfile.HEADSET) }
|
||||||
}
|
|
||||||
.map { devices ->
|
.map { devices ->
|
||||||
val currentAddresses = devices.map { it.address }
|
val currentAddresses = devices.map { it.address }
|
||||||
|
|
||||||
@@ -248,9 +177,7 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
devices
|
devices
|
||||||
.filter { device ->
|
.filter { device -> featureFilter.any { feature -> device.hasFeature(feature) } }
|
||||||
ContinuityProtocol.BLE_FEATURE_UUIDS.any { feature -> device.hasFeature(feature) }
|
|
||||||
}
|
|
||||||
.map { device ->
|
.map { device ->
|
||||||
BluetoothDevice2(
|
BluetoothDevice2(
|
||||||
internal = device,
|
internal = device,
|
||||||
@@ -262,30 +189,6 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.retryWhen { cause, attempt ->
|
|
||||||
log(TAG, WARN) { "connectedDevices Flow failed (attempt ${attempt + 1}): $cause" }
|
|
||||||
if (attempt < 3) {
|
|
||||||
delay(1000 * (attempt + 1)) // 1s, 2s, 3s exponential backoff
|
|
||||||
true // Retry
|
|
||||||
} else {
|
|
||||||
false // Give up after 3 attempts
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.catch { e ->
|
|
||||||
log(TAG, ERROR) { "connectedDevices Flow failed after retries: $e" }
|
|
||||||
emit(emptyList()) // Emit empty list and complete gracefully
|
|
||||||
}
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.setupCommonEventHandlers(TAG) { "connectedDevices" }
|
|
||||||
.stateIn(
|
|
||||||
scope = appScope + dispatcherProvider.IO,
|
|
||||||
started = SharingStarted.WhileSubscribed(
|
|
||||||
stopTimeoutMillis = 5_000L,
|
|
||||||
replayExpirationMillis = 0L,
|
|
||||||
),
|
|
||||||
initialValue = null
|
|
||||||
)
|
|
||||||
.filterNotNull()
|
|
||||||
|
|
||||||
fun bondedDevices(): Flow<Set<BluetoothDevice2>> = flow {
|
fun bondedDevices(): Flow<Set<BluetoothDevice2>> = flow {
|
||||||
val rawDevices = adapter?.bondedDevices ?: throw IllegalStateException("Bluetooth adapter unavailable")
|
val rawDevices = adapter?.bondedDevices ?: throw IllegalStateException("Bluetooth adapter unavailable")
|
||||||
+1
-2
@@ -7,7 +7,6 @@ import eu.darken.capod.common.fromHex
|
|||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
import eu.darken.capod.common.datastore.valueBlocking
|
|
||||||
|
|
||||||
@Reusable
|
@Reusable
|
||||||
class FakeBleData @Inject constructor(
|
class FakeBleData @Inject constructor(
|
||||||
@@ -15,7 +14,7 @@ class FakeBleData @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
|
|
||||||
fun maybeAddfakeData(originals: Collection<BleScanResult>): Collection<BleScanResult> {
|
fun maybeAddfakeData(originals: Collection<BleScanResult>): Collection<BleScanResult> {
|
||||||
if (!debugSettings.showFakeData.valueBlocking) return originals
|
if (!debugSettings.showFakeData.value) return originals
|
||||||
return originals + getFakeData()
|
return originals + getFakeData()
|
||||||
}
|
}
|
||||||
|
|
||||||
+7
-7
@@ -1,24 +1,24 @@
|
|||||||
package eu.darken.capod.common.bluetooth
|
package eu.darken.capod.common.bluetooth
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import eu.darken.capod.R
|
import com.squareup.moshi.Json
|
||||||
import kotlinx.serialization.SerialName
|
import com.squareup.moshi.JsonClass
|
||||||
import kotlinx.serialization.Serializable
|
import eu.darken.capod.common.R
|
||||||
|
|
||||||
@Serializable
|
@JsonClass(generateAdapter = false)
|
||||||
enum class ScannerMode(
|
enum class ScannerMode(
|
||||||
val identifier: String,
|
val identifier: String,
|
||||||
@StringRes val labelRes: Int
|
@StringRes val labelRes: Int
|
||||||
) {
|
) {
|
||||||
@SerialName("scanner.mode.lowpower") LOW_POWER(
|
@Json(name = "scanner.mode.lowpower") LOW_POWER(
|
||||||
"scanner.mode.lowpower",
|
"scanner.mode.lowpower",
|
||||||
R.string.settings_scanner_mode_lowpower_label
|
R.string.settings_scanner_mode_lowpower_label
|
||||||
),
|
),
|
||||||
@SerialName("scanner.mode.balanced") BALANCED(
|
@Json(name = "scanner.mode.balanced") BALANCED(
|
||||||
"scanner.mode.balanced",
|
"scanner.mode.balanced",
|
||||||
R.string.settings_scanner_mode_balanced_label
|
R.string.settings_scanner_mode_balanced_label
|
||||||
),
|
),
|
||||||
@SerialName("scanner.mode.lowlatency") LOW_LATENCY(
|
@Json(name = "scanner.mode.lowlatency") LOW_LATENCY(
|
||||||
"scanner.mode.lowlatency",
|
"scanner.mode.lowlatency",
|
||||||
R.string.settings_scanner_mode_lowlatency_label
|
R.string.settings_scanner_mode_lowlatency_label
|
||||||
),
|
),
|
||||||
+6
@@ -5,6 +5,7 @@ import android.app.NotificationManager
|
|||||||
import android.bluetooth.BluetoothManager
|
import android.bluetooth.BluetoothManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
|
import androidx.work.WorkManager
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
@@ -29,6 +30,11 @@ class AndroidModule {
|
|||||||
fun bluetoothManager(context: Context): BluetoothManager =
|
fun bluetoothManager(context: Context): BluetoothManager =
|
||||||
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun workerManager(context: Context): WorkManager =
|
||||||
|
WorkManager.getInstance(context)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun audioManager(context: Context): AudioManager =
|
fun audioManager(context: Context): AudioManager =
|
||||||
+1
-3
@@ -1,9 +1,7 @@
|
|||||||
package eu.darken.capod.common.debug
|
package eu.darken.capod.common.debug
|
||||||
|
|
||||||
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
|
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
import eu.darken.capod.common.debug.logging.Logging.Priority.*
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
|
||||||
import eu.darken.capod.common.debug.logging.asLog
|
import eu.darken.capod.common.debug.logging.asLog
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package eu.darken.capod.common.debug
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.preference.PreferenceDataStore
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import eu.darken.capod.common.BuildConfigWrap
|
||||||
|
import eu.darken.capod.common.preferences.PreferenceStoreMapper
|
||||||
|
import eu.darken.capod.common.preferences.Settings
|
||||||
|
import eu.darken.capod.common.preferences.createFlowPreference
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class DebugSettings @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : Settings() {
|
||||||
|
|
||||||
|
override val preferences: SharedPreferences = context.getSharedPreferences("settings_debug", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
val isAutoReportingEnabled = preferences.createFlowPreference(
|
||||||
|
key = "debug.bugreport.automatic.enabled",
|
||||||
|
// Reporting is opt-out for gplay, and opt-in for github builds
|
||||||
|
defaultValue = BuildConfigWrap.FLAVOR == BuildConfigWrap.Flavor.GPLAY
|
||||||
|
)
|
||||||
|
val isDebugModeEnabled = preferences.createFlowPreference("debug.mode.enabled", false)
|
||||||
|
|
||||||
|
val showFakeData = preferences.createFlowPreference("debug.fakedata.enabled", false)
|
||||||
|
|
||||||
|
val showUnfiltered = preferences.createFlowPreference("debug.blescanner.unfiltered.enabled", false)
|
||||||
|
|
||||||
|
override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper(
|
||||||
|
isDebugModeEnabled,
|
||||||
|
showFakeData,
|
||||||
|
showUnfiltered,
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package eu.darken.capod.common.error
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
|
||||||
|
fun Throwable.asErrorDialogBuilder(
|
||||||
|
context: Context
|
||||||
|
) = MaterialAlertDialogBuilder(context).apply {
|
||||||
|
val error = this@asErrorDialogBuilder
|
||||||
|
val localizedError = error.localized(context)
|
||||||
|
|
||||||
|
setTitle(localizedError.label)
|
||||||
|
setMessage(localizedError.description)
|
||||||
|
|
||||||
|
setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
package eu.darken.capod.common.error
|
package eu.darken.capod.common.error
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.common.R
|
||||||
|
|
||||||
interface HasLocalizedError {
|
interface HasLocalizedError {
|
||||||
fun getLocalizedError(context: Context): LocalizedError
|
fun getLocalizedError(context: Context): LocalizedError
|
||||||
+1
-10
@@ -6,16 +6,7 @@ import eu.darken.capod.common.debug.logging.log
|
|||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.channelFlow
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.onCompletion
|
|
||||||
import kotlinx.coroutines.flow.onStart
|
|
||||||
import kotlinx.coroutines.flow.shareIn
|
|
||||||
import kotlinx.coroutines.plus
|
import kotlinx.coroutines.plus
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
-21
@@ -36,27 +36,6 @@ inline fun <T1, T2, T3, R> combine(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST", "LongParameterList")
|
|
||||||
inline fun <T1, T2, T3, T4, R> combine(
|
|
||||||
flow: Flow<T1>,
|
|
||||||
flow2: Flow<T2>,
|
|
||||||
flow3: Flow<T3>,
|
|
||||||
flow4: Flow<T4>,
|
|
||||||
crossinline transform: suspend (T1, T2, T3, T4) -> R
|
|
||||||
): Flow<R> = kotlinx.coroutines.flow.combine(
|
|
||||||
flow,
|
|
||||||
flow2,
|
|
||||||
flow3,
|
|
||||||
flow4,
|
|
||||||
) { args: Array<*> ->
|
|
||||||
transform(
|
|
||||||
args[0] as T1,
|
|
||||||
args[1] as T2,
|
|
||||||
args[2] as T3,
|
|
||||||
args[3] as T4,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST", "LongParameterList")
|
@Suppress("UNCHECKED_CAST", "LongParameterList")
|
||||||
inline fun <T1, T2, T3, T4, T5, R> combine(
|
inline fun <T1, T2, T3, T4, T5, R> combine(
|
||||||
flow: Flow<T1>,
|
flow: Flow<T1>,
|
||||||
+24
-15
@@ -8,23 +8,32 @@ import eu.darken.capod.common.error.hasCause
|
|||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.WhileSubscribed
|
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.conflate
|
|
||||||
import kotlinx.coroutines.flow.drop
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.onCompletion
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.flow.onStart
|
|
||||||
import kotlinx.coroutines.flow.scan
|
|
||||||
import kotlinx.coroutines.flow.shareIn
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.transform
|
|
||||||
import kotlinx.coroutines.flow.transformWhile
|
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a stateful flow, with the initial value of null, but never emits a null value.
|
||||||
|
* Helper method to create a new flow without suspending and without initial value
|
||||||
|
* The flow collector will just wait for the first value
|
||||||
|
*/
|
||||||
|
fun <T : Any> Flow<T>.shareLatest(
|
||||||
|
tag: String? = null,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
started: SharingStarted = SharingStarted.WhileSubscribed(replayExpirationMillis = 0)
|
||||||
|
) = this
|
||||||
|
.onStart { if (tag != null) log(tag) { "shareLatest(...) start" } }
|
||||||
|
.onEach { if (tag != null) log(tag) { "shareLatest(...) emission: $it" } }
|
||||||
|
.onCompletion { if (tag != null) log(tag) { "shareLatest(...) completed." } }
|
||||||
|
.catch {
|
||||||
|
if (tag != null) log(tag) { "shareLatest(...) catch(): ${it.asLog()}" }
|
||||||
|
throw it
|
||||||
|
}
|
||||||
|
.stateIn(
|
||||||
|
scope = scope,
|
||||||
|
started = started,
|
||||||
|
initialValue = null
|
||||||
|
)
|
||||||
|
.filterNotNull()
|
||||||
|
|
||||||
fun <T : Any?> Flow<T>.replayingShare(scope: CoroutineScope) = this.shareIn(
|
fun <T : Any?> Flow<T>.replayingShare(scope: CoroutineScope) = this.shareIn(
|
||||||
scope = scope,
|
scope = scope,
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package eu.darken.capod.common.lists
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.Resources
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.annotation.*
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import eu.darken.capod.common.getColorForAttr
|
||||||
|
|
||||||
|
abstract class BaseAdapter<T : BaseAdapter.VH> : RecyclerView.Adapter<T>() {
|
||||||
|
|
||||||
|
@CallSuper
|
||||||
|
final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): T {
|
||||||
|
return onCreateBaseVH(parent, viewType)
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract fun onCreateBaseVH(parent: ViewGroup, viewType: Int): T
|
||||||
|
|
||||||
|
@CallSuper
|
||||||
|
final override fun onBindViewHolder(holder: T, position: Int) {
|
||||||
|
onBindBaseVH(holder, position, mutableListOf())
|
||||||
|
}
|
||||||
|
|
||||||
|
@CallSuper
|
||||||
|
final override fun onBindViewHolder(holder: T, position: Int, payloads: MutableList<Any>) {
|
||||||
|
onBindBaseVH(holder, position, payloads)
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract fun onBindBaseVH(holder: T, position: Int, payloads: MutableList<Any> = mutableListOf())
|
||||||
|
|
||||||
|
abstract class VH(@LayoutRes layoutRes: Int, private val parent: ViewGroup) : RecyclerView.ViewHolder(
|
||||||
|
LayoutInflater.from(parent.context).inflate(layoutRes, parent, false)
|
||||||
|
) {
|
||||||
|
|
||||||
|
val context: Context
|
||||||
|
get() = parent.context
|
||||||
|
|
||||||
|
val resources: Resources
|
||||||
|
get() = context.resources
|
||||||
|
|
||||||
|
val layoutInflater: LayoutInflater
|
||||||
|
get() = LayoutInflater.from(context)
|
||||||
|
|
||||||
|
fun getColor(@ColorRes colorRes: Int): Int = ContextCompat.getColor(context, colorRes)
|
||||||
|
|
||||||
|
fun getColorForAttr(@AttrRes attrRes: Int): Int = context.getColorForAttr(attrRes)
|
||||||
|
|
||||||
|
fun getString(@StringRes stringRes: Int, vararg args: Any): String = context.getString(stringRes, *args)
|
||||||
|
|
||||||
|
fun getQuantityString(@PluralsRes pluralRes: Int, quantity: Int, vararg args: Any): String =
|
||||||
|
context.resources.getQuantityString(pluralRes, quantity, *args)
|
||||||
|
|
||||||
|
fun getQuantityString(@PluralsRes pluralRes: Int, quantity: Int): String =
|
||||||
|
context.resources.getQuantityString(pluralRes, quantity, quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package eu.darken.capod.common.lists
|
||||||
|
|
||||||
|
import androidx.viewbinding.ViewBinding
|
||||||
|
|
||||||
|
interface BindableVH<ItemT, ViewBindingT : ViewBinding> {
|
||||||
|
|
||||||
|
val viewBinding: Lazy<ViewBindingT>
|
||||||
|
|
||||||
|
val onBindData: ViewBindingT.(item: ItemT, payloads: List<Any>) -> Unit
|
||||||
|
|
||||||
|
fun bind(item: ItemT, payloads: MutableList<Any> = mutableListOf()) = with(viewBinding.value) {
|
||||||
|
onBindData(item, payloads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
inline fun <reified ItemT, ViewBindingT : ViewBinding> BindableVH<ItemT, ViewBindingT>.binding(
|
||||||
|
payload: Boolean = true,
|
||||||
|
crossinline block: ViewBindingT.(ItemT) -> Unit,
|
||||||
|
): ViewBindingT.(item: ItemT, payloads: List<Any>) -> Unit = { item: ItemT, payloads: List<Any> ->
|
||||||
|
val newestItem = when (payload) {
|
||||||
|
true -> payloads.filterIsInstance<ItemT>().lastOrNull() ?: item
|
||||||
|
false -> item
|
||||||
|
}
|
||||||
|
block(newestItem)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package eu.darken.capod.common.lists
|
||||||
|
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
|
||||||
|
interface DataAdapter<T> {
|
||||||
|
val data: MutableList<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <X, T> X.update(newData: List<T>?, notify: Boolean = true) where X : DataAdapter<T>, X : RecyclerView.Adapter<*> {
|
||||||
|
data.clear()
|
||||||
|
if (newData != null) data.addAll(newData)
|
||||||
|
if (notify) notifyDataSetChanged()
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package eu.darken.capod.common.lists
|
||||||
|
|
||||||
|
interface ListItem
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package eu.darken.capod.common.lists
|
||||||
|
|
||||||
|
import androidx.recyclerview.widget.DefaultItemAnimator
|
||||||
|
import androidx.recyclerview.widget.DividerItemDecoration
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
|
||||||
|
fun RecyclerView.setupDefaults(adapter: RecyclerView.Adapter<*>? = null, dividers: Boolean = true) = apply {
|
||||||
|
layoutManager = LinearLayoutManager(context)
|
||||||
|
itemAnimator = DefaultItemAnimator()
|
||||||
|
if (dividers) addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
|
||||||
|
if (adapter != null) this.adapter = adapter
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package eu.darken.capod.common.lists.differ
|
||||||
|
|
||||||
|
import androidx.recyclerview.widget.AsyncListDiffer
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
import eu.darken.capod.common.lists.modular.ModularAdapter
|
||||||
|
import eu.darken.capod.common.lists.modular.mods.StableIdMod
|
||||||
|
|
||||||
|
class AsyncDiffer<A, T : DifferItem> internal constructor(
|
||||||
|
adapter: A,
|
||||||
|
compareItem: (T, T) -> Boolean = { i1, i2 -> i1.stableId == i2.stableId },
|
||||||
|
compareItemContent: (T, T) -> Boolean = { i1, i2 -> i1 == i2 },
|
||||||
|
determinePayload: (T, T) -> Any? = { i1, i2 ->
|
||||||
|
when {
|
||||||
|
i1::class == i2::class -> i1.payloadProvider?.invoke(i1, i2)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) where A : HasAsyncDiffer<T>, A : ModularAdapter<*> {
|
||||||
|
private val callback = object : DiffUtil.ItemCallback<T>() {
|
||||||
|
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = compareItem(oldItem, newItem)
|
||||||
|
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = compareItemContent(oldItem, newItem)
|
||||||
|
override fun getChangePayload(oldItem: T, newItem: T): Any? = determinePayload(oldItem, newItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val internalList = mutableListOf<T>()
|
||||||
|
private val listDiffer = AsyncListDiffer(adapter, callback)
|
||||||
|
|
||||||
|
val currentList: List<T>
|
||||||
|
get() = synchronized(internalList) { internalList }
|
||||||
|
|
||||||
|
init {
|
||||||
|
adapter.modules.add(0, StableIdMod(currentList))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun submitUpdate(newData: List<T>) {
|
||||||
|
listDiffer.submitList(newData) {
|
||||||
|
synchronized(internalList) {
|
||||||
|
internalList.clear()
|
||||||
|
internalList.addAll(newData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package eu.darken.capod.common.lists.differ
|
||||||
|
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import eu.darken.capod.common.lists.modular.ModularAdapter
|
||||||
|
|
||||||
|
|
||||||
|
fun <X, T> X.update(newData: List<T>?)
|
||||||
|
where X : HasAsyncDiffer<T>, X : RecyclerView.Adapter<*> {
|
||||||
|
|
||||||
|
asyncDiffer.submitUpdate(newData ?: emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <A, T : DifferItem> A.setupDiffer(): AsyncDiffer<A, T>
|
||||||
|
where A : HasAsyncDiffer<T>, A : ModularAdapter<*> =
|
||||||
|
AsyncDiffer(this)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package eu.darken.capod.common.lists.differ
|
||||||
|
|
||||||
|
import eu.darken.capod.common.lists.ListItem
|
||||||
|
|
||||||
|
interface DifferItem : ListItem {
|
||||||
|
val stableId: Long
|
||||||
|
|
||||||
|
val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)?
|
||||||
|
get() = null
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package eu.darken.capod.common.lists.differ
|
||||||
|
|
||||||
|
interface HasAsyncDiffer<T : DifferItem> {
|
||||||
|
|
||||||
|
val data: List<T>
|
||||||
|
get() = asyncDiffer.currentList
|
||||||
|
|
||||||
|
val asyncDiffer: AsyncDiffer<*, T>
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package eu.darken.capod.common.lists.modular
|
||||||
|
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.annotation.CallSuper
|
||||||
|
import androidx.annotation.LayoutRes
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import eu.darken.capod.common.lists.BaseAdapter
|
||||||
|
|
||||||
|
abstract class ModularAdapter<VH : ModularAdapter.VH> : BaseAdapter<VH>() {
|
||||||
|
val modules = mutableListOf<Module>()
|
||||||
|
|
||||||
|
init {
|
||||||
|
modules.filterIsInstance<Module.Setup>().forEach { it.onAdapterReady(this) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemId(position: Int): Long {
|
||||||
|
modules.filterIsInstance<Module.ItemId>().forEach {
|
||||||
|
val id = it.getItemId(this, position)
|
||||||
|
if (id != null) return id
|
||||||
|
}
|
||||||
|
return super.getItemId(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
@CallSuper
|
||||||
|
override fun getItemViewType(position: Int): Int {
|
||||||
|
modules.filterIsInstance<Module.Typing>().forEach {
|
||||||
|
val type = it.onGetItemType(this, position)
|
||||||
|
if (type != null) return type
|
||||||
|
}
|
||||||
|
return super.getItemViewType(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateBaseVH(parent: ViewGroup, viewType: Int): VH {
|
||||||
|
modules.filterIsInstance<Module.Creator<VH>>().forEach {
|
||||||
|
val vh = it.onCreateModularVH(this, parent, viewType)
|
||||||
|
if (vh != null) return vh
|
||||||
|
}
|
||||||
|
throw IllegalStateException("Couldn't create VH for type $viewType with $parent")
|
||||||
|
}
|
||||||
|
|
||||||
|
@CallSuper
|
||||||
|
override fun onBindBaseVH(holder: VH, position: Int, payloads: MutableList<Any>) {
|
||||||
|
modules.filterIsInstance<Module.Binder<VH>>().forEach {
|
||||||
|
it.onBindModularVH(this, holder, position, payloads)
|
||||||
|
it.onPostBind(this, holder, position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@CallSuper
|
||||||
|
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
|
||||||
|
modules.filterIsInstance<Module.RecyclerViewLifecycle>().forEach { it.onAttachedToRecyclerView(recyclerView) }
|
||||||
|
super.onAttachedToRecyclerView(recyclerView)
|
||||||
|
}
|
||||||
|
|
||||||
|
@CallSuper
|
||||||
|
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
|
||||||
|
modules.filterIsInstance<Module.RecyclerViewLifecycle>().forEach { it.onDetachedFromRecyclerView(recyclerView) }
|
||||||
|
super.onDetachedFromRecyclerView(recyclerView)
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class VH(@LayoutRes layoutRes: Int, parent: ViewGroup) : BaseAdapter.VH(layoutRes, parent)
|
||||||
|
|
||||||
|
interface Module {
|
||||||
|
interface Setup {
|
||||||
|
fun onAdapterReady(adapter: ModularAdapter<*>)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Creator<T : VH> : Module {
|
||||||
|
fun onCreateModularVH(adapter: ModularAdapter<T>, parent: ViewGroup, viewType: Int): T?
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Binder<T : VH> : Module {
|
||||||
|
fun onBindModularVH(adapter: ModularAdapter<T>, vh: T, pos: Int, payloads: MutableList<Any>) {
|
||||||
|
// NOOP
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onPostBind(adapter: ModularAdapter<T>, vh: T, pos: Int) {
|
||||||
|
// NOOP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Typing : Module {
|
||||||
|
fun onGetItemType(adapter: ModularAdapter<*>, pos: Int): Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ItemId : Module {
|
||||||
|
fun getItemId(adapter: ModularAdapter<*>, position: Int): Long?
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecyclerViewLifecycle : Module {
|
||||||
|
fun onDetachedFromRecyclerView(recyclerView: RecyclerView)
|
||||||
|
fun onAttachedToRecyclerView(recyclerView: RecyclerView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package eu.darken.capod.common.lists.modular.mods
|
||||||
|
|
||||||
|
import eu.darken.capod.common.lists.modular.ModularAdapter
|
||||||
|
|
||||||
|
class ClickMod<VHT : ModularAdapter.VH> constructor(
|
||||||
|
private val listener: (VHT, Int) -> Unit
|
||||||
|
) : ModularAdapter.Module.Binder<VHT> {
|
||||||
|
|
||||||
|
override fun onBindModularVH(adapter: ModularAdapter<VHT>, vh: VHT, pos: Int, payloads: MutableList<Any>) {
|
||||||
|
vh.itemView.setOnClickListener { listener.invoke(vh, pos) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package eu.darken.capod.common.lists.modular.mods
|
||||||
|
|
||||||
|
import androidx.viewbinding.ViewBinding
|
||||||
|
import eu.darken.capod.common.lists.BindableVH
|
||||||
|
import eu.darken.capod.common.lists.modular.ModularAdapter
|
||||||
|
|
||||||
|
class DataBinderMod<ItemT, HolderT> constructor(
|
||||||
|
private val data: List<ItemT>,
|
||||||
|
private val customBinder: (
|
||||||
|
(adapter: ModularAdapter<HolderT>, vh: HolderT, pos: Int, payload: MutableList<Any>) -> Unit
|
||||||
|
)? = null
|
||||||
|
) : ModularAdapter.Module.Binder<HolderT> where HolderT : BindableVH<ItemT, ViewBinding>, HolderT : ModularAdapter.VH {
|
||||||
|
|
||||||
|
override fun onBindModularVH(adapter: ModularAdapter<HolderT>, vh: HolderT, pos: Int, payloads: MutableList<Any>) {
|
||||||
|
customBinder?.invoke(adapter, vh, pos, mutableListOf()) ?: vh.bind(data[pos], payloads)
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package eu.darken.capod.common.lists.modular.mods
|
||||||
|
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import eu.darken.capod.common.lists.modular.ModularAdapter
|
||||||
|
|
||||||
|
class SimpleVHCreatorMod<HolderT> constructor(
|
||||||
|
private val viewType: Int = 0,
|
||||||
|
private val factory: (ViewGroup) -> HolderT
|
||||||
|
) : ModularAdapter.Module.Creator<HolderT> where HolderT : ModularAdapter.VH {
|
||||||
|
|
||||||
|
override fun onCreateModularVH(adapter: ModularAdapter<HolderT>, parent: ViewGroup, viewType: Int): HolderT? {
|
||||||
|
if (this.viewType != viewType) return null
|
||||||
|
return factory.invoke(parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package eu.darken.capod.common.lists.modular.mods
|
||||||
|
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import eu.darken.capod.common.lists.differ.DifferItem
|
||||||
|
import eu.darken.capod.common.lists.modular.ModularAdapter
|
||||||
|
|
||||||
|
class StableIdMod<ItemT : DifferItem> constructor(
|
||||||
|
private val data: List<ItemT>,
|
||||||
|
private val customResolver: (position: Int) -> Long = {
|
||||||
|
(data[it] as? DifferItem)?.stableId ?: RecyclerView.NO_ID
|
||||||
|
}
|
||||||
|
) : ModularAdapter.Module.ItemId, ModularAdapter.Module.Setup {
|
||||||
|
|
||||||
|
override fun onAdapterReady(adapter: ModularAdapter<*>) {
|
||||||
|
adapter.setHasStableIds(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemId(adapter: ModularAdapter<*>, position: Int): Long? {
|
||||||
|
return customResolver.invoke(position)
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package eu.darken.capod.common.lists.modular.mods
|
||||||
|
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import eu.darken.capod.common.lists.modular.ModularAdapter
|
||||||
|
|
||||||
|
class TypedVHCreatorMod<HolderT> constructor(
|
||||||
|
private val typeResolver: (Int) -> Boolean,
|
||||||
|
private val factory: (ViewGroup) -> HolderT
|
||||||
|
) : ModularAdapter.Module.Typing,
|
||||||
|
ModularAdapter.Module.Creator<HolderT> where HolderT : ModularAdapter.VH {
|
||||||
|
|
||||||
|
private fun ModularAdapter<*>.determineOurViewType(): Int {
|
||||||
|
val typingModules = modules.filterIsInstance(ModularAdapter.Module.Typing::class.java)
|
||||||
|
return typingModules.indexOf(this@TypedVHCreatorMod)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onGetItemType(adapter: ModularAdapter<*>, pos: Int): Int? {
|
||||||
|
return if (typeResolver.invoke(pos)) adapter.determineOurViewType() else null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateModularVH(
|
||||||
|
adapter: ModularAdapter<HolderT>,
|
||||||
|
parent: ViewGroup,
|
||||||
|
viewType: Int
|
||||||
|
): HolderT? {
|
||||||
|
if (adapter.determineOurViewType() != viewType) return null
|
||||||
|
return factory.invoke(parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package eu.darken.capod.common.navigation
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import androidx.annotation.IdRes
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.FragmentContainerView
|
||||||
|
import androidx.fragment.app.FragmentManager
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import androidx.navigation.NavDirections
|
||||||
|
import androidx.navigation.fragment.NavHostFragment
|
||||||
|
import androidx.navigation.fragment.findNavController
|
||||||
|
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||||
|
import eu.darken.capod.common.debug.logging.asLog
|
||||||
|
import eu.darken.capod.common.debug.logging.log
|
||||||
|
|
||||||
|
fun Fragment.doNavigate(direction: NavDirections) = findNavController().doNavigate(direction)
|
||||||
|
|
||||||
|
fun Fragment.popBackStack(): Boolean {
|
||||||
|
if (!isAdded) {
|
||||||
|
IllegalStateException("Fragment is not added").also {
|
||||||
|
log(WARN) { "Trying to pop backstack on Fragment that isn't added to an Activity: ${it.asLog()}" }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return findNavController().popBackStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [FragmentContainerView] does not access [NavController] in [Activity.onCreate]
|
||||||
|
* as workaround [FragmentManager] is used to get the [NavController]
|
||||||
|
* @param id [Int] NavFragment id
|
||||||
|
* @see <a href="https://issuetracker.google.com/issues/142847973">issue-142847973</a>
|
||||||
|
*/
|
||||||
|
@Throws(IllegalStateException::class)
|
||||||
|
fun FragmentManager.findNavController(@IdRes id: Int): NavController {
|
||||||
|
val fragment = findFragmentById(id) ?: throw IllegalStateException("Fragment is not found for id:$id")
|
||||||
|
return NavHostFragment.findNavController(fragment)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package eu.darken.capod.common.navigation
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.navigation.NavArgs
|
||||||
|
import androidx.navigation.NavArgsLazy
|
||||||
|
import java.io.Serializable
|
||||||
|
|
||||||
|
// TODO Remove with "androidx.navigation:navigation-safe-args-gradle-plugin:2.4.0-alpha/stable"
|
||||||
|
inline fun <reified Args : NavArgs> SavedStateHandle.navArgs() = NavArgsLazy(Args::class) {
|
||||||
|
Bundle().apply {
|
||||||
|
keys().forEach {
|
||||||
|
when (val value = get<Any>(it)) {
|
||||||
|
is Serializable -> putSerializable(it, value)
|
||||||
|
is Parcelable -> putParcelable(it, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package eu.darken.capod.common.navigation
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.annotation.IdRes
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import androidx.navigation.NavDirections
|
||||||
|
|
||||||
|
fun NavController.navigateIfNotThere(@IdRes resId: Int, args: Bundle? = null) {
|
||||||
|
if (currentDestination?.id == resId) return
|
||||||
|
navigate(resId, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun NavController.doNavigate(direction: NavDirections) {
|
||||||
|
currentDestination?.getAction(direction.actionId)?.let { navigate(direction) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun NavController.isGraphSet(): Boolean = try {
|
||||||
|
graph
|
||||||
|
true
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package eu.darken.capod.common.navigation
|
||||||
|
|
||||||
|
import androidx.annotation.IdRes
|
||||||
|
import androidx.navigation.NavDestination
|
||||||
|
|
||||||
|
fun NavDestination?.hasAction(@IdRes id: Int): Boolean {
|
||||||
|
if (this == null) return false
|
||||||
|
return getAction(id) != null
|
||||||
|
}
|
||||||
+2
-6
@@ -7,7 +7,7 @@ import android.os.PowerManager
|
|||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import eu.darken.capod.common.BuildConfigWrap
|
import eu.darken.capod.common.BuildConfigWrap
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.common.R
|
||||||
import eu.darken.capod.common.withinApiLevel
|
import eu.darken.capod.common.withinApiLevel
|
||||||
|
|
||||||
enum class Permission(
|
enum class Permission(
|
||||||
@@ -16,7 +16,6 @@ enum class Permission(
|
|||||||
@StringRes val labelRes: Int,
|
@StringRes val labelRes: Int,
|
||||||
@StringRes val descriptionRes: Int,
|
@StringRes val descriptionRes: Int,
|
||||||
val permissionId: String,
|
val permissionId: String,
|
||||||
val isScanBlocking: Boolean = false,
|
|
||||||
val isGranted: (Context) -> Boolean = {
|
val isGranted: (Context) -> Boolean = {
|
||||||
ContextCompat.checkSelfPermission(it, permissionId) == PackageManager.PERMISSION_GRANTED
|
ContextCompat.checkSelfPermission(it, permissionId) == PackageManager.PERMISSION_GRANTED
|
||||||
},
|
},
|
||||||
@@ -27,7 +26,6 @@ enum class Permission(
|
|||||||
labelRes = R.string.permission_bluetooth_label,
|
labelRes = R.string.permission_bluetooth_label,
|
||||||
descriptionRes = R.string.permission_bluetooth_description,
|
descriptionRes = R.string.permission_bluetooth_description,
|
||||||
permissionId = "android.permission.BLUETOOTH",
|
permissionId = "android.permission.BLUETOOTH",
|
||||||
isScanBlocking = true,
|
|
||||||
),
|
),
|
||||||
BLUETOOTH_CONNECT(
|
BLUETOOTH_CONNECT(
|
||||||
minApiLevel = Build.VERSION_CODES.S,
|
minApiLevel = Build.VERSION_CODES.S,
|
||||||
@@ -40,7 +38,6 @@ enum class Permission(
|
|||||||
labelRes = R.string.permission_bluetooth_scan_label,
|
labelRes = R.string.permission_bluetooth_scan_label,
|
||||||
descriptionRes = R.string.permission_bluetooth_scan_description,
|
descriptionRes = R.string.permission_bluetooth_scan_description,
|
||||||
permissionId = "android.permission.BLUETOOTH_SCAN",
|
permissionId = "android.permission.BLUETOOTH_SCAN",
|
||||||
isScanBlocking = true,
|
|
||||||
),
|
),
|
||||||
ACCESS_FINE_LOCATION(
|
ACCESS_FINE_LOCATION(
|
||||||
minApiLevel = Build.VERSION_CODES.BASE,
|
minApiLevel = Build.VERSION_CODES.BASE,
|
||||||
@@ -48,7 +45,6 @@ enum class Permission(
|
|||||||
labelRes = R.string.permission_access_fine_location_label,
|
labelRes = R.string.permission_access_fine_location_label,
|
||||||
descriptionRes = R.string.permission_access_fine_location_description,
|
descriptionRes = R.string.permission_access_fine_location_description,
|
||||||
permissionId = "android.permission.ACCESS_FINE_LOCATION",
|
permissionId = "android.permission.ACCESS_FINE_LOCATION",
|
||||||
isScanBlocking = true,
|
|
||||||
),
|
),
|
||||||
ACCESS_BACKGROUND_LOCATION(
|
ACCESS_BACKGROUND_LOCATION(
|
||||||
minApiLevel = Build.VERSION_CODES.Q,
|
minApiLevel = Build.VERSION_CODES.Q,
|
||||||
@@ -77,7 +73,7 @@ enum class Permission(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
POST_NOTIFICATIONS(
|
POST_NOTIFICATIONS(
|
||||||
minApiLevel = Build.VERSION_CODES.TIRAMISU,
|
minApiLevel = Build.VERSION_CODES.S,
|
||||||
labelRes = R.string.permission_post_notifications_label,
|
labelRes = R.string.permission_post_notifications_label,
|
||||||
descriptionRes = R.string.permission_post_notifications_description,
|
descriptionRes = R.string.permission_post_notifications_description,
|
||||||
permissionId = "android.permission.POST_NOTIFICATIONS",
|
permissionId = "android.permission.POST_NOTIFICATIONS",
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package eu.darken.capod.common.preferences
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||||
|
import eu.darken.capod.common.debug.logging.log
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
|
class FlowPreference<T> constructor(
|
||||||
|
private val preferences: SharedPreferences,
|
||||||
|
val key: String,
|
||||||
|
val rawReader: (Any?) -> T,
|
||||||
|
val rawWriter: (T) -> Any?
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val flowInternal = MutableStateFlow(value)
|
||||||
|
val flow: Flow<T> = flowInternal
|
||||||
|
|
||||||
|
private val preferenceChangeListener =
|
||||||
|
SharedPreferences.OnSharedPreferenceChangeListener { changedPrefs, changedKey ->
|
||||||
|
if (changedKey != key) return@OnSharedPreferenceChangeListener
|
||||||
|
|
||||||
|
val newValue = rawReader(changedPrefs.all[key])
|
||||||
|
|
||||||
|
val currentValue = flowInternal.value
|
||||||
|
if (currentValue != newValue && flowInternal.compareAndSet(currentValue, newValue)) {
|
||||||
|
log(VERBOSE) { "$changedPrefs:$changedKey changed to $newValue" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
preferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
var value: T
|
||||||
|
get() = rawReader(valueRaw)
|
||||||
|
set(newVal) {
|
||||||
|
valueRaw = rawWriter(newVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
var valueRaw: Any?
|
||||||
|
get() = preferences.all[key] ?: rawWriter(rawReader(null))
|
||||||
|
set(value) {
|
||||||
|
preferences.edit {
|
||||||
|
when (value) {
|
||||||
|
is Boolean -> putBoolean(key, value)
|
||||||
|
is String -> putString(key, value)
|
||||||
|
is Int -> putInt(key, value)
|
||||||
|
is Long -> putLong(key, value)
|
||||||
|
is Float -> putFloat(key, value)
|
||||||
|
null -> remove(key)
|
||||||
|
else -> throw NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
flowInternal.value = rawReader(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(update: (T) -> T) {
|
||||||
|
value = update(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user