Compare commits

...
29 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
darken 5860bbffb6 Release: 3.0.3-rc0 2025-10-29 15:52:54 +01:00
darken da53d68190 Update translations 2025-10-29 15:52:04 +01:00
darken b397bf190c Update translations 2025-10-29 15:52:04 +01:00
darken 15d664c361 feat: Make connectedDevices flow more robust
Adds retry and catch logic to handle failures gracefully.
2025-10-29 15:51:48 +01:00
darken d2f24d8b95 fix: Improve BluetoothManager stability
Enhances robustness by adding extensive error handling around broadcast receiver registration, profile event processing, and service disconnections to prevent crashes. Also ensures device flow is cleared when Bluetooth is disabled.
2025-10-29 15:51:48 +01:00
darken b5436659b6 refactor: Make connectedDevices a hot StateFlow
Convert `connectedDevices()` from a function returning a cold Flow to a property that is a hot `StateFlow`. This simplifies call sites and improves efficiency by sharing the underlying subscription.
2025-10-29 15:51:48 +01:00
darken 1cfc4611cb Improve Bluetooth device connection monitoring
Switched from monitoring generic ACL events to specific Headset profile state changes for more reliable device connection and disconnection detection.

This fixes a race condition where CAPod thinks no device is connected because we triggered too early, before "connectedDevices" on the HEADSET profile contains our target device.

