mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Compare commits
29
Commits
v3.0.1-rc0
...
v3.0.4-rc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba554c8787 | ||
|
|
968829072a | ||
|
|
0d1f331551 | ||
|
|
a8bb42d40b | ||
|
|
9a3fb11ef7 | ||
|
|
12d2c4dd06 | ||
|
|
cfed733fa7 | ||
|
|
1af8743533 | ||
|
|
2f3f1f3f70 | ||
|
|
6223524a1e | ||
|
|
ebfffb1536 | ||
|
|
5aa36b148b | ||
|
|
29391feaf8 | ||
|
|
4e279ca44d | ||
|
|
9ff8d9e04e | ||
|
|
10d5a823fc | ||
|
|
54afadb136 | ||
|
|
e4db9387ca | ||
|
|
4834eb276a | ||
|
|
5860bbffb6 | ||
|
|
da53d68190 | ||
|
|
b397bf190c | ||
|
|
15d664c361 | ||
|
|
d2f24d8b95 | ||
|
|
b5436659b6 | ||
|
|
1cfc4611cb | ||
|
|
bda0a20743 | ||
|
|
09bad90c0c | ||
|
|
34856607f8 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)",
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -26,4 +26,3 @@ exclude:
|
||||
- app
|
||||
- app-common
|
||||
- CONTRIBUTING.md
|
||||
- CLAUDE.md
|
||||
|
||||
+37
-14
@@ -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()
|
||||
|
||||
|
||||
Vendored
+1
-22
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+105
-72
@@ -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
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
<string name="settings_autopause_description">Mettre le son en pause si vous ôtez l’appareil de votre oreille.</string>
|
||||
<string name="settings_autopplay_label">Lecture automatique</string>
|
||||
<string name="settings_autoplay_description">Démarrer la lecture du son quand l’appareil est porté.</string>
|
||||
<string name="settings_eardetection_info_label">Note de détection de l’oreille</string>
|
||||
<string name="settings_eardetection_info_description">Si la détection de l’oreille ne fonctionne que pour un seul AirPod, cela est causé par une limitation d’Apple. Seul « l’AirPod 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 n’existent 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 l’appareil.</string>
|
||||
<string name="widget_configuration_title">Choisir un appareil</string>
|
||||
<string name="widget_configuration_description">Choisissez le profil d’appareil 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 l’autorisation « Connexion Bluetooth » pour interagir avec les appareils jumelés et démarrer les connexions.</string>
|
||||
<string name="permission_bluetooth_scan_label">Analyse Bluetooth</string>
|
||||
|
||||
@@ -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">저장 & 종료</string>
|
||||
<string name="general_discard_action">무시</string>
|
||||
<string name="general_keep_editing_action">계속 수정하기</string>
|
||||
</resources>
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
#############################
|
||||
|
||||
Reference in New Issue
Block a user