Compare commits

..
19 Commits
Author SHA1 Message Date
darken ba554c8787 Release: 3.0.4-rc1 2026-02-09 16:51:10 +01:00
darken 968829072a chore: Update app translations from Crowdin
Pull latest string translations from Crowdin.
Validated and reverted any problematic changes.
2026-02-09 16:48:43 +01:00
darken 0d1f331551 Release: 3.0.4-rc0 2026-02-09 16:22:31 +01:00
darken a8bb42d40b fix(ci): Narrow APK glob to avoid uploading duplicates
The copyTo-based APK renaming (from AGP 9 upgrade) leaves both the
original and renamed APK in the output directory. Narrow the glob to
only match renamed APKs.
2026-02-09 16:16:18 +01:00
darken 9a3fb11ef7 fix: Use correct labels for upgrade and donate menu items
Both menu items were using settings_general_label ("Settings") instead
of their proper labels, affecting accessibility and long-press tooltips.
2026-02-09 16:07:25 +01:00
darken 12d2c4dd06 fix: Pre-release cleanup of manifest, resource leaks, and dependencies
- Fix BleScanResultReceiver package name in manifest (.bluetooth → .common.bluetooth)
- Fix HandlerThread leak in BluetoothManager2 when registerReceiver() throws
- Cache battery values in MonitorNotifications to match hardening pattern
- Remove duplicate fragment-ktx dependency and align fragment-testing version
- Remove deprecated lifecycle-extensions dependency
2026-02-09 15:50:39 +01:00
darken cfed733fa7 fix: Guard MonitorService start against missing Bluetooth permissions 2026-02-09 14:24:45 +01:00
darken 1af8743533 chore: Remove redundant gradle.properties after AGP 9 upgrade 2026-02-09 07:50:36 +01:00
darken 2f3f1f3f70 fix(ci): Remove non-debug unit test variants for AGP 9 compatibility 2026-02-08 17:31:19 +01:00
darken 6223524a1e chore: Upgrade to AGP 9.0.0 and Gradle 9.3.1 2026-02-08 17:31:19 +01:00
darken ebfffb1536 fix: Replace WorkManager with ForegroundService to eliminate excessive wakelocks
Replace MonitorWorker (CoroutineWorker) with MonitorService (ForegroundService)
to eliminate persistent ProcessorForegroundLck wakelock reported in Android Vitals.
Add BootCompletedReceiver for post-reboot auto-start.
Remove WorkManager dependency entirely.
2026-02-08 14:19:11 +01:00
darken 5aa36b148b fix: Harden battery display against native crashes
Remove thread-unsafe shared RelativeDateTimeFormatter instance and cache
battery percentage values to local variables at all call sites.
2026-02-08 04:25:30 +01:00
darken 29391feaf8 chore: Remove stale CLAUDE.md exclusion from _config.yml 2026-02-07 20:17:51 +01:00
darken 4e279ca44d chore: Restructure Claude Code config into modular rules
Migrate monolithic root CLAUDE.md into .claude/rules/ structure
with focused topic files and glob-based contextual loading.
Clean up leftover helper scripts in .claude/tmp/.
2026-02-07 19:31:28 +01:00
darken 9ff8d9e04e fix: Handle display cutouts in landscape mode
EdgeToEdgeHelper now considers both systemBars() and displayCutout()
insets when applying padding. This prevents camera cutouts from
obscuring UI content when the device is in landscape orientation.
2026-01-17 14:20:21 +01:00
darken 10d5a823fc fix: Make widget clicks unique
Use the widget ID as the request code for `PendingIntent`. This ensures that each widget instance has a distinct `PendingIntent`, preventing them from overwriting each other and allowing clicks on multiple widgets to work correctly.
2025-11-11 08:37:37 +01:00
darken 54afadb136 fix: Prevent memory leak in WidgetConfigurationActivity
Uses the application context instead of the activity context when updating the widget. This avoids leaking the activity instance if the update operation outlives the configuration screen.
2025-11-11 08:37:37 +01:00
darken e4db9387ca Update translations 2025-11-10 08:38:12 +01:00
darken 4834eb276a Widget: Fix layout for devices with single charge detection
The instance check was being done on the wrong value, so it was never `true`.
2025-11-09 15:03:04 +01:00
54 changed files with 980 additions and 893 deletions
+45
View File
@@ -0,0 +1,45 @@
# 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 |
## 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
- `agent-instructions.md` — Sub-agent delegation and critical thinking
+39
View File
@@ -0,0 +1,39 @@
---
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
+84
View File
@@ -0,0 +1,84 @@
---
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
- **Moshi**: JSON serialization for configuration and debugging
- **Material Design**: UI components following Material Design guidelines
+71
View File
@@ -0,0 +1,71 @@
---
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
+41
View File
@@ -0,0 +1,41 @@
---
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
+21
View File
@@ -0,0 +1,21 @@
---
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)
+10
View File
@@ -8,6 +8,16 @@
"Bash(ls:*)",
"Bash(grep:*)",
"Bash(rg:*)",
"Bash(git add:*)",
"Bash(git log:*)",
"Bash(git commit:*)",
"Bash(git show:*)",
"Bash(git checkout:*)",
"Bash(git branch:*)",
"Bash(git stash:*)",
"Bash(gh issue list:*)",
"Bash(gh pr list:*)",
"Bash(gh label list:*)",
"WebSearch",
"WebFetch(domain:support.google.com)",
"WebFetch(domain:github.com)",
-59
View File
@@ -1,59 +0,0 @@
#!/usr/bin/env python3
"""
Script to fix import statements after removing app-common module.
This script fixes R class imports from app-common to app module.
"""
import os
import re
from pathlib import Path
def fix_r_import(file_path):
"""Fix R import in a single file."""
print(f"Fixing R import in: {file_path}")
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Replace the import statement
updated_content = re.sub(
r'import eu\.darken\.capod\.common\.R$',
'import eu.darken.capod.R',
content,
flags=re.MULTILINE
)
if updated_content != content:
with open(file_path, 'w', encoding='utf-8') as f:
f.write(updated_content)
print(f" ✓ Updated import in {file_path}")
return True
else:
print(f" - No changes needed in {file_path}")
return False
def main():
"""Main function to fix all import statements."""
project_root = Path.cwd()
app_src = project_root / "app" / "src"
# Find all Kotlin files that import eu.darken.capod.common.R
files_to_fix = []
for kt_file in app_src.rglob("*.kt"):
with open(kt_file, 'r', encoding='utf-8') as f:
content = f.read()
if re.search(r'import eu\.darken\.capod\.common\.R$', content, re.MULTILINE):
files_to_fix.append(kt_file)
print(f"Found {len(files_to_fix)} files with incorrect R imports")
success_count = 0
for file_path in files_to_fix:
if fix_r_import(file_path):
success_count += 1
print(f"\nFixed imports in {success_count} files")
return 0
if __name__ == "__main__":
exit(main())
-58
View File
@@ -1,58 +0,0 @@
#!/usr/bin/env python3
"""
Script to fix fully qualified R references in code.
This script finds and replaces eu.darken.capod.common.R with R.
"""
import os
import re
from pathlib import Path
def fix_qualified_r_references(file_path):
"""Fix fully qualified R references in a single file."""
print(f"Fixing qualified R references in: {file_path}")
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Replace fully qualified references
updated_content = re.sub(
r'\beu\.darken\.capod\.common\.R\.',
'R.',
content
)
if updated_content != content:
with open(file_path, 'w', encoding='utf-8') as f:
f.write(updated_content)
print(f" ✓ Fixed qualified R references in {file_path}")
return True
else:
print(f" - No changes needed in {file_path}")
return False
def main():
"""Main function to fix all qualified R references."""
project_root = Path.cwd()
app_src = project_root / "app" / "src"
# Find all Kotlin files that have qualified R references
files_to_fix = []
for kt_file in app_src.rglob("*.kt"):
with open(kt_file, 'r', encoding='utf-8') as f:
content = f.read()
if re.search(r'\beu\.darken\.capod\.common\.R\.', content):
files_to_fix.append(kt_file)
print(f"Found {len(files_to_fix)} files with qualified R references")
success_count = 0
for file_path in files_to_fix:
if fix_qualified_r_references(file_path):
success_count += 1
print(f"\nFixed qualified R references in {success_count} files")
return 0
if __name__ == "__main__":
exit(main())
-87
View File
@@ -1,87 +0,0 @@
#!/usr/bin/env python3
"""
Script to fix remaining R import issues.
This script finds files that use R.* but don't have R imports and adds them.
"""
import os
import re
from pathlib import Path
def needs_r_import(file_path):
"""Check if file uses R.* but doesn't import R."""
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Check if file uses R.something
uses_r = re.search(r'\bR\.[a-zA-Z_]', content)
if not uses_r:
return False
# Check if file already imports R
has_import = re.search(r'import.*\.R$', content, re.MULTILINE)
if has_import:
return False
return True
def add_r_import(file_path):
"""Add R import to a file."""
print(f"Adding R import to: {file_path}")
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Find the package line
package_match = re.search(r'^package\s+[^\n]+$', content, re.MULTILINE)
if not package_match:
print(f" Error: No package declaration found in {file_path}")
return False
# Find existing imports
import_pattern = r'^import\s+[^\n]+$'
existing_imports = list(re.finditer(import_pattern, content, re.MULTILINE))
if existing_imports:
# Insert after the last import
last_import = existing_imports[-1]
insert_pos = last_import.end()
updated_content = (content[:insert_pos] +
"\nimport eu.darken.capod.R" +
content[insert_pos:])
else:
# Insert after package line
insert_pos = package_match.end()
updated_content = (content[:insert_pos] +
"\n\nimport eu.darken.capod.R" +
content[insert_pos:])
with open(file_path, 'w', encoding='utf-8') as f:
f.write(updated_content)
print(f" ✓ Added R import to {file_path}")
return True
def main():
"""Main function to fix all R import issues."""
project_root = Path.cwd()
app_src = project_root / "app" / "src"
# Find all Kotlin files that need R import
files_to_fix = []
for kt_file in app_src.rglob("*.kt"):
if needs_r_import(kt_file):
files_to_fix.append(kt_file)
print(f"Found {len(files_to_fix)} files that need R imports")
success_count = 0
for file_path in files_to_fix:
if add_r_import(file_path):
success_count += 1
print(f"\nAdded R imports to {success_count} files")
return 0
if __name__ == "__main__":
exit(main())
-105
View File
@@ -1,105 +0,0 @@
#!/usr/bin/env python3
"""
Script to merge strings.xml files from app-common module to app module.
This script merges all string entries from app-common into the corresponding app module files.
"""
import os
import re
import xml.etree.ElementTree as ET
from pathlib import Path
def merge_strings_xml(app_common_file, app_file):
"""Merge strings from app-common file into app file."""
print(f"Processing: {app_common_file.name} -> {app_file.name}")
# Read the app-common strings.xml content
with open(app_common_file, 'r', encoding='utf-8') as f:
common_content = f.read()
# Read the app strings.xml content
with open(app_file, 'r', encoding='utf-8') as f:
app_content = f.read()
# Extract string entries from app-common (including comments)
# Find everything between <resources> and </resources> tags, excluding the tags themselves
common_match = re.search(r'<resources[^>]*>(.*?)</resources>', common_content, re.DOTALL)
if not common_match:
print(f" Warning: No resources found in {app_common_file}")
return False
common_strings = common_match.group(1).strip()
if not common_strings:
print(f" Warning: No string content found in {app_common_file}")
return False
# Find the insertion point in the app file (before </resources>)
app_match = re.search(r'(.*?)(\s*</resources>)', app_content, re.DOTALL)
if not app_match:
print(f" Error: Invalid XML structure in {app_file}")
return False
# Merge the content
before_closing = app_match.group(1)
closing_tag = app_match.group(2)
# Add the common strings with proper spacing
merged_content = f"{before_closing}\n\n <!-- Strings from app-common -->\n{common_strings}\n{closing_tag}"
# Write the merged content back to the app file
with open(app_file, 'w', encoding='utf-8') as f:
f.write(merged_content)
print(f" ✓ Merged successfully")
return True
def main():
"""Main function to merge all strings.xml files."""
project_root = Path.cwd()
app_common_res = project_root / "app-common" / "src" / "main" / "res"
app_res = project_root / "app" / "src" / "main" / "res"
if not app_common_res.exists():
print(f"Error: app-common resources directory not found: {app_common_res}")
return 1
if not app_res.exists():
print(f"Error: app resources directory not found: {app_res}")
return 1
# Find all strings.xml files in app-common
common_strings_files = list(app_common_res.glob("*/strings.xml"))
if not common_strings_files:
print("Error: No strings.xml files found in app-common")
return 1
print(f"Found {len(common_strings_files)} strings.xml files to merge")
success_count = 0
error_count = 0
for common_file in sorted(common_strings_files):
# Determine the corresponding app file
locale_dir = common_file.parent.name
app_file = app_res / locale_dir / "strings.xml"
if not app_file.exists():
print(f" Error: Corresponding app file does not exist: {app_file}")
error_count += 1
continue
if merge_strings_xml(common_file, app_file):
success_count += 1
else:
error_count += 1
print(f"\nMerge complete:")
print(f" ✓ Successfully merged: {success_count}")
print(f" ✗ Errors: {error_count}")
return 0 if error_count == 0 else 1
if __name__ == "__main__":
exit(main())
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
strategy:
fail-fast: false
matrix:
variant: [ Debug,Beta,Release ]
variant: [ Debug ]
flavor: [ testFoss,testGplay ]
runs-on: ubuntu-22.04
steps:
+2 -2
View File
@@ -65,7 +65,7 @@ jobs:
tag_name: ${{ steps.tagger.outputs.tag }}
name: ${{ steps.tagger.outputs.tag }}
generate_release_notes: true
files: app/build/outputs/apk/foss/beta/*.apk
files: app/build/outputs/apk/foss/beta/eu.darken.capod-*.apk
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -77,7 +77,7 @@ jobs:
tag_name: ${{ steps.tagger.outputs.tag }}
name: ${{ steps.tagger.outputs.tag }}
generate_release_notes: true
files: app/build/outputs/apk/foss/release/*.apk
files: app/build/outputs/apk/foss/release/eu.darken.capod-*.apk
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-158
View File
@@ -1,158 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
### Build Commands
```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 Commands
```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 Commands
```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 Commands
```bash
./gradlew assembleFossRelease assembleGplayRelease
```
## Architecture Overview
### Multi-Module Structure
- **app/**: Main Android application with FOSS and Google Play flavors
- **app-common/**: Shared code between main app
### Core Architecture Patterns
- **MVVM**: ViewModels with LiveData/StateFlow for UI state management
- **Dependency Injection**: Hilt/Dagger for dependency management
- **Coroutines**: Extensive use of 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 background monitoring worker lifecycle
- `MonitorWorker`: Background worker 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**: 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 Architecture
The app follows a unidirectional data flow:
1. `BluetoothEventReceiver` detects Bluetooth events
2. `MonitorWorker` 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
### 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
- **WorkManager**: Background task scheduling for monitoring
- **Moshi**: JSON serialization for configuration and debugging
- **Material Design**: UI components following Material Design guidelines
## Development Notes
### Bluetooth LE Implementation
The app uses Android's Bluetooth LE APIs to scan for Apple device advertisements. The core scanning logic is in
`MonitorWorker` which runs as a long-lived background task.
### 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.
### 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
Examples of correct string naming:
- `profiles_create_title` (screen title)
- `profiles_name_label` (form field label)
- `profiles_delete_confirmation` (dialog message)
- `error_network_unavailable` (error message)
+1 -1
View File
@@ -1 +1 @@
3.0.3-rc0 30003000
3.0.4-rc1 30004010
-1
View File
@@ -26,4 +26,3 @@ exclude:
- app
- app-common
- CONTRIBUTING.md
- CLAUDE.md
+37 -14
View File
@@ -94,21 +94,9 @@ android {
}
}
buildOutputs.all {
val variantOutputImpl = this as com.android.build.gradle.internal.api.BaseVariantOutputImpl
val variantName: String = variantOutputImpl.name
if (listOf("release", "beta").any { variantName.lowercase().contains(it) }) {
val outputFileName = projectConfig.packageName +
"-v${defaultConfig.versionName}-${defaultConfig.versionCode}" +
"-${variantName.uppercase()}.apk"
variantOutputImpl.outputFileName = outputFileName
}
}
buildFeatures {
viewBinding = true
buildConfig = true
}
compileOptions {
@@ -141,6 +129,42 @@ android {
}
}
androidComponents {
onVariants { variant ->
val buildType = variant.buildType ?: return@onVariants
if (buildType != "release" && buildType != "beta") return@onVariants
val formattedVariantName = variant.name
.replace(Regex("([a-z])([A-Z])"), "$1-$2")
.uppercase()
val apkFolder = variant.artifacts.get(com.android.build.api.artifact.SingleArtifact.APK)
val loader = variant.artifacts.getBuiltArtifactsLoader()
val packageName = projectConfig.packageName
val renameTask = tasks.register("rename${variant.name.replaceFirstChar { it.uppercase() }}Apk") {
inputs.files(apkFolder)
outputs.upToDateWhen { false }
doLast {
val builtArtifacts = loader.load(apkFolder.get()) ?: return@doLast
builtArtifacts.elements.forEach { element ->
val apkFile = File(element.outputFile)
val outputFileName = "$packageName-v${element.versionName}-${element.versionCode}-$formattedVariantName.apk"
if (apkFile.exists() && apkFile.name != outputFileName) {
apkFile.copyTo(File(apkFile.parentFile, outputFileName), overwrite = true)
}
}
}
}
tasks.matching { it.name == "assemble${variant.name.replaceFirstChar { it.uppercase() }}" }.configureEach {
finalizedBy(renameTask)
}
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
@@ -158,7 +182,6 @@ dependencies {
implementation("androidx.core:core-splashscreen:1.0.0-alpha02")
addNavigation()
addBaseWorkManager()
addTesting()
+2 -2
View File
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="upgrades_gplay_unavailable_error">De Google Play-diensten zijn niet beschikbaar.</string>
<string name="upgrades_gplay_unavailable_error">De Google Play diensten zijn niet beschikbaar.</string>
<string name="upgrades_no_purchases_found_check_account">Geen aankopen gevonden. Gebruikt u de juiste rekening?</string>
<string name="upgrades_gplay_billing_error_label">Google Play Fout</string>
<string name="upgrades_gplay_billing_error_description">Er is een fout opgetreden in Google Play. Probeer het later opnieuw of start je telefoon opnieuw op.\n\nFout: %s</string>
<string name="upgrades_gplay_billing_result_error_label">Google Play Factureringsfout</string>
<string name="upgrades_gplay_billing_result_error_label">Factureringsfout bij Google Play</string>
<string name="upgrades_gplay_billing_result_error_description">Er is een fout opgetreden bij het opvragen van je aankoopgegevens bij Google Play. Wis de cache van Google Play en start je telefoon opnieuw op.\n\nFout %s</string>
</resources>
@@ -2,4 +2,8 @@
<resources>
<string name="upgrades_gplay_unavailable_error">Os serviços do Google Play não estão disponíveis.</string>
<string name="upgrades_no_purchases_found_check_account">Nenhuma compra encontrada. Você está usando a conta certa?</string>
<string name="upgrades_gplay_billing_error_label">Erro do Google Play</string>
<string name="upgrades_gplay_billing_error_description">Houve um erro no Google Play. Por favor, tente novamente mais tarde ou reinicie seu telefone.\n\nError: %s</string>
<string name="upgrades_gplay_billing_result_error_label">Erro de faturamento do Google Play</string>
<string name="upgrades_gplay_billing_result_error_description">Houve um erro ao solicitar ao Google Play os seus detalhes de compra. Limpe o cache do Google Play e reinicie o seu telefone. \n\nError %s</string>
</resources>
+4
View File
@@ -2,4 +2,8 @@
<resources>
<string name="upgrades_gplay_unavailable_error">Сервіси Google Play недоступні.</string>
<string name="upgrades_no_purchases_found_check_account">Покупок не знайдено. Ви справді використовуєте правильний обліковий запис?</string>
<string name="upgrades_gplay_billing_error_label">Помилка Google Play</string>
<string name="upgrades_gplay_billing_error_description">У Google Play сталася помилка. Спробуйте ще раз пізніше або перезавантажте телефон.\n\nПомилка: %s</string>
<string name="upgrades_gplay_billing_result_error_label">Помилка платіжної системи Google Play</string>
<string name="upgrades_gplay_billing_result_error_description">Сталася помилка під час отримання даних про покупки з Google Play. Очистіть кеш Google Play та перезавантажте телефон.\n\nПомилка %s</string>
</resources>
+13 -18
View File
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="eu.darken.capod">
<uses-permission-sdk-23 android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
@@ -9,6 +8,8 @@
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
@@ -58,13 +59,21 @@
</activity>
<receiver
android:name=".bluetooth.BleScanResultReceiver"
android:name=".common.bluetooth.BleScanResultReceiver"
android:exported="false">
<intent-filter>
<action android:name="eu.darken.capod.bluetooth.DELIVER_SCAN_RESULTS" />
</intent-filter>
</receiver>
<receiver
android:name=".monitor.core.receiver.BootCompletedReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<receiver
android:name=".monitor.core.receiver.BluetoothEventReceiver"
android:enabled="true"
@@ -112,24 +121,10 @@
android:name=".common.debug.recording.ui.RecorderActivity"
android:theme="@style/AppThemeFloating" />
<!-- Worker stuff-->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:name=".monitor.core.worker.MonitorService"
android:foregroundServiceType="connectedDevice"
tools:node="merge" />
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
android:exported="false" />
</application>
</manifest>
+2 -24
View File
@@ -1,10 +1,7 @@
package eu.darken.capod
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
import eu.darken.capod.common.debug.logging.LogCatLogger
@@ -23,13 +20,11 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltAndroidApp
open class App : Application(), Configuration.Provider {
open class App : Application() {
@Inject lateinit var workerFactory: HiltWorkerFactory
@Inject lateinit var autoReporting: AutomaticBugReporter
@Inject lateinit var monitorControl: MonitorControl
@Inject lateinit var podMonitor: PodMonitor
@@ -45,9 +40,7 @@ open class App : Application(), Configuration.Provider {
log(TAG) { "onCreate() done! ${Exception().asLog()}" }
appScope.launch {
monitorControl.startMonitor(forceStart = true)
}
monitorControl.startMonitor(forceStart = true)
podMonitor.devicesWithProfiles()
.distinctUntilChanged()
@@ -68,21 +61,6 @@ open class App : Application(), Configuration.Provider {
.launchIn(appScope)
}
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setMinimumLoggingLevel(
when {
BuildConfigWrap.DEBUG -> android.util.Log.VERBOSE
BuildConfigWrap.BUILD_TYPE == BuildConfigWrap.BuildType.DEV -> android.util.Log.DEBUG
BuildConfigWrap.BUILD_TYPE == BuildConfigWrap.BuildType.BETA -> android.util.Log.INFO
BuildConfigWrap.BUILD_TYPE == BuildConfigWrap.BuildType.RELEASE -> android.util.Log.WARN
else -> android.util.Log.VERBOSE
}
)
.setWorkerFactory(workerFactory)
.build()
companion object {
internal val TAG = logTag("CAP")
}
@@ -2,7 +2,6 @@ package eu.darken.capod.common
import android.app.Activity
import android.view.View
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import eu.darken.capod.common.debug.logging.logTag
@@ -20,12 +19,14 @@ class EdgeToEdgeHelper(activity: Activity) {
bottom: Boolean = false,
) {
ViewCompat.setOnApplyWindowInsetsListener(view) { v: View, insets: WindowInsetsCompat ->
val systemBars: Insets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val displayCutout = insets.getInsets(WindowInsetsCompat.Type.displayCutout())
v.setPadding(
if (left) systemBars.left else v.paddingLeft,
if (top) systemBars.top else v.paddingTop,
if (right) systemBars.right else v.paddingRight,
if (bottom) systemBars.bottom else v.paddingBottom,
if (left) maxOf(systemBars.left, displayCutout.left) else v.paddingLeft,
if (top) maxOf(systemBars.top, displayCutout.top) else v.paddingTop,
if (right) maxOf(systemBars.right, displayCutout.right) else v.paddingRight,
if (bottom) maxOf(systemBars.bottom, displayCutout.bottom) else v.paddingBottom,
)
insets
}
@@ -212,6 +212,7 @@ class BluetoothManager2 @Inject constructor(
context.registerReceiver(receiver, filter, null, handler)
} catch (e: Exception) {
log(TAG, ERROR) { "monitorProfile(): Failed to register receiver: $e" }
handlerThread.quitSafely()
close(e)
return@callbackFlow
}
@@ -5,7 +5,6 @@ import android.app.NotificationManager
import android.bluetooth.BluetoothManager
import android.content.Context
import android.media.AudioManager
import androidx.work.WorkManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -30,11 +29,6 @@ class AndroidModule {
fun bluetoothManager(context: Context): BluetoothManager =
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
@Provides
@Singleton
fun workerManager(context: Context): WorkManager =
WorkManager.getInstance(context)
@Provides
@Singleton
fun audioManager(context: Context): AudioManager =
@@ -1,31 +0,0 @@
package eu.darken.capod.common.worker
import android.os.Parcel
import android.os.Parcelable
import androidx.work.Data
@Suppress("UNCHECKED_CAST")
inline fun <reified T : Parcelable> Data.getParcelable(key: String): T? {
val parcel = Parcel.obtain()
try {
val bytes = getByteArray(key) ?: return null
parcel.unmarshall(bytes, 0, bytes.size)
parcel.setDataPosition(0)
val creator = T::class.java.getField("CREATOR").get(null) as Parcelable.Creator<T>
return creator.createFromParcel(parcel)
} finally {
parcel.recycle()
}
}
fun Data.Builder.putParcelable(key: String, parcelable: Parcelable): Data.Builder {
val parcel = Parcel.obtain()
try {
parcelable.writeToParcel(parcel, 0)
putByteArray(key, parcel.marshall())
} finally {
parcel.recycle()
}
return this
}
@@ -19,9 +19,7 @@ import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.firstSeenFormatted
import eu.darken.capod.pods.core.getBatteryDrawable
import eu.darken.capod.pods.core.getBatteryLevelCase
import eu.darken.capod.pods.core.getBatteryLevelLeftPod
import eu.darken.capod.pods.core.getBatteryLevelRightPod
import eu.darken.capod.pods.core.formatBatteryPercent
import eu.darken.capod.pods.core.lastSeenFormatted
import java.time.Duration
import java.time.Instant
@@ -76,11 +74,13 @@ class DualPodsCardVH(parent: ViewGroup) :
// Pods battery state
device.apply {
podLeftBatteryIcon.setImageResource(getBatteryDrawable(batteryLeftPodPercent))
podLeftBatteryLabel.text = getBatteryLevelLeftPod(context)
val leftPercent = batteryLeftPodPercent
podLeftBatteryIcon.setImageResource(getBatteryDrawable(leftPercent))
podLeftBatteryLabel.text = formatBatteryPercent(context, leftPercent)
podRightBatteryIcon.setImageResource(getBatteryDrawable(batteryRightPodPercent))
podRightBatteryLabel.text = getBatteryLevelRightPod(context)
val rightPercent = batteryRightPodPercent
podRightBatteryIcon.setImageResource(getBatteryDrawable(rightPercent))
podRightBatteryLabel.text = formatBatteryPercent(context, rightPercent)
}
// Pods charging state
@@ -139,8 +139,9 @@ class DualPodsCardVH(parent: ViewGroup) :
if (this is HasCase) {
podCaseIcon.setImageResource(caseIcon)
podCaseBatteryIcon.isGone = false
podCaseBatteryIcon.setImageResource(getBatteryDrawable(batteryCasePercent))
podCaseBatteryLabel.text = getBatteryLevelCase(context)
val casePercent = batteryCasePercent
podCaseBatteryIcon.setImageResource(getBatteryDrawable(casePercent))
podCaseBatteryLabel.text = formatBatteryPercent(context, casePercent)
podCaseChargingIcon.isInvisible = !isCaseCharging
podCaseChargingLabel.isInvisible = !isCaseCharging
@@ -13,7 +13,7 @@ import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.firstSeenFormatted
import eu.darken.capod.pods.core.getBatteryDrawable
import eu.darken.capod.pods.core.getBatteryLevelHeadset
import eu.darken.capod.pods.core.formatBatteryPercent
import eu.darken.capod.pods.core.lastSeenFormatted
import java.time.Duration
import java.time.Instant
@@ -55,8 +55,9 @@ class SinglePodsCardVH(parent: ViewGroup) :
// Battery level
device.apply {
batteryLabel.text = getBatteryLevelHeadset(context)
batteryIcon.setImageResource(getBatteryDrawable(batteryHeadsetPercent))
val headsetPercent = batteryHeadsetPercent
batteryIcon.setImageResource(getBatteryDrawable(headsetPercent))
batteryLabel.text = formatBatteryPercent(context, headsetPercent)
}
// Charge state
@@ -1,12 +1,14 @@
package eu.darken.capod.main.ui.widget
import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.core.view.isVisible
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
import eu.darken.capod.common.EdgeToEdgeHelper
import eu.darken.capod.common.debug.logging.log
@@ -24,6 +26,7 @@ class WidgetConfigurationActivity : Activity2() {
@Inject lateinit var profileAdapter: WidgetProfileSelectionAdapter
@Inject lateinit var upgradeRepo: UpgradeRepo
@ApplicationContext @Inject lateinit var appContext: Context
private var widgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID
@@ -101,10 +104,10 @@ class WidgetConfigurationActivity : Activity2() {
val resultValue = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)
setResult(RESULT_OK, resultValue)
val appWidgetManager = AppWidgetManager.getInstance(this@WidgetConfigurationActivity)
val appWidgetManager = AppWidgetManager.getInstance(appContext)
WidgetProvider.updateWidget(
context = this@WidgetConfigurationActivity,
context = appContext,
appWidgetManager = appWidgetManager,
widgetId = widgetId
)
@@ -30,11 +30,8 @@ import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.PodFactory
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.formatBatteryPercent
import eu.darken.capod.pods.core.getBatteryDrawable
import eu.darken.capod.pods.core.getBatteryLevelCase
import eu.darken.capod.pods.core.getBatteryLevelHeadset
import eu.darken.capod.pods.core.getBatteryLevelLeftPod
import eu.darken.capod.pods.core.getBatteryLevelRightPod
import eu.darken.capod.profiles.core.ProfileId
import finish2
import kotlinx.coroutines.CoroutineScope
@@ -141,7 +138,7 @@ class WidgetProvider : AppWidgetProvider() {
val device: PodDevice? = profileId?.let { podMonitor.getDeviceForProfile(it) }
val layout = when {
!upgradeRepo.isPro() -> createUpgradeRequiredLayout(context)
!upgradeRepo.isPro() -> createUpgradeRequiredLayout(context, widgetId)
device is DualPodDevice -> {
val minWidth = widgetManager.getAppWidgetOptions(widgetId)
.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)
@@ -157,23 +154,24 @@ class WidgetProvider : AppWidgetProvider() {
else -> R.layout.widget_pod_dual_wide_layout
}
createDualPodLayout(context, device, layout)
createDualPodLayout(context, device, layout, widgetId)
}
device is SinglePodDevice -> createSinglePodLayout(context, device)
device is PodDevice -> createUnknownPodLayout(context, device)
else -> createNoDeviceLayout(context, profileId != null)
device is SinglePodDevice -> createSinglePodLayout(context, device, widgetId)
device is PodDevice -> createUnknownPodLayout(context, device, widgetId)
else -> createNoDeviceLayout(context, profileId != null, widgetId)
}
widgetManager.updateAppWidget(widgetId, layout)
}
private suspend fun createUpgradeRequiredLayout(
context: Context
context: Context,
widgetId: Int
) = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
log(TAG, VERBOSE) { "createUpgradeRequiredLayout(context=$context)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
0,
widgetId,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
@@ -188,11 +186,12 @@ class WidgetProvider : AppWidgetProvider() {
private fun createUnknownPodLayout(
context: Context,
podDevice: PodDevice,
widgetId: Int
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
log(TAG, VERBOSE) { "createUnknownPodLayout(context=$context, podDevice=$podDevice)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
0,
widgetId,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
@@ -204,12 +203,13 @@ class WidgetProvider : AppWidgetProvider() {
private fun createNoDeviceLayout(
context: Context,
hasConfiguredProfile: Boolean = false
hasConfiguredProfile: Boolean = false,
widgetId: Int
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
log(TAG, VERBOSE) { "createNoDeviceLayout(context=$context, hasConfiguredProfile=$hasConfiguredProfile)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
0,
widgetId,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
@@ -227,12 +227,13 @@ class WidgetProvider : AppWidgetProvider() {
private fun createDualPodLayout(
context: Context,
podDevice: DualPodDevice,
@LayoutRes layout: Int
@LayoutRes layout: Int,
widgetId: Int
): RemoteViews = RemoteViews(context.packageName, layout).apply {
log(TAG, VERBOSE) { "createSinglePodLayout(context=$context, podDevice=$podDevice), layout=${layout}" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
0,
widgetId,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
@@ -242,8 +243,9 @@ class WidgetProvider : AppWidgetProvider() {
setTextViewText(R.id.headphones_label, podDevice.getLabel(context))
// Left
val leftPercent = podDevice.batteryLeftPodPercent
setImageViewResource(R.id.pod_left_icon, podDevice.leftPodIcon)
setTextViewText(R.id.pod_left_label, podDevice.getBatteryLevelLeftPod(context))
setTextViewText(R.id.pod_left_label, formatBatteryPercent(context, leftPercent))
setViewVisibility(
R.id.pod_left_charging,
if (podDevice is HasChargeDetectionDual && podDevice.isLeftPodCharging) View.VISIBLE else View.GONE
@@ -255,15 +257,17 @@ class WidgetProvider : AppWidgetProvider() {
// Case
(podDevice as? HasCase)?.let { setImageViewResource(R.id.pod_case_icon, it.caseIcon) }
setTextViewText(R.id.pod_case_label, (podDevice as? HasCase)?.getBatteryLevelCase(context))
val casePercent = (podDevice as? HasCase)?.batteryCasePercent
setTextViewText(R.id.pod_case_label, formatBatteryPercent(context, casePercent))
setViewVisibility(
R.id.pod_case_charging,
if (podDevice is HasCase && podDevice.isCaseCharging) View.VISIBLE else View.GONE
)
// Right
val rightPercent = podDevice.batteryRightPodPercent
setImageViewResource(R.id.pod_right_icon, podDevice.rightPodIcon)
setTextViewText(R.id.pod_right_label, podDevice.getBatteryLevelRightPod(context))
setTextViewText(R.id.pod_right_label, formatBatteryPercent(context, rightPercent))
setViewVisibility(
R.id.pod_right_charging,
if (podDevice is HasChargeDetectionDual && podDevice.isRightPodCharging) View.VISIBLE else View.GONE
@@ -277,30 +281,33 @@ class WidgetProvider : AppWidgetProvider() {
private fun createSinglePodLayout(
context: Context,
podDevice: SinglePodDevice,
widgetId: Int
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_pod_single_layout).apply {
log(TAG, VERBOSE) { "createSinglePodLayout(context=$context, podDevice=$podDevice)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
0,
widgetId,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
val headsetPercent = podDevice.batteryHeadsetPercent
setTextViewText(R.id.headphones_label, podDevice.getLabel(context))
setImageViewResource(R.id.headphones_icon, podDevice.iconRes)
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(podDevice.batteryHeadsetPercent))
setTextViewText(R.id.headphones_battery_label, podDevice.getBatteryLevelHeadset(context))
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(headsetPercent))
setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent))
setViewVisibility(
R.id.headphones_worn,
if (podDevice is HasEarDetection && podDevice.isBeingWorn) View.VISIBLE else View.GONE
)
if (this is HasChargeDetectionDual) {
setViewVisibility(R.id.headphones_charging, if (isHeadsetBeingCharged) View.VISIBLE else View.GONE)
}
setViewVisibility(
R.id.headphones_charging,
if (podDevice is HasChargeDetectionDual && podDevice.isHeadsetBeingCharged) View.VISIBLE else View.GONE
)
}
companion object {
@@ -8,21 +8,17 @@ import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.common.bluetooth.hasFeature
import eu.darken.capod.common.coroutine.AppScope
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.logTag
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class BluetoothEventReceiver : BroadcastReceiver() {
@Inject lateinit var monitorControl: MonitorControl
@Inject @AppScope lateinit var appScope: CoroutineScope
override fun onReceive(context: Context, intent: Intent) {
log(TAG) { "onReceive($context, $intent)" }
@@ -47,12 +43,8 @@ class BluetoothEventReceiver : BroadcastReceiver() {
log { "Device has the following we features we support $supportedFeatures" }
}
val pending = goAsync()
appScope.launch {
log(TAG) { "Starting monitor" }
monitorControl.startMonitor(bluetoothDevice, forceStart = false)
pending.finish()
}
log(TAG) { "Starting monitor" }
monitorControl.startMonitor(forceStart = false)
}
companion object {
@@ -0,0 +1,26 @@
package eu.darken.capod.monitor.core.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.monitor.core.worker.MonitorControl
import javax.inject.Inject
@AndroidEntryPoint
class BootCompletedReceiver : BroadcastReceiver() {
@Inject lateinit var monitorControl: MonitorControl
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
log(TAG) { "Boot completed, starting monitor." }
monitorControl.startMonitor(forceStart = false)
}
companion object {
private val TAG = logTag("Monitor", "BootReceiver")
}
}
@@ -1,51 +1,50 @@
package eu.darken.capod.monitor.core.worker
import android.bluetooth.BluetoothDevice
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.coroutine.DispatcherProvider
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
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.log
import eu.darken.capod.common.debug.logging.logTag
import kotlinx.coroutines.withContext
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.startServiceCompat
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MonitorControl @Inject constructor(
private val workerManager: WorkManager,
private val dispatcherProvider: DispatcherProvider,
@ApplicationContext private val context: Context,
) {
suspend fun startMonitor(
bluetoothDevice: BluetoothDevice? = null,
fun startMonitor(
forceStart: Boolean = false,
): Unit = withContext(dispatcherProvider.IO) {
val workerData = Data.Builder().apply {
) {
log(TAG, VERBOSE) { "startMonitor(forceStart=$forceStart)" }
}.build()
log(TAG, VERBOSE) { "Worker data: $workerData" }
val hasBluetoothPermission =
Permission.BLUETOOTH.isGranted(context) || Permission.BLUETOOTH_CONNECT.isGranted(context)
if (!hasBluetoothPermission) {
log(TAG, WARN) { "Missing Bluetooth permission, not starting monitor service." }
return
}
val workRequest = OneTimeWorkRequestBuilder<MonitorWorker>().apply {
setInputData(workerData)
}.build()
try {
context.startServiceCompat(MonitorService.intent(context, forceStart))
log(TAG) { "Monitor start request sent." }
} catch (e: IllegalStateException) {
log(TAG, WARN) { "Failed to start monitor service: ${e.message}" }
} catch (e: SecurityException) {
log(TAG, WARN) { "Failed to start monitor service, permission issue: ${e.message}" }
}
}
log(TAG, VERBOSE) { "Worker request: $workRequest" }
val operation = workerManager.enqueueUniqueWork(
"${BuildConfigWrap.APPLICATION_ID}.monitor.worker",
if (forceStart) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP,
workRequest,
)
operation.result.get()
log(TAG) { "Monitor start request send." }
fun stopMonitor() {
log(TAG, VERBOSE) { "stopMonitor()" }
context.stopService(MonitorService.intent(context))
log(TAG) { "Monitor stop request sent." }
}
companion object {
private val TAG = logTag("Monitor", "Control")
}
}
}
@@ -1,13 +1,13 @@
package eu.darken.capod.monitor.core.worker
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.IBinder
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.common.bluetooth.BluetoothDevice2
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
@@ -19,6 +19,7 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.hasApiLevel
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool
@@ -33,6 +34,7 @@ import eu.darken.capod.reaction.core.playpause.PlayPause
import eu.darken.capod.reaction.core.popup.PopUpReaction
import eu.darken.capod.reaction.ui.popup.PopUpWindow
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.delay
@@ -45,78 +47,87 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@AndroidEntryPoint
class MonitorService : Service() {
@HiltWorker
class MonitorWorker @AssistedInject constructor(
@Assisted private val context: Context,
@Assisted private val params: WorkerParameters,
private val dispatcherProvider: DispatcherProvider,
private val notifications: MonitorNotifications,
private val notificationManager: NotificationManager,
private val generalSettings: GeneralSettings,
private val permissionTool: PermissionTool,
private val podMonitor: PodMonitor,
private val bluetoothManager: BluetoothManager2,
private val playPause: PlayPause,
private val autoConnect: AutoConnect,
private val popUpReaction: PopUpReaction,
private val popUpWindow: PopUpWindow,
private val profilesRepo: DeviceProfilesRepo,
) : CoroutineWorker(context, params) {
@Inject lateinit var dispatcherProvider: DispatcherProvider
@Inject lateinit var notifications: MonitorNotifications
@Inject lateinit var notificationManager: NotificationManager
@Inject lateinit var generalSettings: GeneralSettings
@Inject lateinit var permissionTool: PermissionTool
@Inject lateinit var podMonitor: PodMonitor
@Inject lateinit var bluetoothManager: BluetoothManager2
@Inject lateinit var playPause: PlayPause
@Inject lateinit var autoConnect: AutoConnect
@Inject lateinit var popUpReaction: PopUpReaction
@Inject lateinit var popUpWindow: PopUpWindow
@Inject lateinit var profilesRepo: DeviceProfilesRepo
private val workerScope = MonitorCoroutineScope()
private val monitorScope = MonitorCoroutineScope()
private var monitoringJob: Job? = null
@Volatile private var monitorGeneration = 0
private var finishedWithError = false
@SuppressLint("InlinedApi")
override fun onCreate() {
super.onCreate()
log(TAG, VERBOSE) { "onCreate()" }
init {
log(TAG, VERBOSE) { "init(): workerId=$id" }
}
override suspend fun getForegroundInfo(): ForegroundInfo {
return notifications.getForegroundInfo(null)
}
override suspend fun doWork(): Result = try {
val start = System.currentTimeMillis()
log(TAG, VERBOSE) { "Executing $inputData now (runAttemptCount=$runAttemptCount)" }
doDoWork()
val duration = System.currentTimeMillis() - start
log(TAG, VERBOSE) { "Execution finished after ${duration}ms, $inputData" }
Result.success(inputData)
} catch (e: Throwable) {
if (e !is CancellationException) {
Bugs.report(tag = TAG, "Execution failed", exception = e)
finishedWithError = true
Result.failure(inputData)
val notification = notifications.getStartupNotification()
if (hasApiLevel(29)) {
startForeground(
MonitorNotifications.NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
)
} else {
Result.success()
startForeground(MonitorNotifications.NOTIFICATION_ID, notification)
}
} finally {
if (generalSettings.useExtraMonitorNotification.value && !generalSettings.keepConnectedNotificationAfterDisconnect.value) {
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
log(TAG, VERBOSE) { "onStartCommand(intent=$intent, flags=$flags, startId=$startId)" }
val forceStart = intent?.getBooleanExtra(EXTRA_FORCE_START, false) ?: false
if (monitoringJob?.isActive == true && !forceStart) {
log(TAG) { "Already monitoring and forceStart=false, keeping current session." }
return START_STICKY
}
val generation = ++monitorGeneration
monitorScope.coroutineContext.cancelChildren()
monitoringJob = monitorScope.launch {
try {
notificationManager.cancel(MonitorNotifications.NOTIFICATION_ID_CONNECTED)
doMonitor()
} catch (e: CancellationException) {
log(TAG) { "Monitor cancelled." }
} catch (e: Exception) {
log(TAG, WARN) { "Failed to cancel connected notification: ${e.message}" }
Bugs.report(tag = TAG, "Monitor failed", exception = e)
} finally {
if (monitorGeneration == generation) {
log(TAG) { "Monitor finished, stopping service." }
stopSelf()
} else {
log(TAG) { "Monitor replaced, not stopping service." }
}
}
}
this.workerScope.cancel("Worker finished (withError?=$finishedWithError).")
return START_STICKY
}
private suspend fun doDoWork() {
private suspend fun doMonitor() {
val permissionsMissingOnStart = permissionTool.missingPermissions.first()
if (permissionsMissingOnStart.isNotEmpty()) {
log(TAG, WARN) { "Aborting, missing permissions: $permissionsMissingOnStart" }
return
}
setForeground(notifications.getForegroundInfo(null))
val monitorJob = podMonitor.primaryDevice()
.setupCommonEventHandlers(TAG) { "PodMonitor" }
.distinctUntilChanged()
@@ -136,13 +147,13 @@ class MonitorWorker @AssistedInject constructor(
.catch {
log(TAG, WARN) { "Pod Flow failed:\n${it.asLog()}" }
}
.launchIn(workerScope)
.launchIn(monitorScope)
permissionTool.missingPermissions
.flatMapLatest { missingPermsFlow ->
if (missingPermsFlow.isNotEmpty()) {
log(TAG, WARN) { "Aborting, permissions are missing: $missingPermsFlow" }
workerScope.coroutineContext.cancelChildren()
monitorScope.coroutineContext.cancelChildren()
emptyFlow()
} else {
combine(
@@ -163,7 +174,6 @@ class MonitorWorker @AssistedInject constructor(
@Suppress("UNCHECKED_CAST")
val devices = arguments[2] as Collection<BluetoothDevice2>
val connectedAddresses = devices.map { it.address }.toSet()
val knownAddresses = profiles.mapNotNull { it.address }.toSet()
log(TAG) { "Monitor mode: $monitorMode" }
@@ -172,8 +182,7 @@ class MonitorWorker @AssistedInject constructor(
when (monitorMode) {
MonitorMode.MANUAL -> flow<Unit> {
// Cancel worker, ui scans manually
workerScope.coroutineContext.cancelChildren()
monitorScope.coroutineContext.cancelChildren()
}
MonitorMode.ALWAYS -> emptyFlow()
@@ -188,11 +197,11 @@ class MonitorWorker @AssistedInject constructor(
}
else -> {
log(TAG) { "No known Pods are connected, canceling worker soon." }
log(TAG) { "No known Pods are connected, stopping service soon." }
delay(15 * 1000)
log(TAG) { "Canceling worker now, still no Pods connected." }
log(TAG) { "Stopping service now, still no Pods connected." }
workerScope.coroutineContext.cancelChildren()
monitorScope.coroutineContext.cancelChildren()
}
}
}
@@ -201,7 +210,7 @@ class MonitorWorker @AssistedInject constructor(
.catch {
log(TAG, WARN) { "MonitorMode Flow failed:\n${it.asLog()}" }
}
.launchIn(workerScope)
.launchIn(monitorScope)
popUpReaction.monitor()
.onEach {
@@ -214,24 +223,48 @@ class MonitorWorker @AssistedInject constructor(
}
.setupCommonEventHandlers(TAG) { "popUpReaction" }
.catch { log(TAG, WARN) { "popUpReaction failed:\n${it.asLog()}" } }
.launchIn(workerScope)
.launchIn(monitorScope)
playPause.monitor()
.setupCommonEventHandlers(TAG) { "playPause" }
.catch { log(TAG, WARN) { "playPause failed:\n${it.asLog()}" } }
.launchIn(workerScope)
.launchIn(monitorScope)
autoConnect.monitor()
.setupCommonEventHandlers(TAG) { "autoConnect" }
.catch { log(TAG, WARN) { "autoConnect failed:\n${it.asLog()}" } }
.launchIn(workerScope)
.launchIn(monitorScope)
log(TAG, VERBOSE) { "Monitor job is active" }
monitorJob.join()
log(TAG, VERBOSE) { "Monitor job quit" }
}
override fun onDestroy() {
log(TAG, VERBOSE) { "onDestroy()" }
monitorScope.cancel("Service destroyed")
if (generalSettings.useExtraMonitorNotification.value && !generalSettings.keepConnectedNotificationAfterDisconnect.value) {
try {
notificationManager.cancel(MonitorNotifications.NOTIFICATION_ID_CONNECTED)
} catch (e: Exception) {
log(TAG, WARN) { "Failed to cancel connected notification: ${e.message}" }
}
}
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
companion object {
val TAG = logTag("Monitor", "Worker")
val TAG = logTag("Monitor", "Service")
private const val EXTRA_FORCE_START = "extra.force_start"
fun intent(context: Context, forceStart: Boolean = false): Intent {
return Intent(context, MonitorService::class.java).apply {
putExtra(EXTRA_FORCE_START, forceStart)
}
}
}
}
@@ -5,7 +5,15 @@ import android.view.View
import android.widget.RemoteViews
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.formatBatteryPercent
import eu.darken.capod.pods.core.getBatteryDrawable
import javax.inject.Inject
@@ -23,48 +31,48 @@ class MonitorNotificationViewFactory @Inject constructor(
context.packageName,
R.layout.monitor_notification_dual_pods_small
).apply {
device.apply {
// Left
setImageViewResource(R.id.pod_left_icon, device.leftPodIcon)
setTextViewText(R.id.pod_left_label, getBatteryLevelLeftPod(context))
val isLeftPodCharging = (device as? HasChargeDetectionDual)?.isLeftPodCharging ?: false
setViewVisibility(R.id.pod_left_charging, if (isLeftPodCharging) View.VISIBLE else View.GONE)
val isLeftPodInEar = (device as? HasEarDetectionDual)?.isLeftPodInEar ?: false
setViewVisibility(R.id.pod_left_ear, if (isLeftPodInEar) View.VISIBLE else View.GONE)
// Left
val leftPercent = device.batteryLeftPodPercent
setImageViewResource(R.id.pod_left_icon, device.leftPodIcon)
setTextViewText(R.id.pod_left_label, formatBatteryPercent(context, leftPercent))
val isLeftPodCharging = (device as? HasChargeDetectionDual)?.isLeftPodCharging ?: false
setViewVisibility(R.id.pod_left_charging, if (isLeftPodCharging) View.VISIBLE else View.GONE)
val isLeftPodInEar = (device as? HasEarDetectionDual)?.isLeftPodInEar ?: false
setViewVisibility(R.id.pod_left_ear, if (isLeftPodInEar) View.VISIBLE else View.GONE)
// Case
setViewVisibility(R.id.pod_case_charging, if (device is HasCase) View.VISIBLE else View.GONE)
(device as? HasCase)?.let { case ->
setImageViewResource(R.id.pod_case_icon, device.caseIcon)
setTextViewText(R.id.pod_case_label, case.getBatteryLevelCase(context))
setViewVisibility(R.id.pod_case_charging, if (case.isCaseCharging) View.VISIBLE else View.GONE)
}
// Right
setImageViewResource(R.id.pod_right_icon, device.rightPodIcon)
setTextViewText(R.id.pod_right_label, getBatteryLevelRightPod(context))
val isRightPodCharging = (device as? HasChargeDetectionDual)?.isRightPodCharging ?: false
setViewVisibility(R.id.pod_right_charging, if (isRightPodCharging) View.VISIBLE else View.GONE)
val isRightPodInEar = (device as? HasEarDetectionDual)?.isRightPodInEar ?: false
setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE)
// Case
setViewVisibility(R.id.pod_case_charging, if (device is HasCase) View.VISIBLE else View.GONE)
(device as? HasCase)?.let { case ->
setImageViewResource(R.id.pod_case_icon, device.caseIcon)
val casePercent = case.batteryCasePercent
setTextViewText(R.id.pod_case_label, formatBatteryPercent(context, casePercent))
setViewVisibility(R.id.pod_case_charging, if (case.isCaseCharging) View.VISIBLE else View.GONE)
}
// Right
val rightPercent = device.batteryRightPodPercent
setImageViewResource(R.id.pod_right_icon, device.rightPodIcon)
setTextViewText(R.id.pod_right_label, formatBatteryPercent(context, rightPercent))
val isRightPodCharging = (device as? HasChargeDetectionDual)?.isRightPodCharging ?: false
setViewVisibility(R.id.pod_right_charging, if (isRightPodCharging) View.VISIBLE else View.GONE)
val isRightPodInEar = (device as? HasEarDetectionDual)?.isRightPodInEar ?: false
setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE)
}
private fun createSinglePod(device: SinglePodDevice): RemoteViews = RemoteViews(
context.packageName,
R.layout.monitor_notification_single_pods_small
).apply {
device.apply {
setTextViewText(R.id.headphones_label, getLabel(context))
setImageViewResource(R.id.headphones_icon, device.iconRes)
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(batteryHeadsetPercent))
setTextViewText(R.id.headphones_battery_label, getBatteryLevelHeadset(context))
if (this is HasEarDetection) {
setViewVisibility(R.id.headphones_worn, if (isBeingWorn) View.VISIBLE else View.GONE)
}
if (this is HasChargeDetectionDual) {
setViewVisibility(R.id.headphones_charging, if (isHeadsetBeingCharged) View.VISIBLE else View.GONE)
}
val headsetPercent = device.batteryHeadsetPercent
setTextViewText(R.id.headphones_label, device.getLabel(context))
setImageViewResource(R.id.headphones_icon, device.iconRes)
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(headsetPercent))
setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent))
if (device is HasEarDetection) {
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn) View.VISIBLE else View.GONE)
}
if (device is HasChargeDetectionDual) {
setViewVisibility(R.id.headphones_charging, if (device.isHeadsetBeingCharged) View.VISIBLE else View.GONE)
}
}
@@ -1,22 +1,18 @@
package eu.darken.capod.monitor.ui
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import androidx.core.app.NotificationCompat
import androidx.work.ForegroundInfo
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.hasApiLevel
import eu.darken.capod.common.notifications.PendingIntentCompat
import eu.darken.capod.main.ui.MainActivity
import eu.darken.capod.pods.core.DualPodDevice
@@ -25,10 +21,7 @@ import eu.darken.capod.pods.core.HasChargeDetection
import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.getBatteryLevelCase
import eu.darken.capod.pods.core.getBatteryLevelHeadset
import eu.darken.capod.pods.core.getBatteryLevelLeftPod
import eu.darken.capod.pods.core.getBatteryLevelRightPod
import eu.darken.capod.pods.core.formatBatteryPercent
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
@@ -112,11 +105,14 @@ class MonitorNotifications @Inject constructor(
val batteryText = when (device) {
is DualPodDevice -> {
val left = device.getBatteryLevelLeftPod(context)
val right = device.getBatteryLevelRightPod(context)
val leftPercent = device.batteryLeftPodPercent
val rightPercent = device.batteryRightPodPercent
val left = formatBatteryPercent(context, leftPercent)
val right = formatBatteryPercent(context, rightPercent)
when {
device is HasCase -> {
val case = device.getBatteryLevelCase(context)
val casePercent = device.batteryCasePercent
val case = formatBatteryPercent(context, casePercent)
"$left $case $right"
}
@@ -125,10 +121,12 @@ class MonitorNotifications @Inject constructor(
}
is SinglePodDevice -> {
val headset = device.getBatteryLevelHeadset(context)
val headsetPercent = device.batteryHeadsetPercent
val headset = formatBatteryPercent(context, headsetPercent)
when {
device is HasCase -> {
val case = device.getBatteryLevelCase(context)
val casePercent = device.batteryCasePercent
val case = formatBatteryPercent(context, casePercent)
"$headset $case"
}
@@ -160,25 +158,9 @@ class MonitorNotifications @Inject constructor(
}.build()
}
suspend fun getForegroundInfo(podDevice: PodDevice?): ForegroundInfo = builderLock.withLock {
getBuilder(podDevice).apply {
setChannelId(NOTIFICATION_CHANNEL_ID)
}.toForegroundInfo()
}
@SuppressLint("InlinedApi")
private fun NotificationCompat.Builder.toForegroundInfo(): ForegroundInfo = if (hasApiLevel(29)) {
ForegroundInfo(
NOTIFICATION_ID,
this.build(),
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
)
} else {
ForegroundInfo(
NOTIFICATION_ID,
this.build()
)
}
fun getStartupNotification(): Notification = getBuilder(null).apply {
setChannelId(NOTIFICATION_CHANNEL_ID)
}.build()
companion object {
val TAG = logTag("Monitor", "Notifications")
@@ -8,20 +8,8 @@ import java.time.Duration
import java.time.Instant
import kotlin.math.roundToInt
fun DualPodDevice.getBatteryLevelLeftPod(context: Context): String =
batteryLeftPodPercent?.let { "${(it * 100).roundToInt()}%" }
?: context.getString(R.string.general_value_not_available_label)
fun DualPodDevice.getBatteryLevelRightPod(context: Context): String =
batteryRightPodPercent?.let { "${(it * 100).roundToInt()}%" }
?: context.getString(R.string.general_value_not_available_label)
fun HasCase.getBatteryLevelCase(context: Context): String =
batteryCasePercent?.let { "${(it * 100).roundToInt()}%" }
?: context.getString(R.string.general_value_not_available_label)
fun SinglePodDevice.getBatteryLevelHeadset(context: Context): String =
batteryHeadsetPercent?.let { "${(it * 100).roundToInt()}%" }
fun formatBatteryPercent(context: Context, percent: Float?): String =
percent?.let { "${(it * 100).roundToInt()}%" }
?: context.getString(R.string.general_value_not_available_label)
fun PodDevice.getSignalQuality(context: Context): String {
@@ -42,18 +30,17 @@ fun getBatteryDrawable(percent: Float?): Int = when {
else -> R.drawable.ic_baseline_battery_0_bar_24
}
private val lastSeenFormatter = RelativeDateTimeFormatter.getInstance()
fun PodDevice.lastSeenFormatted(now: Instant): String {
val formatter = RelativeDateTimeFormatter.getInstance()
val duration = Duration.between(seenLastAt, now)
return if (duration > Duration.ofMinutes(1)) {
lastSeenFormatter.format(
formatter.format(
duration.toMinutes().toDouble(),
RelativeDateTimeFormatter.Direction.LAST,
RelativeDateTimeFormatter.RelativeUnit.MINUTES
)
} else {
lastSeenFormatter.format(
formatter.format(
duration.seconds.toDouble(),
RelativeDateTimeFormatter.Direction.LAST,
RelativeDateTimeFormatter.RelativeUnit.SECONDS
@@ -62,8 +49,9 @@ fun PodDevice.lastSeenFormatted(now: Instant): String {
}
fun PodDevice.firstSeenFormatted(now: Instant): String {
val formatter = RelativeDateTimeFormatter.getInstance()
val duration = Duration.between(seenFirstAt, now)
return lastSeenFormatter.format(
return formatter.format(
duration.toMinutes().toDouble(),
RelativeDateTimeFormatter.Direction.LAST,
RelativeDateTimeFormatter.RelativeUnit.MINUTES
@@ -12,7 +12,13 @@ import eu.darken.capod.R
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.databinding.PopupNotificationDualPodsBinding
import eu.darken.capod.databinding.PopupNotificationSinglePodsBinding
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.formatBatteryPercent
import eu.darken.capod.pods.core.getBatteryDrawable
import eu.darken.capod.pods.core.getSignalQuality
import javax.inject.Inject
@@ -33,43 +39,43 @@ class PopUpPodViewFactory @Inject constructor(
private fun createDualPods(parent: ViewGroup, device: DualPodDevice): View =
PopupNotificationDualPodsBinding.inflate(layoutInflater, parent, false).apply {
device.apply {
podIcon.setImageResource(iconRes)
podLabel.text = getLabel(context)
signal.text = getSignalQuality(context)
signal.isInvisible = debugSettings.isDebugModeEnabled.value
podIcon.setImageResource(device.iconRes)
podLabel.text = device.getLabel(context)
signal.text = device.getSignalQuality(context)
signal.isInvisible = debugSettings.isDebugModeEnabled.value
// Left
podLeftIcon.setImageResource(device.leftPodIcon)
podLeftBatteryIcon.setImageResource(getBatteryDrawable(batteryLeftPodPercent))
podLeftBatteryLabel.text = getBatteryLevelLeftPod(context)
// Left
val leftPercent = device.batteryLeftPodPercent
podLeftIcon.setImageResource(device.leftPodIcon)
podLeftBatteryIcon.setImageResource(getBatteryDrawable(leftPercent))
podLeftBatteryLabel.text = formatBatteryPercent(context, leftPercent)
// Case
podCaseContainer.isVisible = device is HasCase
(device as? HasCase)?.let { case ->
podCaseIcon.setImageResource(case.caseIcon)
podCaseBatteryIcon.setImageResource(getBatteryDrawable(case.batteryCasePercent))
podCaseBatteryLabel.text = case.getBatteryLevelCase(context)
}
// Right
podRightIcon.setImageResource(device.rightPodIcon)
podRightBatteryIcon.setImageResource(getBatteryDrawable(batteryRightPodPercent))
podRightBatteryLabel.text = getBatteryLevelRightPod(context)
// Case
podCaseContainer.isVisible = device is HasCase
(device as? HasCase)?.let { case ->
val casePercent = case.batteryCasePercent
podCaseIcon.setImageResource(case.caseIcon)
podCaseBatteryIcon.setImageResource(getBatteryDrawable(casePercent))
podCaseBatteryLabel.text = formatBatteryPercent(context, casePercent)
}
// Right
val rightPercent = device.batteryRightPodPercent
podRightIcon.setImageResource(device.rightPodIcon)
podRightBatteryIcon.setImageResource(getBatteryDrawable(rightPercent))
podRightBatteryLabel.text = formatBatteryPercent(context, rightPercent)
}.root
private fun createSinglePod(parent: ViewGroup, device: SinglePodDevice): View =
PopupNotificationSinglePodsBinding.inflate(layoutInflater, parent, false).apply {
device.apply {
headphonesIcon.setImageResource(iconRes)
headphonesLabel.text = getLabel(context)
signal.text = getSignalQuality(context)
signal.isInvisible = debugSettings.isDebugModeEnabled.value
headphonesIcon.setImageResource(device.iconRes)
headphonesLabel.text = device.getLabel(context)
signal.text = device.getSignalQuality(context)
signal.isInvisible = debugSettings.isDebugModeEnabled.value
headphonesBatteryIcon.setImageResource(getBatteryDrawable(batteryHeadsetPercent))
headphonesBatteryLabel.text = getBatteryLevelHeadset(context)
}
val headsetPercent = device.batteryHeadsetPercent
headphonesBatteryIcon.setImageResource(getBatteryDrawable(headsetPercent))
headphonesBatteryLabel.text = formatBatteryPercent(context, headsetPercent)
}.root
}
+2 -2
View File
@@ -5,14 +5,14 @@
<item
android:id="@+id/menu_item_donate"
android:icon="@drawable/ic_baseline_heart_24"
android:title="@string/settings_general_label"
android:title="@string/general_donate_action"
android:visible="false"
tool:visible="true"
app:showAsAction="always" />
<item
android:id="@+id/menu_item_upgrade"
android:icon="@drawable/ic_baseline_stars_24"
android:title="@string/settings_general_label"
android:title="@string/general_upgrade_action"
android:visible="false"
tool:visible="true"
app:showAsAction="always" />
@@ -212,5 +212,22 @@
<string name="profiles_delete_message">¿Estás seguro de que quieres eliminar este perfil? Esto no se puede deshacer.</string>
<string name="profiles_delete_action">Borrar</string>
<string name="profiles_basic_info_title">Información del dispositivo</string>
<string name="profiles_basic_info_description">Configure el nombre de su dispositivo, modelo y sincronización opcional con Bluetooth.</string>
<string name="profiles_signal_quality_title">Calidad mínima de la señal</string>
<string name="profiles_signal_quality_description">Solo detecta dispositivos con una intensidad de señal superior a este umbral. Valores más bajos aumentan el rango de detección pero pueden causar falsos positivos. No lo configures demasiado alto; la recepción de Bluetooth es generalmente mala y varía con la distancia y los obstáculos.</string>
<string name="profiles_identitykey_label">Clave de Identidad</string>
<string name="profilessettings_maindevice_identitykey_description">La Clave de Resolución de Identidad (IRK) de su dispositivo, que ayuda a CAPod a identificarlo entre los dispositivos cercanos.</string>
<string name="profiles_maindevice_identitykey_explanation">Los AirPods cambian con frecuencia su dirección Bluetooth por motivos de privacidad. El IRK ayuda a CAPod a reconocer tu dispositivo. Necesitas acceso único a un MacBook.</string>
<string name="profiles_maindevice_encryptionkey_label">Clave de cifrado</string>
<string name="profiles_maindevice_encryptionkey_description">La clave de cifrado de tu dispositivo, que permite a CAPod recuperar información detallada sobre el estado.</string>
<string name="profiles_maindevice_encryptionkey_explanation">Los AirPods envían un mensaje de estado, parte del cual está cifrado. La clave de cifrado permite a CAPod descifrar el mensaje completo. Se necesita acceso único a un MacBook.</string>
<string name="profiles_key_invalid_format">El formato de la clave no es correcta</string>
<string name="profiles_key_expected_format">Formato esperado: %1$s</string>
<string name="profiles_priority_hint">El orden de los perfiles determina la prioridad. Arrastra los perfiles para reordenarlos: los perfiles que aparecen más arriba en la lista tienen prioridad cuando coinciden varios dispositivos.</string>
<!-- Unsaved changes dialog -->
<string name="general_unsaved_changes_title">Cambios sin guardar</string>
<string name="general_unsaved_changes_message">Tienes cambios sin guardar. ¿Qué quieres hacer?</string>
<string name="general_save_and_exit_action">Guardar y salir</string>
<string name="general_discard_action">Descartar</string>
<string name="general_keep_editing_action">Seguir editando</string>
</resources>
@@ -212,5 +212,22 @@
<string name="profiles_delete_message">¿Estás seguro de que quieres eliminar este perfil? Esto no se puede deshacer.</string>
<string name="profiles_delete_action">Borrar</string>
<string name="profiles_basic_info_title">Información del dispositivo</string>
<string name="profiles_basic_info_description">Configure el nombre de su dispositivo, modelo y sincronización opcional con Bluetooth.</string>
<string name="profiles_signal_quality_title">Calidad mínima de la señal</string>
<string name="profiles_signal_quality_description">Solo detecta dispositivos con una intensidad de señal superior a este umbral. Valores más bajos aumentan el rango de detección pero pueden causar falsos positivos. No lo configures demasiado alto; la recepción de Bluetooth es generalmente mala y varía con la distancia y los obstáculos.</string>
<string name="profiles_identitykey_label">Clave de Identidad</string>
<string name="profilessettings_maindevice_identitykey_description">La Clave de Resolución de Identidad (IRK) de su dispositivo, que ayuda a CAPod a identificarlo entre los dispositivos cercanos.</string>
<string name="profiles_maindevice_identitykey_explanation">Los AirPods cambian con frecuencia su dirección Bluetooth por motivos de privacidad. El IRK ayuda a CAPod a reconocer tu dispositivo. Necesitas acceso único a un MacBook.</string>
<string name="profiles_maindevice_encryptionkey_label">Clave de cifrado</string>
<string name="profiles_maindevice_encryptionkey_description">La clave de cifrado de tu dispositivo, que permite a CAPod recuperar información detallada sobre el estado.</string>
<string name="profiles_maindevice_encryptionkey_explanation">Los AirPods envían un mensaje de estado, parte del cual está cifrado. La clave de cifrado permite a CAPod descifrar el mensaje completo. Se necesita acceso único a un MacBook.</string>
<string name="profiles_key_invalid_format">El formato de la clave no es correcta</string>
<string name="profiles_key_expected_format">Formato esperado: %1$s</string>
<string name="profiles_priority_hint">El orden de los perfiles determina la prioridad. Arrastra los perfiles para reordenarlos: los perfiles que aparecen más arriba en la lista tienen prioridad cuando coinciden varios dispositivos.</string>
<!-- Unsaved changes dialog -->
<string name="general_unsaved_changes_title">Cambios sin guardar</string>
<string name="general_unsaved_changes_message">Tienes cambios sin guardar. ¿Qué quieres hacer?</string>
<string name="general_save_and_exit_action">Guardar y salir</string>
<string name="general_discard_action">Descartar</string>
<string name="general_keep_editing_action">Seguir editando</string>
</resources>
+17
View File
@@ -212,5 +212,22 @@
<string name="profiles_delete_message">¿Estás seguro de que quieres eliminar este perfil? Esto no se puede deshacer.</string>
<string name="profiles_delete_action">Borrar</string>
<string name="profiles_basic_info_title">Información del dispositivo</string>
<string name="profiles_basic_info_description">Configure el nombre de su dispositivo, modelo y sincronización opcional con Bluetooth.</string>
<string name="profiles_signal_quality_title">Calidad mínima de la señal</string>
<string name="profiles_signal_quality_description">Solo detecta dispositivos con una intensidad de señal superior a este umbral. Valores más bajos aumentan el rango de detección pero pueden causar falsos positivos. No lo configures demasiado alto; la recepción de Bluetooth es generalmente mala y varía con la distancia y los obstáculos.</string>
<string name="profiles_identitykey_label">Clave de Identidad</string>
<string name="profilessettings_maindevice_identitykey_description">La Clave de Resolución de Identidad (IRK) de su dispositivo, que ayuda a CAPod a identificarlo entre los dispositivos cercanos.</string>
<string name="profiles_maindevice_identitykey_explanation">Los AirPods cambian con frecuencia su dirección Bluetooth por motivos de privacidad. El IRK ayuda a CAPod a reconocer tu dispositivo. Necesitas acceso único a un MacBook.</string>
<string name="profiles_maindevice_encryptionkey_label">Clave de cifrado</string>
<string name="profiles_maindevice_encryptionkey_description">La clave de cifrado de tu dispositivo, que permite a CAPod recuperar información detallada sobre el estado.</string>
<string name="profiles_maindevice_encryptionkey_explanation">Los AirPods envían un mensaje de estado, parte del cual está cifrado. La clave de cifrado permite a CAPod descifrar el mensaje completo. Se necesita acceso único a un MacBook.</string>
<string name="profiles_key_invalid_format">El formato de la clave no es correcta</string>
<string name="profiles_key_expected_format">Formato esperado: %1$s</string>
<string name="profiles_priority_hint">El orden de los perfiles determina la prioridad. Arrastra los perfiles para reordenarlos: los perfiles que aparecen más arriba en la lista tienen prioridad cuando coinciden varios dispositivos.</string>
<!-- Unsaved changes dialog -->
<string name="general_unsaved_changes_title">Cambios sin guardar</string>
<string name="general_unsaved_changes_message">Tienes cambios sin guardar. ¿Qué quieres hacer?</string>
<string name="general_save_and_exit_action">Guardar y salir</string>
<string name="general_discard_action">Descartar</string>
<string name="general_keep_editing_action">Seguir editando</string>
</resources>
+10
View File
@@ -28,6 +28,8 @@
<string name="settings_autopause_description">Mettre le son en pause si vous ôtez lappareil de votre oreille.</string>
<string name="settings_autopplay_label">Lecture automatique</string>
<string name="settings_autoplay_description">Démarrer la lecture du son quand lappareil est porté.</string>
<string name="settings_eardetection_info_label">Note de détection de loreille</string>
<string name="settings_eardetection_info_description">Si la détection de loreille ne fonctionne que pour un seul AirPod, cela est causé par une limitation dApple. Seul « lAirPod principal » (utilisé pour le microphone) est détecté. Configurez dans les appareils Apple : Paramètres → Bluetooth → AirPods → Microphone.</string>
<string name="settings_fake_data_label">Fausses données</string>
<string name="settings_fake_data_description">Afficher les fausses données, c.-à-d. simuler les appareils qui nexistent pas.</string>
<string name="settings_debug_label">Paramètres de débogage</string>
@@ -98,6 +100,10 @@
<string name="translators_thanks_title">Traducteurs</string>
<string name="translators_thanks_description">yahoe-001</string>
<string name="widget_description">Un widget qui affiche le dernier état connu de lappareil.</string>
<string name="widget_configuration_title">Choisir un appareil</string>
<string name="widget_configuration_description">Choisissez le profil dappareil que ce widget doit afficher.</string>
<string name="common_feature_requires_pro_msg">Cette fonction est offerte avec CAPod Pro.</string>
<string name="widget_no_data_label">Aucune donnée</string>
<string name="settings_compat_indirectcallback_title">Transmission indirecte des données</string>
<string name="settings_compat_indirectcallback_summary">Utiliser une méthode de remplacement pour recevoir les données BÉB du système (diffusion au lieu de rappel).</string>
<string name="troubleshooter_title">Dépannage</string>
@@ -134,6 +140,10 @@
<string name="overview_monitoring_active_label">Surveillance des appareils</string>
<string name="overview_monitoring_active_description">Assurez-vous que votre appareil est proche et actif.</string>
<string name="overview_unmatched_devices_label">Appareils sans correspondance</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">%d appareil sans profil correspondant</item>
<item quantity="other">%d appareils sans profil correspondant</item>
</plurals>
<string name="permission_bluetooth_connect_label">Connexion Bluetooth</string>
<string name="permission_bluetooth_connect_description">Cette appli exige lautorisation « Connexion Bluetooth » pour interagir avec les appareils jumelés et démarrer les connexions.</string>
<string name="permission_bluetooth_scan_label">Analyse Bluetooth</string>
+41 -4
View File
@@ -23,7 +23,7 @@
<string name="settings_keep_notification_after_disconnect_label">Melding behouden na verbreken verbinding</string>
<string name="settings_keep_notification_after_disconnect_description">Blijf de laatst bekende batterijniveaus weergeven, zelfs nadat je AirPods zijn losgekoppeld</string>
<string name="settings_scanner_mode_label">Scannermodus</string>
<string name="settings_scanner_mode_description">Moet de Bluetooth Low Energy-gegevensscanner prioriteit geven aan prestaties of energie besparen?</string>
<string name="settings_scanner_mode_description">Moet de \'Bluetooth Low Energy\'-datascanner prioriteit geven aan prestaties of aan energiebesparing?</string>
<string name="settings_autopause_label">Auto pauze</string>
<string name="settings_autopause_description">Pauzeer de muziek wanneer u het apparaat van uw oor haalt.</string>
<string name="settings_autopplay_label">Automatisch afspelen</string>
@@ -49,7 +49,7 @@
<string name="settings_category_compatibility_options_description">Niet aankomen als alles werkt ;-)</string>
<string name="settings_compat_offloaded_filtering_disabled_title">Hardware filtering uitzetten</string>
<string name="settings_compat_offloaded_filtering_disabled_summary">Data filtering niet aan het systeem toevertrouwen, in plaats daarvan alle data in de app ontvangen en filteren.</string>
<string name="settings_compat_offloaded_batching_disabled_title">Hardware batching uitzetten</string>
<string name="settings_compat_offloaded_batching_disabled_title">Schakel hardwarebatchverwerking uit.</string>
<string name="settings_compat_offloaded_batching_disabled_summary">Laat de systeemgroep geen BLE-gegevens verzamelen voordat deze naar ons wordt doorgestuurd.</string>
<string name="settings_onepod_mode_label">Eén pod-modus</string>
<string name="settings_onepod_mode_description">Het dragen van beide pods is niet vereist, het dragen van een enkele pod is voldoende om reacties uit te lokken.</string>
@@ -151,7 +151,7 @@
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Deze app heeft de toestemming \'Bluetooth\' nodig om verbinding te maken met gekoppelde Bluetooth-apparaten.</string>
<string name="permission_access_fine_location_label">Toegang tot een prima locatie</string>
<string name="permission_access_fine_location_description">CAPod gebruikt de \'prima locatie\'-machtiging om Bluetooth Low Energy-gegevens te ontvangen. Je hoofdtelefoon gebruikt Bluetooth Low Energy-technologie om zijn status uit te zenden. Deze app gebruikt GEEN Bluetooth-gegevens om je locatie te bepalen.</string>
<string name="permission_access_fine_location_description">CAPod gebruikt de \'precieze locatie\'-machtiging om Bluetooth Low Energy-gegevens te ontvangen. Je hoofdtelefoon gebruikt Bluetooth Low Energy-technologie om zijn status uit te zenden. Deze app gebruikt GEEN Bluetooth-gegevens om je locatie te bepalen.</string>
<string name="permission_background_location_label">Toegang tot achtergrondlocatie</string>
<string name="permission_background_location_description">CAPod gebruikt \'locatietoegang op de achtergrond\' om functies zoals \'Pop-up weergeven\' en \'Automatisch verbinden\' in te schakelen terwijl de app gesloten is. Met locatietoegang op de achtergrond kan de app Bluetooth Low Energy-gegevens ontvangen terwijl deze op de achtergrond actief is. Deze app gebruikt GEEN Bluetooth-gegevens om je locatie te bepalen.</string>
<string name="permission_ignore_battery_optimizations_label">Batterij-optimalisaties uitschakelen</string>
@@ -180,13 +180,50 @@
<string name="pods_connection_state_ringing_label">Rinkelen</string>
<string name="pods_connection_state_hanging_up_label">Ophangen</string>
<string name="pods_connection_state_unknown_label">Onbekende verbindingsstatus</string>
<string name="pods_unknown_label">Onbekend apparaat</string>
<string name="pods_unknown_contact_dev">Dit is een onbekend apparaat, maar het gebruikt hetzelfde berichtformaat</string>
<string name="pods_none_label_short">Geen apparaat</string>
<string name="pods_charging_label">Opladen</string>
<string name="pods_inear_label">In oor</string>
<string name="pods_microphone_label">Microfoon</string>
<string name="pods_yours">Uw</string>
<string name="headset_being_worn_label">Wordt gedragen</string>
<string name="headset_not_being_worn_label">Wordt niet gedragen</string>
<string name="pods_case_unknown_state">Onbekende status</string>
<string name="last_seen_x">Laatst gezien: %s</string>
<string name="first_seen_x">Voor het eerst gezien: %s</string>
<string name="permission_post_notifications_label">Toon notificaties</string>
<string name="permission_post_notifications_description">"Sta Capod toe om notificaties te tonen over je Airpods, bijvoorbeeld de huidige verbindingsstatus."</string>
<!-- Device profiles -->
<string name="profiles_empty_title">Geen apparaatprofielen geconfigureerd</string>
<string name="profiles_empty_description">Maak apparaat profielen voor meerdere apparaten met aangepaste instellingen en prioriteiten.</string>
<string name="profiles_add_action">Profiel toevoegen</string>
<string name="profiles_create_title">Profiel aanmaken</string>
<string name="profiles_name_label">Profiel naam</string>
<string name="profiles_name_default">Mijn koptelefoon</string>
<string name="profiles_model_label">Apparaatmodel</string>
<string name="profiles_paired_device_label">Gekoppeld apparaat</string>
<string name="profiles_paired_device_none">Geen</string>
<string name="profiles_paired_device_none_description">Geen apparaat geselecteerd</string>
<string name="profiles_save_action">Profiel opslaan</string>
<string name="profiles_drag_handle_description">Sleep om de volgorde aan te passen</string>
<string name="profiles_delete_title">Verwijder profiel</string>
<string name="profiles_delete_message">Ben je zeker dat je dit profiel wilt verwijderen? Dit kan niet ongedaan worden gemaakt.</string>
<string name="profiles_delete_action">Verwijderen</string>
<string name="profiles_basic_info_title">Apparaat informatie</string>
<string name="profiles_basic_info_description">Configureer jouw apparaat naam, model en optioneel bluetooth koppeling.</string>
<string name="profiles_signal_quality_title">Minimale signaal kwaliteit</string>
<string name="profiles_signal_quality_description">Detecteer alleen apparaten met een signaalsterkte boven deze drempelwaarde. Lagere waarden vergroten het detectiebereik, maar kunnen valse positieven veroorzaken. Stel deze waarde niet te hoog in - Bluetooth-ontvangst is over het algemeen slecht en varieert met de afstand en obstakels.</string>
<string name="profiles_identitykey_label">Identiteitssleutel</string>
<string name="profilessettings_maindevice_identitykey_description">De Identity Resolving Key (IRK) van uw apparaat, waarmee CAPod uw apparaat kan identificeren tussen apparaten in de buurt.</string>
<string name="profiles_maindevice_identitykey_explanation">AirPods veranderen regelmatig hun Bluetooth-adres om privacyredenen. De IRK helpt CAPod uw apparaat te herkennen. U hebt eenmalig toegang tot een MacBook nodig.</string>
<string name="profiles_maindevice_encryptionkey_label">Encryptiesleutel</string>
<string name="profiles_maindevice_encryptionkey_explanation">AirPods sturen een statusbericht, waarvan een deel versleuteld is. De encryptiesleutel stelt CAPod in staat het volledige bericht te ontsleutelen. Je hebt hiervoor eenmalige toegang tot een MacBook nodig.</string>
<string name="profiles_key_invalid_format">Ongeldige sleutelindeling</string>
<string name="profiles_key_expected_format">Verwachte indeling: %1$s</string>
<string name="profiles_priority_hint">De volgorde van de profielen bepaalt de prioriteit. Versleep profielen om de volgorde te wijzigen. Profielen hoger in de lijst krijgen voorrang wanneer meerdere apparaten overeenkomen.</string>
<!-- Unsaved changes dialog -->
<string name="general_unsaved_changes_title">Niet-opgeslagen wijzigingen</string>
<string name="general_unsaved_changes_title">Niet opgeslagen wijzigingen</string>
<string name="general_unsaved_changes_message">Er zijn nog geen wijzigingen opgeslagen. Wat wil je doen?</string>
<string name="general_save_and_exit_action">Opslaan &amp; afsluiten</string>
<string name="general_discard_action">Weggooien</string>
@@ -11,6 +11,8 @@
<string name="general_save_action">Salvar</string>
<string name="general_guide_action">Guia</string>
<string name="general_continue_action">Continuar</string>
<string name="general_show_action">Exibir</string>
<string name="general_hide_action">Ocultar</string>
<string name="general_example_label">Ex.: %s</string>
<string name="upgrade_capod_label">Atualizar CAPod</string>
<string name="upgrade_capod_description">Obtenha recursos adicionais e apoie o desenvolvedor.</string>
@@ -18,6 +20,8 @@
<string name="settings_monitor_mode_description">Em quais circunstâncias este aplicativo monitora os dados do Bluetooth.</string>
<string name="settings_monitor_connected_notification_label">Notificação extra</string>
<string name="settings_monitor_connected_notification_description">Mostra uma notificação extra quando um dispositivo está conectado. Isso permite que você oculte a notificação permanente \"Nenhum dispositivo\" desativando o canal \"Status do dispositivo\".</string>
<string name="settings_keep_notification_after_disconnect_label">Manter notificação após desconectar</string>
<string name="settings_keep_notification_after_disconnect_description">Continuar mostrando os últimos níveis de bateria registrados mesmo após a desconexão do AirPods</string>
<string name="settings_scanner_mode_label">Modo de scanner</string>
<string name="settings_scanner_mode_description">O scanner de dados Bluetooth de Baixo Consumo de Energia deve priorizar o desempenho ou economizar energia?</string>
<string name="settings_autopause_label">Pausa automática</string>
@@ -34,6 +38,8 @@
<string name="settings_autoconnect_description">Se o Android não se conectar automaticamente, também podemos solicitá-lo. Isso definirá a configuração do modo de monitoramento para \"Sempre\".</string>
<string name="settings_autoconnect_condition_label">Condição de conexão automática</string>
<string name="settings_autoconnect_condition_description">Quando devemos tentar nos conectar ao seu dispositivo?</string>
<string name="settings_devices_label">Dispositivos</string>
<string name="settings_devices_description">Gerenciar seus dispositivos</string>
<string name="settings_reaction_label">Reações</string>
<string name="settings_reaction_description">Reaja a eventos e comportamentos.</string>
<string name="settings_category_yourdevice_label">Seu dispositivo</string>
@@ -68,6 +74,7 @@
<string name="settings_support_description">Se você precisar de alguma ajuda.</string>
<string name="issue_tracker_label">Rastreador de problemas</string>
<string name="issue_tracker_description">Um rastreador de problemas público para relatórios de bugs e solicitações de recursos (somente em inglês).</string>
<string name="discord_label">Discord</string>
<string name="discord_description">Um lugar para conversar e tirar dúvidas.</string>
<string name="changelog_label">Registro de alterações</string>
<string name="settings_label">Configurações</string>
@@ -91,9 +98,14 @@
<string name="translators_thanks_title">Tradutores</string>
<string name="translators_thanks_description">Igor Silva (ferrare42@gmail.com)</string>
<string name="widget_description">Um widget que mostra o último status conhecido do dispositivo.</string>
<string name="widget_configuration_title">Selecionar Dispositivo</string>
<string name="widget_configuration_description">Escolha qual o perfil de dispositivo este widget deve exibir.</string>
<string name="common_feature_requires_pro_msg">Este recurso requer CAPod Pro.</string>
<string name="widget_no_data_label">Sem Informação</string>
<string name="settings_compat_indirectcallback_title">Entrega indireta de dados</string>
<string name="settings_compat_indirectcallback_summary">Use um método alternativo para receber dados BLE do sistema (transmissão em vez de retorno de chamada).</string>
<string name="troubleshooter_title">Solucionador de problemas</string>
<string name="troubleshooter_summary">Diagnostique e corrija problemas de conectividade Bluetooth.</string>
<string name="troubleshooter_ble_intro_title">Transmissões Bluetooth de baixa energia</string>
<string name="troubleshooter_ble_intro_body1">AirPods (e fones de ouvido semelhantes) transmitem informações de status usando uma tecnologia BLE chamada \"anúncios\". Alguns telefones não implementam essa tecnologia corretamente. O CAPod pode tentar corrigir isso tentando diferentes opções de compatibilidade até que os dados sejam recebidos. Inicie a reprodução de música em seus fones de ouvido e coloque-os perto do telefone e inicie o processo.</string>
<string name="troubleshooter_ble_intro_start_action">Iniciar solução de problemas</string>
@@ -112,6 +124,22 @@
<string name="onboarding_body3">O CAPod não possui anúncios e não coleta seus dados.</string>
<string name="onboarding_body4">Você pode atualizar para o CAPod Pro para obter recursos extras e apoiar o desenvolvimento.</string>
<!-- Strings from app-common -->
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">N/A</string>
<string name="general_error_label">Erro</string>
<string name="general_grant_permission_action">Conceder permissão</string>
<string name="general_manage_devices_action">Gerenciar dispositivos</string>
<string name="overview_nomaindevice_label">Sem dispositivos configurados</string>
<string name="overview_nomaindevice_description">Configure o seu dispositivo para começar a monitorar os níveis de bateria e habilitar recursos adicionais.</string>
<string name="settings_scanner_mode_lowpower_label">Baixa potência</string>
<string name="settings_scanner_mode_balanced_label">Balanceado</string>
<string name="settings_scanner_mode_lowlatency_label">Baixa latência</string>
<string name="settings_monitor_mode_manual_label">Quando o aplicativo está aberto</string>
<string name="settings_monitor_mode_automatic_label">Quando o dispositivo está conectado</string>
<string name="settings_monitor_mode_always_label">Sempre</string>
<string name="settings_reaction_autoconnect_whenseen_label">Quando visto</string>
<!-- Device profiles -->
<!-- Unsaved changes dialog -->
</resources>
+124 -7
View File
@@ -11,6 +11,8 @@
<string name="general_save_action">Зберегти</string>
<string name="general_guide_action">Посібник</string>
<string name="general_continue_action">Продовжити</string>
<string name="general_show_action">Показати</string>
<string name="general_hide_action">Приховати</string>
<string name="general_example_label">Напр.: %s</string>
<string name="upgrade_capod_label">Оновити CAPod</string>
<string name="upgrade_capod_description">Отримайте додаткові функції та підтримайте розробника.</string>
@@ -18,12 +20,16 @@
<string name="settings_monitor_mode_description">За яких умов ця програма відстежує дані Bluetooth.</string>
<string name="settings_monitor_connected_notification_label">Додаткове сповіщення</string>
<string name="settings_monitor_connected_notification_description">Показує додаткове сповіщення, коли пристрій підключено. Це дозволяє приховати постійне сповіщення \"Немає пристроїв\", вимкнувши канал \"Стан пристрою\".</string>
<string name="settings_keep_notification_after_disconnect_label">Залишати сповіщення після відключення</string>
<string name="settings_keep_notification_after_disconnect_description">Продовжувати показувати останні відомі рівні заряду навіть після відключення AirPods</string>
<string name="settings_scanner_mode_label">Режим сканера</string>
<string name="settings_scanner_mode_description">Сканер даних Bluetooth Low Energy має зосередитися на продуктивності чи збереженні заряду батареї?</string>
<string name="settings_autopause_label">Автопауза</string>
<string name="settings_autopause_description">Зупинка відтворення аудіо, коли навушник прибрано з Вашого вуха.</string>
<string name="settings_autopplay_label">Автовідтворення</string>
<string name="settings_autoplay_description">Почати відтворення коли ви одягли навушники.</string>
<string name="settings_eardetection_info_label">Примітка про виявлення вуха</string>
<string name="settings_eardetection_info_description">Якщо виявлення вуха працює лише для одного навушника, це обмеження Apple. Виявляється лише \"головний навушник\" (який використовується для мікрофона). Налаштуйте на пристроях Apple: Параметри → Bluetooth → AirPods → Мікрофон.</string>
<string name="settings_fake_data_label">Підроблені дані</string>
<string name="settings_fake_data_description">Показувати підроблені дані, коли, наприклад, імітуються пристрої яких не існує.</string>
<string name="settings_debug_label">Налаштування налагодження</string>
@@ -34,6 +40,8 @@
<string name="settings_autoconnect_description">Якщо Android не підʼєднує навушники автоматично, додаток може попросити зробити це. Ця опція встановить \"Режим відстеження\" в стан \"Завжди\".</string>
<string name="settings_autoconnect_condition_label">Умова автопід\'єднання</string>
<string name="settings_autoconnect_condition_description">Коли слід здійснювати спробу під\'єднання до Вашого пристрою?</string>
<string name="settings_devices_label">Пристрої</string>
<string name="settings_devices_description">Керування вашими пристроями.</string>
<string name="settings_reaction_label">Реакції</string>
<string name="settings_reaction_description">Реагування на події та дії.</string>
<string name="settings_category_yourdevice_label">Ваш пристрій</string>
@@ -51,14 +59,14 @@
<string name="settings_popup_connected_description">Показувати спливаюче вікно коли пристрій підключається вперше.</string>
<string name="notification_channel_device_status_label">Статус пристрою</string>
<string name="notification_channel_device_status_connected_label">Підключений пристрій</string>
<string name="support_debuglog_label">Журнал зневадження</string>
<string name="support_debuglog_label">Журнал налагодження</string>
<string name="support_debuglog_desc">Запис всього, що робить програма, у текстовий файл, яким можна поділитися.</string>
<string name="debug_debuglog_size_label">Розмір</string>
<string name="debug_debuglog_size_compressed_label">Стиснутий розмір</string>
<string name="debug_notification_channel_label">Повідомлення зневадження</string>
<string name="debug_debuglog_file_label">Файл журналу зневадження</string>
<string name="debug_debuglog_recording_progress">Запис журналу зневадження</string>
<string name="debug_debuglog_record_action">Записати журнал зневадження</string>
<string name="debug_notification_channel_label">Сповіщення налагодження</string>
<string name="debug_debuglog_file_label">Файл журналу налагодження</string>
<string name="debug_debuglog_recording_progress">Запис журналу налагодження</string>
<string name="debug_debuglog_record_action">Записати журнал налагодження</string>
<string name="debug_debuglog_stop_action">Зупинити запис</string>
<string name="debug_debuglog_sensitive_information_message">Створений файл містить чутливу інформацію (наприклад, відомості про пристрої Bluetooth). Діліться ним лише з довіреними особами.</string>
<string name="settings_debuglog_explanation">Ця функція записує все, що робить програма, у файл, яким можна ділитися. Створений файл містить чутливу інформацію (наприклад, відомості про пристрої Bluetooth). Діліться ним лише з довіреними особами (наприклад, розробником, що розв\'язує проблему).</string>
@@ -92,16 +100,21 @@
<string name="translators_thanks_title">Перекладачі</string>
<string name="translators_thanks_description">Yevhen Fastiuk (Євген Фастюк), Ievgen Gil (Євген Гіль)</string>
<string name="widget_description">Віджет, який показує останній відомий стан пристрою.</string>
<string name="widget_configuration_title">Оберіть пристрій</string>
<string name="widget_configuration_description">Оберіть, який профіль пристрою має відображати цей віджет.</string>
<string name="common_feature_requires_pro_msg">Ця функція вимагає CAPod Pro.</string>
<string name="widget_no_data_label">Немає даних</string>
<string name="settings_compat_indirectcallback_title">Непряме доставлення даних</string>
<string name="settings_compat_indirectcallback_summary">Використовувати альтернативний спосіб отримання даних BLE від системи (трансляція замість зворотного виклику).</string>
<string name="troubleshooter_title">Засіб усунення несправностей</string>
<string name="troubleshooter_summary">Діагностика та виправлення проблем з підключенням Bluetooth.</string>
<string name="troubleshooter_ble_intro_title">Трансляція Bluetooth Low Energy</string>
<string name="troubleshooter_ble_intro_body1">AirPods (і подібні навушники) транслюють інформацію про їх стан, використовуючи BLE технологію, яка називається \"рекламування\". Деякі смартфони не реалізовують цю технологію коректно. CAPod може спробувати усунути цю проблему, спробувавши різні опції сумісності, доки не отримає необхідні дані від пристрою. Увімкніть відтворення музики на ваших навушниках, покладіть їх поруч із смартфоном і розпочніть процес.</string>
<string name="troubleshooter_ble_intro_body1">AirPods (і подібні навушники) транслюють інформацію про їх стан, використовуючи BLE технологію, яка називається \"оголошення\". Деякі смартфони не реалізовують цю технологію коректно. CAPod може спробувати усунути цю проблему, спробувавши різні опції сумісності, доки не отримає необхідні дані від пристрою. Увімкніть відтворення музики на ваших навушниках, покладіть їх поруч із смартфоном і розпочніть процес.</string>
<string name="troubleshooter_ble_intro_start_action">Почати виправлення неполадок</string>
<string name="troubleshooter_ble_process_title">Триває виправлення неполадок</string>
<string name="troubleshooter_ble_process_subtile">Кожен крок займає 5–10 секунд</string>
<string name="troubleshooter_ble_result_success_title">Успішно</string>
<string name="troubleshooter_ble_result_success_body">CAPod приймає трансляцію BLE-реклами.</string>
<string name="troubleshooter_ble_result_success_body">CAPod приймає трансляцію BLE-оголошень.</string>
<string name="troubleshooter_ble_result_failure_title">Невдало</string>
<string name="troubleshooter_ble_result_failure_body">Не вдалося виправити неполадки. Жодна комбінація варіантів сумісності не допомогла.</string>
<string name="troubleshooter_ble_result_failure_phone_body">Ваш смартфон не отримав жодних даних BLE. Ви можете повторити цей тест у людному місці, щоб перевірити, чи можна отримати дані з інших джерел (окрім ваших навушників). Не отримання жодних даних свідчить про проблему з операційною системою вашого смартфону.</string>
@@ -113,6 +126,110 @@
<string name="onboarding_body3">CAPod не містить реклами та не збирає ваші дані.</string>
<string name="onboarding_body4">Ви можете оновитись до CAPod Pro, щоб отримати підтримку додаткових функцій та підтримати розробку.</string>
<!-- Strings from app-common -->
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">Н</string>
<string name="general_error_label">Помилка</string>
<string name="general_grant_permission_action">Надати дозвіл</string>
<string name="general_manage_devices_action">Керувати пристроями</string>
<string name="overview_nomaindevice_label">Пристрій не налаштовано</string>
<string name="overview_nomaindevice_description">Налаштуйте свій пристрій, щоб почати відстежувати рівень заряду та активувати додаткові функції.</string>
<string name="overview_bluetooth_disabled_label">Bluetooth вимкнено</string>
<string name="overview_bluetooth_disabled_description">Bluetooth вимкнено, увімкніть його ;)</string>
<string name="overview_monitoring_active_label">Пошук пристроїв</string>
<string name="overview_monitoring_active_description">Переконайтеся, що ваш пристрій поблизу та активний.</string>
<string name="overview_unmatched_devices_label">Нерозпізнані пристрої</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">%d пристрій без відповідного профілю</item>
<item quantity="few">%d пристрої без відповідного профілю</item>
<item quantity="many">%d пристроїв без відповідного профілю</item>
<item quantity="other">%d пристроїв без відповідного профілю</item>
</plurals>
<string name="permission_bluetooth_connect_label">Підключення Bluetooth</string>
<string name="permission_bluetooth_connect_description">Ця програма вимагає дозволу \"Підключення Bluetooth\" для взаємодії зі спареними пристроями та ініціювання з\'єднань.</string>
<string name="permission_bluetooth_scan_label">Сканування Bluetooth</string>
<string name="permission_bluetooth_scan_description">Дозвіл \"Сканування Bluetooth\" дозволяє цій програмі виявляти та отримувати дані Bluetooth від пристроїв поблизу, таких як ваші AirPods.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Ця програма вимагає дозволу \"Bluetooth\" для підключення до спарених пристроїв.</string>
<string name="permission_access_fine_location_label">Точне місцезнаходження</string>
<string name="permission_access_fine_location_description">CAPod використовує дозвіл \"точне місцезнаходження\" для отримання даних Bluetooth Low Energy. Ваші навушники використовують технологію BLE для трансляції свого стану. Ця програма НЕ використовуватиме дані Bluetooth для визначення вашого місцезнаходження.</string>
<string name="permission_background_location_label">Доступ до місцезнаходження у фоні</string>
<string name="permission_background_location_description">CAPod використовує \"доступ до місцезнаходження у фоні\", щоб увімкнути такі функції, як \"Показувати спливаюче вікно\" та \"Автопід\'єднання\", коли програму закрито. Доступ у фоні дозволяє програмі отримувати дані BLE, перебуваючи у згорнутому стані. Ця програма НЕ використовуватиме дані Bluetooth для визначення вашого місцезнаходження.</string>
<string name="permission_ignore_battery_optimizations_label">Вимкнути оптимізацію батареї</string>
<string name="permission_ignore_battery_optimizations_description">Оптимізація батареї заважає цій програмі надійно отримувати дані Bluetooth, коли вона працює у фоновому режимі.</string>
<string name="permission_required_title">Потрібен наступний дозвіл:</string>
<string name="permission_system_alert_window_label">Відображення поверх інших програм</string>
<string name="permission_system_alert_window_description">Дозвольте CAPod відображатися поверх інших програм, щоб уможливити функцію \"Показувати спливаюче вікно\".</string>
<string name="settings_scanner_mode_lowpower_label">Енергозбереження</string>
<string name="settings_scanner_mode_balanced_label">Збалансований</string>
<string name="settings_scanner_mode_lowlatency_label">Низька затримка</string>
<string name="settings_monitor_mode_manual_label">Коли програму відкрито</string>
<string name="settings_monitor_mode_automatic_label">Коли пристрій підключено</string>
<string name="settings_monitor_mode_always_label">Завжди</string>
<string name="settings_reaction_autoconnect_whenseen_label">Коли виявлено</string>
<string name="settings_reaction_autoconnect_caseopen_label">Футляр відкрито</string>
<string name="settings_reaction_autoconnect_inear_label">У вусі</string>
<string name="pods_dual_left_label">Лівий</string>
<string name="pods_dual_right_label">Правий</string>
<string name="pods_case_label">Футляр</string>
<string name="pods_case_status_open_label">Відкрито</string>
<string name="pods_case_status_closed_label">Закрито</string>
<string name="pods_connection_state_disconnected_label">Не підключено до пристрою</string>
<string name="pods_connection_state_idle_label">Підключено, але неактивний</string>
<string name="pods_connection_state_music_label">У режимі музики</string>
<string name="pods_connection_state_call_label">У режимі дзвінка</string>
<string name="pods_connection_state_ringing_label">Дзвінок</string>
<string name="pods_connection_state_hanging_up_label">Завершення дзвінка</string>
<string name="pods_connection_state_unknown_label">Невідомий стан</string>
<string name="pods_unknown_raw_data_label">Сирі дані</string>
<string name="pods_unknown_label">Невідомий пристрій</string>
<string name="pods_unknown_contact_dev">Це невідомий пристрій, але він використовує схожий формат повідомлень. Давайте додамо підтримку для нього, зв\'яжіться зі мною :)</string>
<string name="pods_none_label_short">Немає пристрою</string>
<string name="pods_charging_label">Заряджається</string>
<string name="pods_inear_label">У вусі</string>
<string name="pods_microphone_label">Мікрофон</string>
<string name="pods_yours">Ваші</string>
<string name="headset_being_worn_label">Надягнуті</string>
<string name="headset_not_being_worn_label">Не надягнуті</string>
<string name="pods_case_unknown_state">Невідомий стан</string>
<string name="last_seen_x">Востаннє бачили: %s</string>
<string name="first_seen_x">Вперше бачили: %s</string>
<string name="permission_post_notifications_label">Показувати сповіщення</string>
<string name="permission_post_notifications_description">"Дозволити CAPod показувати сповіщення про ваші AirPods, наприклад, їхній поточний статус під час підключення."</string>
<!-- Device profiles -->
<string name="profiles_empty_title">Профілі пристроїв не налаштовано</string>
<string name="profiles_empty_description">Створіть профілі пристроїв, щоб керувати кількома пристроями з власними налаштуваннями та пріоритетами.</string>
<string name="profiles_add_action">Додати профіль</string>
<string name="profiles_create_title">Створити профіль</string>
<string name="profiles_name_label">Назва профілю</string>
<string name="profiles_name_default">Мої навушники</string>
<string name="profiles_model_label">Модель пристрою</string>
<string name="profiles_paired_device_label">Спарений пристрій</string>
<string name="profiles_paired_device_none">Немає</string>
<string name="profiles_paired_device_none_description">Пристрій не обрано</string>
<string name="profiles_save_action">Зберегти профіль</string>
<string name="profiles_drag_handle_description">Перетягніть, щоб змінити порядок</string>
<string name="profiles_delete_title">Видалити профіль</string>
<string name="profiles_delete_message">Ви впевнені, що хочете видалити цей профіль? Цю дію неможливо скасувати.</string>
<string name="profiles_delete_action">Видалити</string>
<string name="profiles_basic_info_title">Інформація про пристрій</string>
<string name="profiles_basic_info_description">Налаштуйте назву пристрою, модель та (опціонально) спарювання Bluetooth.</string>
<string name="profiles_signal_quality_title">Мінімальна якість сигналу</string>
<string name="profiles_signal_quality_description">Виявляти лише пристрої з силою сигналу вище цього порогу. Менші значення збільшують діапазон виявлення, але можуть викликати хибні спрацьовування. Не встановлюйте занадто високе значення — прийом Bluetooth зазвичай поганий і залежить від відстані та перешкод.</string>
<string name="profiles_identitykey_label">Ключ ідентифікації</string>
<string name="profilessettings_maindevice_identitykey_description">Identity Resolving Key (IRK) вашого пристрою, який допомагає CAPod ідентифікувати його серед пристроїв поблизу.</string>
<string name="profiles_maindevice_identitykey_explanation">AirPods часто змінюють свою Bluetooth-адресу задля приватності. IRK допомагає CAPod розпізнати ваш пристрій. Вам знадобиться одноразовий доступ до MacBook.</string>
<string name="profiles_maindevice_encryptionkey_label">Ключ шифрування</string>
<string name="profiles_maindevice_encryptionkey_description">Ключ шифрування вашого пристрою, який дозволяє CAPod отримувати детальну інформацію про стан.</string>
<string name="profiles_maindevice_encryptionkey_explanation">AirPods надсилають повідомлення про стан, частина якого зашифрована. Ключ шифрування дозволяє CAPod розшифрувати повне повідомлення. Вам знадобиться одноразовий доступ до MacBook.</string>
<string name="profiles_key_invalid_format">Невірний формат ключа</string>
<string name="profiles_key_expected_format">Очікуваний формат: %1$s</string>
<string name="profiles_priority_hint">Порядок профілів визначає пріоритет. Перетягуйте профілі, щоб змінити їхній порядок — профілі, що знаходяться вище у списку, мають пріоритет, коли знайдено кілька відповідних пристроїв.</string>
<!-- Unsaved changes dialog -->
<string name="general_unsaved_changes_title">Незбережені зміни</string>
<string name="general_unsaved_changes_message">У вас є незбережені зміни. Що ви хочете зробити?</string>
<string name="general_save_and_exit_action">Зберегти та вийти</string>
<string name="general_discard_action">Відхилити</string>
<string name="general_keep_editing_action">Продовжити редагування</string>
</resources>
+1
View File
@@ -5,6 +5,7 @@
<string name="general_copy_action">Copy</string>
<string name="general_thank_you_label">Thank you</string>
<string name="general_upgrade_action">Upgrade</string>
<string name="general_donate_action">Donate</string>
<string name="general_check_action">Check</string>
<string name="general_close_action">Close</string>
<string name="general_save_action">Save</string>
+2 -2
View File
@@ -1,5 +1,5 @@
plugins {
id("com.google.devtools.ksp") version "2.2.10-2.0.2" apply false
id("com.google.devtools.ksp") version "2.3.2" apply false
}
buildscript {
@@ -8,7 +8,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.13.0")
classpath("com.android.tools.build:gradle:9.0.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.Kotlin.core}")
classpath("com.google.dagger:hilt-android-gradle-plugin:${Versions.Dagger.core}")
classpath("androidx.navigation:navigation-safe-args-gradle-plugin:${Versions.AndroidX.Navigation.core}")
+1 -1
View File
@@ -18,7 +18,7 @@ repositories {
}
dependencies {
implementation("com.android.tools.build:gradle:8.13.0")
implementation("com.android.tools.build:gradle:9.0.0")
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.10")
implementation("com.squareup:javapoet:1.13.0")
}
+1 -13
View File
@@ -76,16 +76,6 @@ fun DependencyHandlerScope.addNavigation() {
androidTestImplementation("androidx.navigation:navigation-testing:${Versions.AndroidX.Navigation.core}")
}
fun DependencyHandlerScope.addBaseWorkManager() {
val version = "2.9.0"
implementation("androidx.work:work-runtime:$version")
testImplementation("androidx.work:work-testing:$version")
implementation("androidx.work:work-runtime-ktx:$version")
implementation("androidx.hilt:hilt-work:1.0.0")
kapt("androidx.hilt:hilt-compiler:1.0.0")
}
fun DependencyHandlerScope.addBaseAndroid() {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.annotation:annotation:1.7.0")
@@ -96,7 +86,6 @@ fun DependencyHandlerScope.addBaseAndroid() {
fun DependencyHandlerScope.addBaseAndroidUi() {
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.constraintlayout:constraintlayout:2.1.3")
implementation("androidx.fragment:fragment-ktx:1.4.1")
implementation("androidx.activity:activity-ktx:1.8.0")
implementation("androidx.fragment:fragment-ktx:1.6.1")
@@ -104,7 +93,6 @@ fun DependencyHandlerScope.addBaseAndroidUi() {
implementation("com.google.android.material:material:1.12.0")
val lifecycleVers = "2.6.2"
implementation("androidx.lifecycle:lifecycle-extensions:2.2.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVers")
implementation("androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycleVers")
implementation("androidx.lifecycle:lifecycle-common-java8:$lifecycleVers")
@@ -133,5 +121,5 @@ fun DependencyHandlerScope.addTesting() {
androidTestImplementation("io.kotest:kotest-assertions-core-jvm:4.6.4")
androidTestImplementation("io.kotest:kotest-property-jvm:4.6.4")
debugImplementation("androidx.fragment:fragment-testing:1.4.1")
debugImplementation("androidx.fragment:fragment-testing:1.6.1")
}
+14 -3
View File
@@ -7,7 +7,6 @@ import org.gradle.api.tasks.testing.TestResult
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import org.gradle.api.tasks.testing.logging.TestLogEvent
import java.io.File
import java.io.FileInputStream
import java.util.Properties
val Project.projectConfig: ProjectConfig
@@ -21,23 +20,35 @@ fun SigningConfig.setupCredentials(
if (keyStoreFromEnv?.exists() == true) {
println("Using signing data from environment variables.")
val missingVars = listOf("STORE_PASSWORD", "KEY_ALIAS", "KEY_PASSWORD")
.filter { System.getenv(it).isNullOrBlank() }
if (missingVars.isNotEmpty()) {
println("WARNING: STORE_PATH is set but missing env vars: ${missingVars.joinToString()}")
}
storeFile = keyStoreFromEnv
storePassword = System.getenv("STORE_PASSWORD")
keyAlias = System.getenv("KEY_ALIAS")
keyPassword = System.getenv("KEY_PASSWORD")
} else {
println("Using signing data from properties file.")
println("Trying signing data from properties file: $signingPropsPath")
val props = Properties().apply {
signingPropsPath?.takeIf { it.canRead() }?.let { load(FileInputStream(it)) }
signingPropsPath?.takeIf { it.canRead() }?.let { file ->
file.inputStream().use { stream -> load(stream) }
}
}
val keyStorePath = props.getProperty("release.storePath")?.let { File(it) }
if (keyStorePath?.exists() == true) {
println("Using signing data from properties file: $signingPropsPath")
storeFile = keyStorePath
storePassword = props.getProperty("release.storePassword")
keyAlias = props.getProperty("release.keyAlias")
keyPassword = props.getProperty("release.keyPassword")
} else {
println("WARNING: No valid signing configuration found (no env vars or properties file)")
}
}
}
+5 -19
View File
@@ -1,23 +1,9 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=true
android.nonFinalResIds=true
org.gradle.unsafe.configuration-cache=false
# Required by androidx.navigation.safeargs plugin
android.useAndroidX=true
# Temporary AGP 9 opt-outs (must migrate before AGP 10)
android.builtInKotlin=false
android.newDsl=false
+1 -1
View File
@@ -1,6 +1,6 @@
#Tue May 16 07:17:06 CEST 2023
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+2 -2
View File
@@ -1,7 +1,7 @@
### Updated by release.sh ###
project.versioning.major=3
project.versioning.minor=0
project.versioning.patch=3
project.versioning.build=0
project.versioning.patch=4
project.versioning.build=1
project.versioning.type=rc
#############################