This fixes #313
2025-10-29 15:14:45 +01:00
darken bda0a20743 Release: 3.0.2-rc0 2025-10-11 08:02:51 +02:00
darken 09bad90c0c feat: Add and update translations for Dutch and Mexican Spanish 2025-10-11 07:50:32 +02:00
darken 34856607f8 Fix crash on start due to R8
Closes #341
2025-10-11 07:45:42 +02:00
65 changed files with 1339 additions and 970 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.1-rc0 30001000
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()
+1 -22
View File
@@ -1,23 +1,2 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-keep class eu.darken.capod.BuildConfig { *; }
-dontobfuscate
+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>
+1
View File
@@ -2,4 +2,5 @@
<resources>
<string name="upgrades_gplay_unavailable_error">Služby Google Play nie sú k dispozícii.</string>
<string name="upgrades_no_purchases_found_check_account">Nenašli sa žiadne nákupy. Používate správny účet?</string>
<string name="upgrades_gplay_billing_error_label">Chyba Google Play</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>
@@ -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
}
@@ -12,8 +12,8 @@ import android.content.Intent
import android.content.IntentFilter
import android.os.Handler
import android.os.HandlerThread
import android.os.ParcelUuid
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
@@ -21,27 +21,38 @@ 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 eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.IOException
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BluetoothManager2 @Inject constructor(
private val manager: BluetoothManager,
@ApplicationContext private val context: Context,
@AppScope private val appScope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider,
@ApplicationContext private val context: Context,
private val manager: BluetoothManager,
) {
val adapter: BluetoothAdapter?
@@ -89,7 +100,7 @@ class BluetoothManager2 @Inject constructor(
override fun onServiceDisconnected(profile: Int) {
log(TAG, WARN) { "onServiceDisconnected(profile=$profile)" }
close(IOException("BluetoothProfile service disconnected (profile=$profile)"))
close() // Close gracefully without exception to prevent crash
}
}, profile)
@@ -103,59 +114,118 @@ class BluetoothManager2 @Inject constructor(
}
private fun monitorDevicesForProfile(
private fun monitorProfile(
profile: Int = BluetoothProfile.HEADSET
): Flow<Set<BluetoothDevice>> = getBluetoothProfile(profile).flatMapLatest { bluetoothProfile ->
callbackFlow {
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) starting" }
trySend(bluetoothProfile.connectedDevices)
log(TAG, VERBOSE) { "monitorProfile(): for profile=$profile starting" }
try {
trySend(bluetoothProfile.connectedDevices)
} catch (e: Exception) {
log(TAG, ERROR) { "monitorProfile(): Error querying initial connected devices: $e" }
close(e)
return@callbackFlow
}
val filter = IntentFilter().apply {
addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)
}
val handlerThread = HandlerThread("BluetoothEventReceiver").apply {
start()
}
val handlerThread = HandlerThread("BluetoothEventReceiver").apply { start() }
val handler = Handler(handlerThread.looper)
val receiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
log(TAG, VERBOSE) { "Bluetooth event (intent=$intent, extras=${intent.extras})" }
val action = intent.action
if (action == null) {
log(TAG, ERROR) { "Bluetooth event without action, how did we get this?" }
log(TAG, VERBOSE) { "monitorProfile(): Bluetooth event (intent=$intent, extras=${intent.extras})" }
if (intent.action == null) {
log(TAG, ERROR) { "monitorProfile(): Bluetooth event without action?" }
return
}
val device = intent.getParcelableExtra<BluetoothDevice?>(BluetoothDevice.EXTRA_DEVICE)
if (device == null) {
log(TAG, ERROR) { "Connection event is missing EXTRA_DEVICE: ${intent.extras}" }
log(TAG, ERROR) { "monitorProfile(): Event is missing EXTRA_DEVICE" }
return
}
this@callbackFlow.launch {
val currentDevices = bluetoothProfile.connectedDevices
if (intent.action != BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) {
log(TAG, WARN) { "Unknown action: ${intent.action}" }
return@launch
}
when (action) {
BluetoothDevice.ACTION_ACL_CONNECTED -> {
log(TAG) { "Adding $device to current devices $currentDevices" }
trySend(currentDevices.plus(device))
// Profile connection changed - query actual state from proxy
val statePrevious = intent.getIntExtra(BluetoothProfile.EXTRA_PREVIOUS_STATE, -1)
log(TAG) { "monitorProfile(): HEADSET profile state changed for $device - previous: $statePrevious" }
val stateNow = intent.getIntExtra(BluetoothProfile.EXTRA_STATE, -1)
log(TAG) { "monitorProfile(): HEADSET profile state changed for $device - now: $stateNow" }
val currentDevices = try {
bluetoothProfile.connectedDevices
} catch (e: Exception) {
log(TAG, ERROR) { "monitorProfile(): Error handling profile event: $e" }
// Log but continue - don't kill the whole Flow for one bad event
emptySet()
}.toMutableSet()
log(TAG) { "monitorProfile(): currentDevices: $currentDevices" }
when (stateNow) {
BluetoothProfile.STATE_CONNECTING -> {
log(TAG) { "monitorProfile(): Currently connecting $device" }
}
BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
log(TAG) { "Removing $device from current devices $currentDevices" }
trySend(currentDevices.minus(device))
BluetoothProfile.STATE_CONNECTED -> {
log(TAG) { "monitorProfile(): Device has connected $device" }
if (!currentDevices.contains(device)) {
log(
TAG,
VERBOSE
) { "monitorProfile(): $device not in proxy yet, adding manually" }
currentDevices.add(device)
}
trySend(currentDevices)
}
BluetoothProfile.STATE_DISCONNECTING -> {
log(TAG) { "monitorProfile(): Currently DISconnecting $device" }
}
BluetoothProfile.STATE_DISCONNECTED -> {
log(TAG) { "monitorProfile(): Device has disconnected $device" }
if (currentDevices.contains(device)) {
log(
TAG,
VERBOSE
) { "monitorProfile(): $device still in proxy, removing manually" }
currentDevices.remove(device)
}
trySend(currentDevices)
}
}
}
}
}
context.registerReceiver(receiver, filter, null, handler)
try {
context.registerReceiver(receiver, filter, null, handler)
} catch (e: Exception) {
log(TAG, ERROR) { "monitorProfile(): Failed to register receiver: $e" }
handlerThread.quitSafely()
close(e)
return@callbackFlow
}
awaitClose {
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) closed." }
context.unregisterReceiver(receiver)
log(TAG, VERBOSE) { "monitorProfile(): profile=$profile closed." }
try {
context.unregisterReceiver(receiver)
} catch (e: Exception) {
log(TAG, ERROR) { "monitorProfile(): Error unregistering receiver: $e" }
} finally {
handlerThread.quitSafely()
}
}
}
}
@@ -163,10 +233,11 @@ class BluetoothManager2 @Inject constructor(
private val seenDevicesLock = Mutex()
private val seenDevicesCache = mutableMapOf<String, Instant>()
fun connectedDevices(
featureFilter: Set<ParcelUuid> = ContinuityProtocol.BLE_FEATURE_UUIDS
): Flow<List<BluetoothDevice2>> = isBluetoothEnabled
.flatMapLatest { monitorDevicesForProfile(BluetoothProfile.HEADSET) }
val connectedDevices: Flow<List<BluetoothDevice2>> = isBluetoothEnabled
.flatMapLatest { enabled ->
if (enabled) monitorProfile(BluetoothProfile.HEADSET)
else flowOf(emptySet()) // Return empty when Bluetooth is off
}
.map { devices ->
val currentAddresses = devices.map { it.address }
@@ -177,7 +248,9 @@ class BluetoothManager2 @Inject constructor(
}
devices
.filter { device -> featureFilter.any { feature -> device.hasFeature(feature) } }
.filter { device ->
ContinuityProtocol.BLE_FEATURE_UUIDS.any { feature -> device.hasFeature(feature) }
}
.map { device ->
BluetoothDevice2(
internal = device,
@@ -189,6 +262,30 @@ class BluetoothManager2 @Inject constructor(
)
}
}
.retryWhen { cause, attempt ->
log(TAG, WARN) { "connectedDevices Flow failed (attempt ${attempt + 1}): $cause" }
if (attempt < 3) {
delay(1000 * (attempt + 1)) // 1s, 2s, 3s exponential backoff
true // Retry
} else {
false // Give up after 3 attempts
}
}
.catch { e ->
log(TAG, ERROR) { "connectedDevices Flow failed after retries: $e" }
emit(emptyList()) // Emit empty list and complete gracefully
}
.distinctUntilChanged()
.setupCommonEventHandlers(TAG) { "connectedDevices" }
.stateIn(
scope = appScope + dispatcherProvider.IO,
started = SharingStarted.WhileSubscribed(
stopTimeoutMillis = 5_000L,
replayExpirationMillis = 0L,
),
initialValue = null
)
.filterNotNull()
fun bondedDevices(): Flow<Set<BluetoothDevice2>> = flow {
val rawDevices = adapter?.bondedDevices ?: throw IllegalStateException("Bluetooth adapter unavailable")
@@ -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
}
@@ -90,7 +90,7 @@ class OverviewFragmentVM @Inject constructor(
val shouldStartMonitor = when (generalSettings.monitorMode.value) {
MonitorMode.MANUAL -> false
MonitorMode.AUTOMATIC -> bluetoothManager.connectedDevices().first().isNotEmpty()
MonitorMode.AUTOMATIC -> bluetoothManager.connectedDevices.first().isNotEmpty()
MonitorMode.ALWAYS -> true
}
if (shouldStartMonitor) {
@@ -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,19 +147,19 @@ 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(
generalSettings.monitorMode.flow,
profilesRepo.profiles,
bluetoothManager.connectedDevices(),
bluetoothManager.connectedDevices,
) { monitorMode, profiles, connectedDevices ->
listOf(monitorMode, profiles, connectedDevices)
}
@@ -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
@@ -16,7 +16,6 @@ import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.reaction.core.ReactionSettings
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filterNotNull
@@ -39,7 +38,7 @@ class AutoConnect @Inject constructor(
.flatMapLatest { isAutoConnectEnabled ->
if (isAutoConnectEnabled) {
combine(
bluetoothManager.connectedDevices().distinctUntilChanged(),
bluetoothManager.connectedDevices,
podMonitor.primaryDevice().filterNotNull().distinctUntilChangedBy { it.rawDataHex },
) { connectedDevices, mainDevice ->
connectedDevices to mainDevice
@@ -35,7 +35,7 @@ class PlayPause @Inject constructor(
reactionSettings.autoPause.flow,
reactionSettings.onePodMode.flow,
) { play, pause, _ -> play || pause }
.flatMapLatest { if (it) bluetoothManager.connectedDevices() else emptyFlow() }
.flatMapLatest { if (it) bluetoothManager.connectedDevices else emptyFlow() }
.flatMapLatest {
if (it.isEmpty()) {
log(TAG) { "No known devices connected." }
@@ -16,7 +16,6 @@ import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.reaction.core.ReactionSettings
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
@@ -116,7 +115,7 @@ class PopUpReaction @Inject constructor(
if (!isEnabled) return@flatMapLatest emptyFlow()
combine(
bluetoothManager.connectedDevices().distinctUntilChanged(),
bluetoothManager.connectedDevices,
podMonitor.primaryDevice().distinctUntilChangedBy { it?.rawDataHex },
) { devices, broadcast ->
log(TAG) { "$broadcast $devices " }
@@ -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>
+19 -2
View File
@@ -76,7 +76,7 @@
<string name="settings_support_description">Si necesitas ayuda.</string>
<string name="issue_tracker_label">Seguidor de problemas</string>
<string name="issue_tracker_description">Un seguidor público de problemas para reportes de errores y solicitudes de funciones (solo en inglés).</string>
<string name="discord_label">En español</string>
<string name="discord_label">Discord</string>
<string name="discord_description">Un lugar para pasar el rato y hacer preguntas.</string>
<string name="changelog_label">Registro de cambios</string>
<string name="settings_label">Ajustes</string>
@@ -98,7 +98,7 @@
<string name="help_translate_label">Traducción</string>
<string name="help_translate_description">Ayuda a traducir esta aplicación a tu idioma favorito.</string>
<string name="translators_thanks_title">Traductores</string>
<string name="translators_thanks_description">español</string>
<string name="translators_thanks_description">Jaime Muñoz(jmmartin_5@outlook.com)</string>
<string name="widget_description">Un widget que muestra el último estado conocido del dispositivo.</string>
<string name="widget_configuration_title">Selecciona el dispositivo</string>
<string name="widget_configuration_description">Elige qué perfil del dispositivo debe mostrar este complemento.</string>
@@ -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>
+73 -5
View File
@@ -28,6 +28,8 @@
<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 기기에서 다음에 따라 설정할 수 있습니다. 설정 → 블루투스 → Airpods → 마이크</string>
<string name="settings_fake_data_label">가짜 데이터</string>
<string name="settings_fake_data_description">가짜 데이터를 표시합니다. 즉, 존재하지 않는 기기를 시뮬레이션합니다.</string>
<string name="settings_debug_label">디버그 설정</string>
@@ -35,7 +37,7 @@
<string name="settings_signal_minimum_label">최소 신호 품질</string>
<string name="settings_signal_minimum_description">기기가 사용자의 것으로 간주되기 위해 필요한 최소 신호 품질입니다.</string>
<string name="settings_autoconnect_label">자동 연결</string>
<string name="settings_autoconnect_description">Android가 자동으로 연결되지 않으면 저희도 요청할 수 있습니다. 이렇게 하면 모니터 모드 설정이 \"항상\"으로 설정됩니다.</string>
<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>
@@ -49,7 +51,7 @@
<string name="settings_compat_offloaded_filtering_disabled_summary">데이터 필터링을 시스템에 위임하지 말고 대신 모든 데이터를 가져와 앱 내에서 필터링합니다.</string>
<string name="settings_compat_offloaded_batching_disabled_title">하드웨어 일괄 처리 비활성화</string>
<string name="settings_compat_offloaded_batching_disabled_summary">수집된 BLE 데이터를 저희에게 전달하기 전에 시스템이 그룹화하도록 허용하지 마십시오.</string>
<string name="settings_onepod_mode_label">단일 Pod 모드</string>
<string name="settings_onepod_mode_label">단일 유닛 모드</string>
<string name="settings_onepod_mode_description">양쪽 기기를 모두 착용할 필요 없이 한쪽만 착용해도 앱이 반응합니다.</string>
<string name="settings_popup_caseopen_label">케이스 팝업 표시</string>
<string name="settings_popup_caseopen_description">기기 케이스를 열면 팝업을 표시합니다(실험적).</string>
@@ -98,6 +100,10 @@
<string name="translators_thanks_title">번역가</string>
<string name="translators_thanks_description">윤지호(annyeong1alt@gmail.com)</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>
@@ -130,15 +136,27 @@
<string name="overview_nomaindevice_label">설정된 장치가 없음</string>
<string name="overview_nomaindevice_description">배터리 잔량 감시와 추가 기능을 활성화하기 위해 당신의 장치를 설정하십시오.</string>
<string name="overview_bluetooth_disabled_label">Bluetooth가 꺼져 있습니다</string>
<string name="overview_monitoring_active_label">장치 감시중</string>
<string name="overview_bluetooth_disabled_description">블루투스를 켜야 연결할 수 있어요 ;)</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="other">%d프로필과 일치하지 않는 기기</item>
</plurals>
<string name="permission_bluetooth_connect_label">블루투스 연결</string>
<string name="permission_bluetooth_connect_description">이 앱은 페어링된 기기와 상호작용하고 연결을 시작하기 위해 \"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 검색\" 권한은 앱이 당신의 AirPods같은 주변의 기기를 찾고 Bluetooth 데이터를 받도록 합니다.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">이 앱은 페어링 된 기기에 접근하기 위해 블루투스 권한을 필요로 합니다.</string>
<string name="permission_access_fine_location_label">정밀 위치 접근</string>
<string name="permission_access_fine_location_description">CAPod는 BLE 정보를 받기 위해 정밀 위치 접근 권한을 사용합니다. 앱은 블루투스 헤드폰의 BLE 기술을 사용해 기기의 상태에 접근합니다. CAPod는 이 권한으로 사용자의 위치를 확인하지 않습니다.</string>
<string name="permission_background_location_label">백그라운드 위치 접근</string>
<string name="permission_background_location_description">CAPod는 팝업 보기나 자동 연결과 같은 기능을 앱이 꺼져있는 상태에서도 사용할 수 있게 하기 위해 백그라운드 위치 접근 권한을 사용합니다. 백그라운드 위치 접근 권한은 백그라운드에서도 BLE 정보를 받을 수 있게 합니다. CAPod는 이 권한으로 사용자의 위치를 확인하지 않습니다.</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">\"팝업 표시\" 기능을 사용하려면 다른 앱 위에 그리기를 허용해야 합니다.</string>
<string name="settings_scanner_mode_lowpower_label">저전력</string>
<string name="settings_scanner_mode_balanced_label">균형잡힌</string>
@@ -148,12 +166,57 @@
<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="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">Raw data</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">기기의 이름, 모델 및 선택적 블루투스 페어링을 설정하세요.</string>
<string name="profiles_signal_quality_title">최소 신호 품질</string>
<string name="profiles_signal_quality_description">이 수치 이상의 신호 세기를 가진 기기만이 감지 됩니다. 낮은 값은 감지 범위를 넓히지만, 오탐을 일으킬 수 있습니다. 너무 높은 값은 블루투스 수신이 거리와 장애물에 민감하기에 권장하지 않습니다.</string>
<string name="profiles_identitykey_label">ID 키</string>
<string name="profilessettings_maindevice_identitykey_description">기기의 ID 확인 키(IRK)로, CAPod가 주변 기기 중에서 해당 기기를 식별하는 데 도움이 됩니다.</string>
<string name="profiles_maindevice_identitykey_explanation">AirPods은 개인정보 보호를 위해 자주 Bluetooth 주소를 변경합니다. IRK는 앱이 당신의 기기를 인식하는데 도움이 됩니다. MacBook을 잠깐 사용해야 합니다.</string>
<string name="profiles_maindevice_encryptionkey_label">암호화 키</string>
<string name="profiles_maindevice_encryptionkey_description">당신의 기기의 암호화 키. 앱이 구체적인 상태 정보를 불러올 수 있게 합니다.</string>
@@ -163,4 +226,9 @@
<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">저장 &amp; 종료</string>
<string name="general_discard_action">무시</string>
<string name="general_keep_editing_action">계속 수정하기</string>
</resources>
+62 -4
View File
@@ -23,11 +23,13 @@
<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>
<string name="settings_autoplay_description">Start audioweergave wanneer het apparaat wordt gedragen.</string>
<string name="settings_eardetection_info_label">Ear detectie notitie</string>
<string name="settings_eardetection_info_description">Als oordetectie slechts voor één pod werkt, is dit een beperking van Apple. Alleen de \"primaire pod\" (gebruikt voor de microfoon) wordt gedetecteerd. Configureer op Apple-apparaten: Instellingen → Bluetooth → AirPods → Microfoon.</string>
<string name="settings_fake_data_label">Nep gegevens</string>
<string name="settings_fake_data_description">Toon nepgegevens, d.w.z. simuleer apparaten die niet bestaan.</string>
<string name="settings_debug_label">Foutopsporingsinstellingen</string>
@@ -47,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>
@@ -98,6 +100,10 @@
<string name="translators_thanks_title">Vertalers</string>
<string name="translators_thanks_description">darken</string>
<string name="widget_description">Een widget dat de laatst gekende apparaatstatus toont.</string>
<string name="widget_configuration_title">Selecteer Apparaat</string>
<string name="widget_configuration_description">Kies welk apparaatprofiel deze widget moet weergeven.</string>
<string name="common_feature_requires_pro_msg">Voor deze functie is CAPod Pro vereist.</string>
<string name="widget_no_data_label">Geen data</string>
<string name="settings_compat_indirectcallback_title">Onrechtstreekse data aflevering</string>
<string name="settings_compat_indirectcallback_summary">Gebruik een alternatieve methode om BLE data van het systeem (broadcast inplaats van callback).</string>
<string name="troubleshooter_title">Probleemoplossing</string>
@@ -134,6 +140,10 @@
<string name="overview_monitoring_active_label">Monitoring voor apparaten</string>
<string name="overview_monitoring_active_description">Zorg ervoor dat je apparaat in de buurt is en actief is.</string>
<string name="overview_unmatched_devices_label">Niet overeenkomende apparaten</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">%d apparaat zonder overeenkomend profiel</item>
<item quantity="other">%d apparaten zonder overeenkomend profiel</item>
</plurals>
<string name="permission_bluetooth_connect_label">Bluetooth-verbinding</string>
<string name="permission_bluetooth_connect_description">Deze app heeft de toestemming voor \'Bluetooth verbinden\' nodig om met gekoppelde apparaten te communiceren en verbindingen tot stand te brengen.</string>
<string name="permission_bluetooth_scan_label">Bluetooth-scannen</string>
@@ -141,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>
@@ -159,13 +169,61 @@
<string name="settings_reaction_autoconnect_caseopen_label">Zaak is geopend</string>
<string name="settings_reaction_autoconnect_inear_label">In oor</string>
<string name="pods_dual_left_label">Linker pod</string>
<string name="pods_dual_right_label">Rechter pod</string>
<string name="pods_case_label">Geval</string>
<string name="pods_case_status_open_label">Open</string>
<string name="pods_case_status_closed_label">Gesloten</string>
<string name="pods_connection_state_disconnected_label">Niet verbonden met een apparaat</string>
<string name="pods_connection_state_idle_label">Aangesloten op een apparaat, maar inactief</string>
<string name="pods_connection_state_music_label">In muziekmodus</string>
<string name="pods_connection_state_call_label">In oproepmodus</string>
<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>
+115
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">在這種情況下應用程式會監視藍牙資料。</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">低功耗藍牙掃描器應該優先考慮效能還是節能?</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 裝置中設定:設定 → 藍牙 → 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>
@@ -68,6 +76,7 @@
<string name="settings_support_description">如果您需要協助。</string>
<string name="issue_tracker_label">問題追蹤器</string>
<string name="issue_tracker_description">一個用於錯誤回報和功能需求的公用問題追蹤器 (僅英文)。</string>
<string name="discord_label">Discord</string>
<string name="discord_description">一個可以在其中閒逛並提出問題的地方。</string>
<string name="changelog_label">變更記錄</string>
<string name="settings_label">設定</string>
@@ -91,9 +100,14 @@
<string name="translators_thanks_title">翻譯人員</string>
<string name="translators_thanks_description">人工知能</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">使用替代方法從系統中接收低功耗藍牙資料 (廣播而非回撥)。</string>
<string name="troubleshooter_title">疑難排解員</string>
<string name="troubleshooter_summary">診斷並修復藍牙連線問題。</string>
<string name="troubleshooter_ble_intro_title">低功耗藍牙廣播</string>
<string name="troubleshooter_ble_intro_body1">AirPods (和類似的耳機) 使用一種叫做「廣告」的低功耗藍牙技術廣播狀態資訊。部分手機不能正確實作這項技術。CAPod 可以嘗試透過不同的相容性選項來修正這個問題,直到收到資料。在耳機上開始播放音樂,並把它們放在靠近手機的地方,然後啟動這個處理程序。</string>
<string name="troubleshooter_ble_intro_start_action">啟動疑難排解</string>
@@ -112,6 +126,107 @@
<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">藍牙已停用</string>
<string name="overview_bluetooth_disabled_description">藍牙已停用,請啟用它 ;)</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="other">%d 個裝置沒有相符的設定檔</item>
</plurals>
<string name="permission_bluetooth_connect_label">藍牙連線</string>
<string name="permission_bluetooth_connect_description">此應用程式需要「藍牙連線」權限,才能與已配對的裝置互動並建立連線。</string>
<string name="permission_bluetooth_scan_label">藍牙掃描中</string>
<string name="permission_bluetooth_scan_description">「藍牙掃描」權限可讓此應用程式探索並接收來自附近裝置的藍牙資料,例如你的 AirPods。</string>
<string name="permission_bluetooth_label">藍牙</string>
<string name="permission_bluetooth_description">這個應用程式需要「藍牙」權限與已配對裝置連線。</string>
<string name="permission_access_fine_location_label">存取精確位置</string>
<string name="permission_access_fine_location_description">CAPod 使用「精確位置」權限以接收低功耗藍牙資料。您的耳機使用低功耗藍牙技術以廣播其狀態。這個應用程式不會使用藍牙資料來確定您的位置。</string>
<string name="permission_background_location_label">背景位置存取</string>
<string name="permission_background_location_description">CAPods 在應用程式關閉時使用「背景位置存取」來啟用諸如「顯示彈出式視窗」和「自動連線」等功能。背景位置存取允許這個應用程式在背景接收低功耗藍牙資料。這個應用程式不會使用藍牙資料來確定您的位置。</string>
<string name="permission_ignore_battery_optimizations_label">停用電池效能最佳化</string>
<string name="permission_ignore_battery_optimizations_description">電池效能最佳化使這個應用程式在背景時無法可靠地接收藍牙資料。</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">設定你的裝置名稱、型號,以及可選的藍牙配對。</string>
<string name="profiles_signal_quality_title">最低訊號品質</string>
<string name="profiles_signal_quality_description">僅偵測訊號強度高於此閾值的裝置。數值越低可增加偵測範圍,但可能導致誤判。請勿將此值設得太高——藍牙接收通常較弱,且會受距離與障礙物影響。</string>
<string name="profiles_identitykey_label">身份金鑰</string>
<string name="profilessettings_maindevice_identitykey_description">您裝置的身份解析金鑰 (IRK),協助 CAPod 在附近的裝置中識別它。</string>
<string name="profiles_maindevice_identitykey_explanation">為保護隱私,AirPods 會經常更改其藍牙位址。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)")
}
}
}
@@ -15,4 +15,4 @@ CAPod no tiene publicidad. Algunas características requieren ser compradas dent
Los AirPods y Beats más populares son soportados.
Si su dispositivo es similar a AirPods pero aún no es compatible, envíeme un correo.
¿Tienes una buena idea para una nueva característica? ¡Háznoslo saber!
¿Tienes una buena idea para una nueva característica? ¡Contáctanos!
@@ -2,17 +2,17 @@ CAPod 是一個能提供 AirPods 相關功能的應用程式。
功能:
* 顯示耳機和充電盒電力
* 顯示耳機和充電盒充電狀態
* 顯示有關連線、麥克風、充電盒的附加資訊
* 可以接收並顯示附近的所有裝置。
* 耳朵偵測,自動播放/暫停。
* 自動連線手機和 AirPods。
* 開啟充電盒時顯示彈出式視窗
CAPod 是無廣告的應用程式。 一些功能需要應用程式內購。 。
CAPod 是無廣告的應用程式。 一些功能需要應用程式內購。 。 一些功能需要應用程式內購。
支援大部分流行的 AirPods 和 Beats 裝置。
如果您的裝置和 AirPods 相似但並不支援,請寄給我一封郵件。
有新功能的好點子? 與我溝通一下吧!
有新功能的好點子? 與我溝通一下吧!
+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=1
project.versioning.build=0
project.versioning.patch=4
project.versioning.build=1
project.versioning.type=rc
#############################