mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Compare commits
19
Commits
v3.0.3-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 |
@@ -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()
|
||||
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -212,6 +212,7 @@ class BluetoothManager2 @Inject constructor(
|
||||
context.registerReceiver(receiver, filter, null, handler)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "monitorProfile(): Failed to register receiver: $e" }
|
||||
handlerThread.quitSafely()
|
||||
close(e)
|
||||
return@callbackFlow
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.app.NotificationManager
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import androidx.work.WorkManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
@@ -30,11 +29,6 @@ class AndroidModule {
|
||||
fun bluetoothManager(context: Context): BluetoothManager =
|
||||
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun workerManager(context: Context): WorkManager =
|
||||
WorkManager.getInstance(context)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun audioManager(context: Context): AudioManager =
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package eu.darken.capod.common.worker
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import androidx.work.Data
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
inline fun <reified T : Parcelable> Data.getParcelable(key: String): T? {
|
||||
val parcel = Parcel.obtain()
|
||||
try {
|
||||
val bytes = getByteArray(key) ?: return null
|
||||
parcel.unmarshall(bytes, 0, bytes.size)
|
||||
parcel.setDataPosition(0)
|
||||
val creator = T::class.java.getField("CREATOR").get(null) as Parcelable.Creator<T>
|
||||
return creator.createFromParcel(parcel)
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun Data.Builder.putParcelable(key: String, parcelable: Parcelable): Data.Builder {
|
||||
val parcel = Parcel.obtain()
|
||||
try {
|
||||
parcelable.writeToParcel(parcel, 0)
|
||||
putByteArray(key, parcel.marshall())
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
return this
|
||||
}
|
||||
@@ -19,9 +19,7 @@ import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
|
||||
import eu.darken.capod.pods.core.firstSeenFormatted
|
||||
import eu.darken.capod.pods.core.getBatteryDrawable
|
||||
import eu.darken.capod.pods.core.getBatteryLevelCase
|
||||
import eu.darken.capod.pods.core.getBatteryLevelLeftPod
|
||||
import eu.darken.capod.pods.core.getBatteryLevelRightPod
|
||||
import eu.darken.capod.pods.core.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.lastSeenFormatted
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
@@ -76,11 +74,13 @@ class DualPodsCardVH(parent: ViewGroup) :
|
||||
|
||||
// Pods battery state
|
||||
device.apply {
|
||||
podLeftBatteryIcon.setImageResource(getBatteryDrawable(batteryLeftPodPercent))
|
||||
podLeftBatteryLabel.text = getBatteryLevelLeftPod(context)
|
||||
val leftPercent = batteryLeftPodPercent
|
||||
podLeftBatteryIcon.setImageResource(getBatteryDrawable(leftPercent))
|
||||
podLeftBatteryLabel.text = formatBatteryPercent(context, leftPercent)
|
||||
|
||||
podRightBatteryIcon.setImageResource(getBatteryDrawable(batteryRightPodPercent))
|
||||
podRightBatteryLabel.text = getBatteryLevelRightPod(context)
|
||||
val rightPercent = batteryRightPodPercent
|
||||
podRightBatteryIcon.setImageResource(getBatteryDrawable(rightPercent))
|
||||
podRightBatteryLabel.text = formatBatteryPercent(context, rightPercent)
|
||||
}
|
||||
|
||||
// Pods charging state
|
||||
@@ -139,8 +139,9 @@ class DualPodsCardVH(parent: ViewGroup) :
|
||||
if (this is HasCase) {
|
||||
podCaseIcon.setImageResource(caseIcon)
|
||||
podCaseBatteryIcon.isGone = false
|
||||
podCaseBatteryIcon.setImageResource(getBatteryDrawable(batteryCasePercent))
|
||||
podCaseBatteryLabel.text = getBatteryLevelCase(context)
|
||||
val casePercent = batteryCasePercent
|
||||
podCaseBatteryIcon.setImageResource(getBatteryDrawable(casePercent))
|
||||
podCaseBatteryLabel.text = formatBatteryPercent(context, casePercent)
|
||||
|
||||
podCaseChargingIcon.isInvisible = !isCaseCharging
|
||||
podCaseChargingLabel.isInvisible = !isCaseCharging
|
||||
|
||||
@@ -13,7 +13,7 @@ import eu.darken.capod.pods.core.SinglePodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.firstSeenFormatted
|
||||
import eu.darken.capod.pods.core.getBatteryDrawable
|
||||
import eu.darken.capod.pods.core.getBatteryLevelHeadset
|
||||
import eu.darken.capod.pods.core.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.lastSeenFormatted
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
@@ -55,8 +55,9 @@ class SinglePodsCardVH(parent: ViewGroup) :
|
||||
|
||||
// Battery level
|
||||
device.apply {
|
||||
batteryLabel.text = getBatteryLevelHeadset(context)
|
||||
batteryIcon.setImageResource(getBatteryDrawable(batteryHeadsetPercent))
|
||||
val headsetPercent = batteryHeadsetPercent
|
||||
batteryIcon.setImageResource(getBatteryDrawable(headsetPercent))
|
||||
batteryLabel.text = formatBatteryPercent(context, headsetPercent)
|
||||
}
|
||||
|
||||
// Charge state
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.view.isVisible
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.EdgeToEdgeHelper
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
@@ -24,6 +26,7 @@ class WidgetConfigurationActivity : Activity2() {
|
||||
|
||||
@Inject lateinit var profileAdapter: WidgetProfileSelectionAdapter
|
||||
@Inject lateinit var upgradeRepo: UpgradeRepo
|
||||
@ApplicationContext @Inject lateinit var appContext: Context
|
||||
|
||||
private var widgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
|
||||
@@ -101,10 +104,10 @@ class WidgetConfigurationActivity : Activity2() {
|
||||
val resultValue = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)
|
||||
setResult(RESULT_OK, resultValue)
|
||||
|
||||
val appWidgetManager = AppWidgetManager.getInstance(this@WidgetConfigurationActivity)
|
||||
val appWidgetManager = AppWidgetManager.getInstance(appContext)
|
||||
|
||||
WidgetProvider.updateWidget(
|
||||
context = this@WidgetConfigurationActivity,
|
||||
context = appContext,
|
||||
appWidgetManager = appWidgetManager,
|
||||
widgetId = widgetId
|
||||
)
|
||||
|
||||
@@ -30,11 +30,8 @@ import eu.darken.capod.pods.core.HasEarDetectionDual
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.PodFactory
|
||||
import eu.darken.capod.pods.core.SinglePodDevice
|
||||
import eu.darken.capod.pods.core.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.getBatteryDrawable
|
||||
import eu.darken.capod.pods.core.getBatteryLevelCase
|
||||
import eu.darken.capod.pods.core.getBatteryLevelHeadset
|
||||
import eu.darken.capod.pods.core.getBatteryLevelLeftPod
|
||||
import eu.darken.capod.pods.core.getBatteryLevelRightPod
|
||||
import eu.darken.capod.profiles.core.ProfileId
|
||||
import finish2
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -141,7 +138,7 @@ class WidgetProvider : AppWidgetProvider() {
|
||||
val device: PodDevice? = profileId?.let { podMonitor.getDeviceForProfile(it) }
|
||||
|
||||
val layout = when {
|
||||
!upgradeRepo.isPro() -> createUpgradeRequiredLayout(context)
|
||||
!upgradeRepo.isPro() -> createUpgradeRequiredLayout(context, widgetId)
|
||||
device is DualPodDevice -> {
|
||||
val minWidth = widgetManager.getAppWidgetOptions(widgetId)
|
||||
.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)
|
||||
@@ -157,23 +154,24 @@ class WidgetProvider : AppWidgetProvider() {
|
||||
else -> R.layout.widget_pod_dual_wide_layout
|
||||
}
|
||||
|
||||
createDualPodLayout(context, device, layout)
|
||||
createDualPodLayout(context, device, layout, widgetId)
|
||||
}
|
||||
|
||||
device is SinglePodDevice -> createSinglePodLayout(context, device)
|
||||
device is PodDevice -> createUnknownPodLayout(context, device)
|
||||
else -> createNoDeviceLayout(context, profileId != null)
|
||||
device is SinglePodDevice -> createSinglePodLayout(context, device, widgetId)
|
||||
device is PodDevice -> createUnknownPodLayout(context, device, widgetId)
|
||||
else -> createNoDeviceLayout(context, profileId != null, widgetId)
|
||||
}
|
||||
widgetManager.updateAppWidget(widgetId, layout)
|
||||
}
|
||||
|
||||
private suspend fun createUpgradeRequiredLayout(
|
||||
context: Context
|
||||
context: Context,
|
||||
widgetId: Int
|
||||
) = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
|
||||
log(TAG, VERBOSE) { "createUpgradeRequiredLayout(context=$context)" }
|
||||
val pendingIntent: PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
widgetId,
|
||||
Intent(context, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
@@ -188,11 +186,12 @@ class WidgetProvider : AppWidgetProvider() {
|
||||
private fun createUnknownPodLayout(
|
||||
context: Context,
|
||||
podDevice: PodDevice,
|
||||
widgetId: Int
|
||||
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
|
||||
log(TAG, VERBOSE) { "createUnknownPodLayout(context=$context, podDevice=$podDevice)" }
|
||||
val pendingIntent: PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
widgetId,
|
||||
Intent(context, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
@@ -204,12 +203,13 @@ class WidgetProvider : AppWidgetProvider() {
|
||||
|
||||
private fun createNoDeviceLayout(
|
||||
context: Context,
|
||||
hasConfiguredProfile: Boolean = false
|
||||
hasConfiguredProfile: Boolean = false,
|
||||
widgetId: Int
|
||||
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
|
||||
log(TAG, VERBOSE) { "createNoDeviceLayout(context=$context, hasConfiguredProfile=$hasConfiguredProfile)" }
|
||||
val pendingIntent: PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
widgetId,
|
||||
Intent(context, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
@@ -227,12 +227,13 @@ class WidgetProvider : AppWidgetProvider() {
|
||||
private fun createDualPodLayout(
|
||||
context: Context,
|
||||
podDevice: DualPodDevice,
|
||||
@LayoutRes layout: Int
|
||||
@LayoutRes layout: Int,
|
||||
widgetId: Int
|
||||
): RemoteViews = RemoteViews(context.packageName, layout).apply {
|
||||
log(TAG, VERBOSE) { "createSinglePodLayout(context=$context, podDevice=$podDevice), layout=${layout}" }
|
||||
val pendingIntent: PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
widgetId,
|
||||
Intent(context, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
@@ -242,8 +243,9 @@ class WidgetProvider : AppWidgetProvider() {
|
||||
setTextViewText(R.id.headphones_label, podDevice.getLabel(context))
|
||||
|
||||
// Left
|
||||
val leftPercent = podDevice.batteryLeftPodPercent
|
||||
setImageViewResource(R.id.pod_left_icon, podDevice.leftPodIcon)
|
||||
setTextViewText(R.id.pod_left_label, podDevice.getBatteryLevelLeftPod(context))
|
||||
setTextViewText(R.id.pod_left_label, formatBatteryPercent(context, leftPercent))
|
||||
setViewVisibility(
|
||||
R.id.pod_left_charging,
|
||||
if (podDevice is HasChargeDetectionDual && podDevice.isLeftPodCharging) View.VISIBLE else View.GONE
|
||||
@@ -255,15 +257,17 @@ class WidgetProvider : AppWidgetProvider() {
|
||||
|
||||
// Case
|
||||
(podDevice as? HasCase)?.let { setImageViewResource(R.id.pod_case_icon, it.caseIcon) }
|
||||
setTextViewText(R.id.pod_case_label, (podDevice as? HasCase)?.getBatteryLevelCase(context))
|
||||
val casePercent = (podDevice as? HasCase)?.batteryCasePercent
|
||||
setTextViewText(R.id.pod_case_label, formatBatteryPercent(context, casePercent))
|
||||
setViewVisibility(
|
||||
R.id.pod_case_charging,
|
||||
if (podDevice is HasCase && podDevice.isCaseCharging) View.VISIBLE else View.GONE
|
||||
)
|
||||
|
||||
// Right
|
||||
val rightPercent = podDevice.batteryRightPodPercent
|
||||
setImageViewResource(R.id.pod_right_icon, podDevice.rightPodIcon)
|
||||
setTextViewText(R.id.pod_right_label, podDevice.getBatteryLevelRightPod(context))
|
||||
setTextViewText(R.id.pod_right_label, formatBatteryPercent(context, rightPercent))
|
||||
setViewVisibility(
|
||||
R.id.pod_right_charging,
|
||||
if (podDevice is HasChargeDetectionDual && podDevice.isRightPodCharging) View.VISIBLE else View.GONE
|
||||
@@ -277,30 +281,33 @@ class WidgetProvider : AppWidgetProvider() {
|
||||
private fun createSinglePodLayout(
|
||||
context: Context,
|
||||
podDevice: SinglePodDevice,
|
||||
widgetId: Int
|
||||
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_pod_single_layout).apply {
|
||||
log(TAG, VERBOSE) { "createSinglePodLayout(context=$context, podDevice=$podDevice)" }
|
||||
val pendingIntent: PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
widgetId,
|
||||
Intent(context, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
|
||||
|
||||
val headsetPercent = podDevice.batteryHeadsetPercent
|
||||
setTextViewText(R.id.headphones_label, podDevice.getLabel(context))
|
||||
setImageViewResource(R.id.headphones_icon, podDevice.iconRes)
|
||||
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(podDevice.batteryHeadsetPercent))
|
||||
setTextViewText(R.id.headphones_battery_label, podDevice.getBatteryLevelHeadset(context))
|
||||
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(headsetPercent))
|
||||
setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent))
|
||||
|
||||
setViewVisibility(
|
||||
R.id.headphones_worn,
|
||||
if (podDevice is HasEarDetection && podDevice.isBeingWorn) View.VISIBLE else View.GONE
|
||||
)
|
||||
|
||||
if (this is HasChargeDetectionDual) {
|
||||
setViewVisibility(R.id.headphones_charging, if (isHeadsetBeingCharged) View.VISIBLE else View.GONE)
|
||||
}
|
||||
setViewVisibility(
|
||||
R.id.headphones_charging,
|
||||
if (podDevice is HasChargeDetectionDual && podDevice.isHeadsetBeingCharged) View.VISIBLE else View.GONE
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -8,21 +8,17 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.darken.capod.common.bluetooth.hasFeature
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.monitor.core.worker.MonitorControl
|
||||
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class BluetoothEventReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject lateinit var monitorControl: MonitorControl
|
||||
@Inject @AppScope lateinit var appScope: CoroutineScope
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
log(TAG) { "onReceive($context, $intent)" }
|
||||
@@ -47,12 +43,8 @@ class BluetoothEventReceiver : BroadcastReceiver() {
|
||||
log { "Device has the following we features we support $supportedFeatures" }
|
||||
}
|
||||
|
||||
val pending = goAsync()
|
||||
appScope.launch {
|
||||
log(TAG) { "Starting monitor" }
|
||||
monitorControl.startMonitor(bluetoothDevice, forceStart = false)
|
||||
pending.finish()
|
||||
}
|
||||
log(TAG) { "Starting monitor" }
|
||||
monitorControl.startMonitor(forceStart = false)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package eu.darken.capod.monitor.core.receiver
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.monitor.core.worker.MonitorControl
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class BootCompletedReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject lateinit var monitorControl: MonitorControl
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||
log(TAG) { "Boot completed, starting monitor." }
|
||||
monitorControl.startMonitor(forceStart = false)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "BootReceiver")
|
||||
}
|
||||
}
|
||||
@@ -1,51 +1,50 @@
|
||||
package eu.darken.capod.monitor.core.worker
|
||||
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import eu.darken.capod.common.BuildConfigWrap
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import kotlinx.coroutines.withContext
|
||||
import eu.darken.capod.common.permissions.Permission
|
||||
import eu.darken.capod.common.startServiceCompat
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class MonitorControl @Inject constructor(
|
||||
private val workerManager: WorkManager,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
|
||||
suspend fun startMonitor(
|
||||
bluetoothDevice: BluetoothDevice? = null,
|
||||
fun startMonitor(
|
||||
forceStart: Boolean = false,
|
||||
): Unit = withContext(dispatcherProvider.IO) {
|
||||
val workerData = Data.Builder().apply {
|
||||
) {
|
||||
log(TAG, VERBOSE) { "startMonitor(forceStart=$forceStart)" }
|
||||
|
||||
}.build()
|
||||
log(TAG, VERBOSE) { "Worker data: $workerData" }
|
||||
val hasBluetoothPermission =
|
||||
Permission.BLUETOOTH.isGranted(context) || Permission.BLUETOOTH_CONNECT.isGranted(context)
|
||||
if (!hasBluetoothPermission) {
|
||||
log(TAG, WARN) { "Missing Bluetooth permission, not starting monitor service." }
|
||||
return
|
||||
}
|
||||
|
||||
val workRequest = OneTimeWorkRequestBuilder<MonitorWorker>().apply {
|
||||
setInputData(workerData)
|
||||
}.build()
|
||||
try {
|
||||
context.startServiceCompat(MonitorService.intent(context, forceStart))
|
||||
log(TAG) { "Monitor start request sent." }
|
||||
} catch (e: IllegalStateException) {
|
||||
log(TAG, WARN) { "Failed to start monitor service: ${e.message}" }
|
||||
} catch (e: SecurityException) {
|
||||
log(TAG, WARN) { "Failed to start monitor service, permission issue: ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
log(TAG, VERBOSE) { "Worker request: $workRequest" }
|
||||
|
||||
val operation = workerManager.enqueueUniqueWork(
|
||||
"${BuildConfigWrap.APPLICATION_ID}.monitor.worker",
|
||||
if (forceStart) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP,
|
||||
workRequest,
|
||||
)
|
||||
|
||||
operation.result.get()
|
||||
log(TAG) { "Monitor start request send." }
|
||||
fun stopMonitor() {
|
||||
log(TAG, VERBOSE) { "stopMonitor()" }
|
||||
context.stopService(MonitorService.intent(context))
|
||||
log(TAG) { "Monitor stop request sent." }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "Control")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+104
-71
@@ -1,13 +1,13 @@
|
||||
package eu.darken.capod.monitor.core.worker
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.IBinder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.darken.capod.common.bluetooth.BluetoothDevice2
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
@@ -19,6 +19,7 @@ import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.common.flow.throttleLatest
|
||||
import eu.darken.capod.common.hasApiLevel
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import eu.darken.capod.main.core.MonitorMode
|
||||
import eu.darken.capod.main.core.PermissionTool
|
||||
@@ -33,6 +34,7 @@ import eu.darken.capod.reaction.core.playpause.PlayPause
|
||||
import eu.darken.capod.reaction.core.popup.PopUpReaction
|
||||
import eu.darken.capod.reaction.ui.popup.PopUpWindow
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.cancelChildren
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -45,78 +47,87 @@ import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MonitorService : Service() {
|
||||
|
||||
@HiltWorker
|
||||
class MonitorWorker @AssistedInject constructor(
|
||||
@Assisted private val context: Context,
|
||||
@Assisted private val params: WorkerParameters,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
private val notifications: MonitorNotifications,
|
||||
private val notificationManager: NotificationManager,
|
||||
private val generalSettings: GeneralSettings,
|
||||
private val permissionTool: PermissionTool,
|
||||
private val podMonitor: PodMonitor,
|
||||
private val bluetoothManager: BluetoothManager2,
|
||||
private val playPause: PlayPause,
|
||||
private val autoConnect: AutoConnect,
|
||||
private val popUpReaction: PopUpReaction,
|
||||
private val popUpWindow: PopUpWindow,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
) : CoroutineWorker(context, params) {
|
||||
@Inject lateinit var dispatcherProvider: DispatcherProvider
|
||||
@Inject lateinit var notifications: MonitorNotifications
|
||||
@Inject lateinit var notificationManager: NotificationManager
|
||||
@Inject lateinit var generalSettings: GeneralSettings
|
||||
@Inject lateinit var permissionTool: PermissionTool
|
||||
@Inject lateinit var podMonitor: PodMonitor
|
||||
@Inject lateinit var bluetoothManager: BluetoothManager2
|
||||
@Inject lateinit var playPause: PlayPause
|
||||
@Inject lateinit var autoConnect: AutoConnect
|
||||
@Inject lateinit var popUpReaction: PopUpReaction
|
||||
@Inject lateinit var popUpWindow: PopUpWindow
|
||||
@Inject lateinit var profilesRepo: DeviceProfilesRepo
|
||||
|
||||
private val workerScope = MonitorCoroutineScope()
|
||||
private val monitorScope = MonitorCoroutineScope()
|
||||
private var monitoringJob: Job? = null
|
||||
@Volatile private var monitorGeneration = 0
|
||||
|
||||
private var finishedWithError = false
|
||||
@SuppressLint("InlinedApi")
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
log(TAG, VERBOSE) { "onCreate()" }
|
||||
|
||||
init {
|
||||
log(TAG, VERBOSE) { "init(): workerId=$id" }
|
||||
}
|
||||
|
||||
override suspend fun getForegroundInfo(): ForegroundInfo {
|
||||
return notifications.getForegroundInfo(null)
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result = try {
|
||||
val start = System.currentTimeMillis()
|
||||
log(TAG, VERBOSE) { "Executing $inputData now (runAttemptCount=$runAttemptCount)" }
|
||||
|
||||
doDoWork()
|
||||
|
||||
val duration = System.currentTimeMillis() - start
|
||||
|
||||
log(TAG, VERBOSE) { "Execution finished after ${duration}ms, $inputData" }
|
||||
|
||||
Result.success(inputData)
|
||||
} catch (e: Throwable) {
|
||||
if (e !is CancellationException) {
|
||||
Bugs.report(tag = TAG, "Execution failed", exception = e)
|
||||
finishedWithError = true
|
||||
Result.failure(inputData)
|
||||
val notification = notifications.getStartupNotification()
|
||||
if (hasApiLevel(29)) {
|
||||
startForeground(
|
||||
MonitorNotifications.NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
|
||||
)
|
||||
} else {
|
||||
Result.success()
|
||||
startForeground(MonitorNotifications.NOTIFICATION_ID, notification)
|
||||
}
|
||||
} finally {
|
||||
if (generalSettings.useExtraMonitorNotification.value && !generalSettings.keepConnectedNotificationAfterDisconnect.value) {
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
log(TAG, VERBOSE) { "onStartCommand(intent=$intent, flags=$flags, startId=$startId)" }
|
||||
|
||||
val forceStart = intent?.getBooleanExtra(EXTRA_FORCE_START, false) ?: false
|
||||
|
||||
if (monitoringJob?.isActive == true && !forceStart) {
|
||||
log(TAG) { "Already monitoring and forceStart=false, keeping current session." }
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
val generation = ++monitorGeneration
|
||||
monitorScope.coroutineContext.cancelChildren()
|
||||
|
||||
monitoringJob = monitorScope.launch {
|
||||
try {
|
||||
notificationManager.cancel(MonitorNotifications.NOTIFICATION_ID_CONNECTED)
|
||||
doMonitor()
|
||||
} catch (e: CancellationException) {
|
||||
log(TAG) { "Monitor cancelled." }
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "Failed to cancel connected notification: ${e.message}" }
|
||||
Bugs.report(tag = TAG, "Monitor failed", exception = e)
|
||||
} finally {
|
||||
if (monitorGeneration == generation) {
|
||||
log(TAG) { "Monitor finished, stopping service." }
|
||||
stopSelf()
|
||||
} else {
|
||||
log(TAG) { "Monitor replaced, not stopping service." }
|
||||
}
|
||||
}
|
||||
}
|
||||
this.workerScope.cancel("Worker finished (withError?=$finishedWithError).")
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private suspend fun doDoWork() {
|
||||
private suspend fun doMonitor() {
|
||||
val permissionsMissingOnStart = permissionTool.missingPermissions.first()
|
||||
if (permissionsMissingOnStart.isNotEmpty()) {
|
||||
log(TAG, WARN) { "Aborting, missing permissions: $permissionsMissingOnStart" }
|
||||
return
|
||||
}
|
||||
|
||||
setForeground(notifications.getForegroundInfo(null))
|
||||
|
||||
val monitorJob = podMonitor.primaryDevice()
|
||||
.setupCommonEventHandlers(TAG) { "PodMonitor" }
|
||||
.distinctUntilChanged()
|
||||
@@ -136,13 +147,13 @@ class MonitorWorker @AssistedInject constructor(
|
||||
.catch {
|
||||
log(TAG, WARN) { "Pod Flow failed:\n${it.asLog()}" }
|
||||
}
|
||||
.launchIn(workerScope)
|
||||
.launchIn(monitorScope)
|
||||
|
||||
permissionTool.missingPermissions
|
||||
.flatMapLatest { missingPermsFlow ->
|
||||
if (missingPermsFlow.isNotEmpty()) {
|
||||
log(TAG, WARN) { "Aborting, permissions are missing: $missingPermsFlow" }
|
||||
workerScope.coroutineContext.cancelChildren()
|
||||
monitorScope.coroutineContext.cancelChildren()
|
||||
emptyFlow()
|
||||
} else {
|
||||
combine(
|
||||
@@ -163,7 +174,6 @@ class MonitorWorker @AssistedInject constructor(
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val devices = arguments[2] as Collection<BluetoothDevice2>
|
||||
|
||||
|
||||
val connectedAddresses = devices.map { it.address }.toSet()
|
||||
val knownAddresses = profiles.mapNotNull { it.address }.toSet()
|
||||
log(TAG) { "Monitor mode: $monitorMode" }
|
||||
@@ -172,8 +182,7 @@ class MonitorWorker @AssistedInject constructor(
|
||||
|
||||
when (monitorMode) {
|
||||
MonitorMode.MANUAL -> flow<Unit> {
|
||||
// Cancel worker, ui scans manually
|
||||
workerScope.coroutineContext.cancelChildren()
|
||||
monitorScope.coroutineContext.cancelChildren()
|
||||
}
|
||||
|
||||
MonitorMode.ALWAYS -> emptyFlow()
|
||||
@@ -188,11 +197,11 @@ class MonitorWorker @AssistedInject constructor(
|
||||
}
|
||||
|
||||
else -> {
|
||||
log(TAG) { "No known Pods are connected, canceling worker soon." }
|
||||
log(TAG) { "No known Pods are connected, stopping service soon." }
|
||||
delay(15 * 1000)
|
||||
log(TAG) { "Canceling worker now, still no Pods connected." }
|
||||
log(TAG) { "Stopping service now, still no Pods connected." }
|
||||
|
||||
workerScope.coroutineContext.cancelChildren()
|
||||
monitorScope.coroutineContext.cancelChildren()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,7 +210,7 @@ class MonitorWorker @AssistedInject constructor(
|
||||
.catch {
|
||||
log(TAG, WARN) { "MonitorMode Flow failed:\n${it.asLog()}" }
|
||||
}
|
||||
.launchIn(workerScope)
|
||||
.launchIn(monitorScope)
|
||||
|
||||
popUpReaction.monitor()
|
||||
.onEach {
|
||||
@@ -214,24 +223,48 @@ class MonitorWorker @AssistedInject constructor(
|
||||
}
|
||||
.setupCommonEventHandlers(TAG) { "popUpReaction" }
|
||||
.catch { log(TAG, WARN) { "popUpReaction failed:\n${it.asLog()}" } }
|
||||
.launchIn(workerScope)
|
||||
.launchIn(monitorScope)
|
||||
|
||||
playPause.monitor()
|
||||
.setupCommonEventHandlers(TAG) { "playPause" }
|
||||
.catch { log(TAG, WARN) { "playPause failed:\n${it.asLog()}" } }
|
||||
.launchIn(workerScope)
|
||||
.launchIn(monitorScope)
|
||||
|
||||
autoConnect.monitor()
|
||||
.setupCommonEventHandlers(TAG) { "autoConnect" }
|
||||
.catch { log(TAG, WARN) { "autoConnect failed:\n${it.asLog()}" } }
|
||||
.launchIn(workerScope)
|
||||
.launchIn(monitorScope)
|
||||
|
||||
log(TAG, VERBOSE) { "Monitor job is active" }
|
||||
monitorJob.join()
|
||||
log(TAG, VERBOSE) { "Monitor job quit" }
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
log(TAG, VERBOSE) { "onDestroy()" }
|
||||
monitorScope.cancel("Service destroyed")
|
||||
|
||||
if (generalSettings.useExtraMonitorNotification.value && !generalSettings.keepConnectedNotificationAfterDisconnect.value) {
|
||||
try {
|
||||
notificationManager.cancel(MonitorNotifications.NOTIFICATION_ID_CONNECTED)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "Failed to cancel connected notification: ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
companion object {
|
||||
val TAG = logTag("Monitor", "Worker")
|
||||
val TAG = logTag("Monitor", "Service")
|
||||
private const val EXTRA_FORCE_START = "extra.force_start"
|
||||
|
||||
fun intent(context: Context, forceStart: Boolean = false): Intent {
|
||||
return Intent(context, MonitorService::class.java).apply {
|
||||
putExtra(EXTRA_FORCE_START, forceStart)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,15 @@ import android.view.View
|
||||
import android.widget.RemoteViews
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.pods.core.*
|
||||
import eu.darken.capod.pods.core.DualPodDevice
|
||||
import eu.darken.capod.pods.core.HasCase
|
||||
import eu.darken.capod.pods.core.HasChargeDetectionDual
|
||||
import eu.darken.capod.pods.core.HasEarDetection
|
||||
import eu.darken.capod.pods.core.HasEarDetectionDual
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.SinglePodDevice
|
||||
import eu.darken.capod.pods.core.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.getBatteryDrawable
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
@@ -23,48 +31,48 @@ class MonitorNotificationViewFactory @Inject constructor(
|
||||
context.packageName,
|
||||
R.layout.monitor_notification_dual_pods_small
|
||||
).apply {
|
||||
device.apply {
|
||||
// Left
|
||||
setImageViewResource(R.id.pod_left_icon, device.leftPodIcon)
|
||||
setTextViewText(R.id.pod_left_label, getBatteryLevelLeftPod(context))
|
||||
val isLeftPodCharging = (device as? HasChargeDetectionDual)?.isLeftPodCharging ?: false
|
||||
setViewVisibility(R.id.pod_left_charging, if (isLeftPodCharging) View.VISIBLE else View.GONE)
|
||||
val isLeftPodInEar = (device as? HasEarDetectionDual)?.isLeftPodInEar ?: false
|
||||
setViewVisibility(R.id.pod_left_ear, if (isLeftPodInEar) View.VISIBLE else View.GONE)
|
||||
// Left
|
||||
val leftPercent = device.batteryLeftPodPercent
|
||||
setImageViewResource(R.id.pod_left_icon, device.leftPodIcon)
|
||||
setTextViewText(R.id.pod_left_label, formatBatteryPercent(context, leftPercent))
|
||||
val isLeftPodCharging = (device as? HasChargeDetectionDual)?.isLeftPodCharging ?: false
|
||||
setViewVisibility(R.id.pod_left_charging, if (isLeftPodCharging) View.VISIBLE else View.GONE)
|
||||
val isLeftPodInEar = (device as? HasEarDetectionDual)?.isLeftPodInEar ?: false
|
||||
setViewVisibility(R.id.pod_left_ear, if (isLeftPodInEar) View.VISIBLE else View.GONE)
|
||||
|
||||
// Case
|
||||
setViewVisibility(R.id.pod_case_charging, if (device is HasCase) View.VISIBLE else View.GONE)
|
||||
(device as? HasCase)?.let { case ->
|
||||
setImageViewResource(R.id.pod_case_icon, device.caseIcon)
|
||||
setTextViewText(R.id.pod_case_label, case.getBatteryLevelCase(context))
|
||||
setViewVisibility(R.id.pod_case_charging, if (case.isCaseCharging) View.VISIBLE else View.GONE)
|
||||
}
|
||||
|
||||
// Right
|
||||
setImageViewResource(R.id.pod_right_icon, device.rightPodIcon)
|
||||
setTextViewText(R.id.pod_right_label, getBatteryLevelRightPod(context))
|
||||
val isRightPodCharging = (device as? HasChargeDetectionDual)?.isRightPodCharging ?: false
|
||||
setViewVisibility(R.id.pod_right_charging, if (isRightPodCharging) View.VISIBLE else View.GONE)
|
||||
val isRightPodInEar = (device as? HasEarDetectionDual)?.isRightPodInEar ?: false
|
||||
setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE)
|
||||
// Case
|
||||
setViewVisibility(R.id.pod_case_charging, if (device is HasCase) View.VISIBLE else View.GONE)
|
||||
(device as? HasCase)?.let { case ->
|
||||
setImageViewResource(R.id.pod_case_icon, device.caseIcon)
|
||||
val casePercent = case.batteryCasePercent
|
||||
setTextViewText(R.id.pod_case_label, formatBatteryPercent(context, casePercent))
|
||||
setViewVisibility(R.id.pod_case_charging, if (case.isCaseCharging) View.VISIBLE else View.GONE)
|
||||
}
|
||||
|
||||
// Right
|
||||
val rightPercent = device.batteryRightPodPercent
|
||||
setImageViewResource(R.id.pod_right_icon, device.rightPodIcon)
|
||||
setTextViewText(R.id.pod_right_label, formatBatteryPercent(context, rightPercent))
|
||||
val isRightPodCharging = (device as? HasChargeDetectionDual)?.isRightPodCharging ?: false
|
||||
setViewVisibility(R.id.pod_right_charging, if (isRightPodCharging) View.VISIBLE else View.GONE)
|
||||
val isRightPodInEar = (device as? HasEarDetectionDual)?.isRightPodInEar ?: false
|
||||
setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE)
|
||||
}
|
||||
|
||||
private fun createSinglePod(device: SinglePodDevice): RemoteViews = RemoteViews(
|
||||
context.packageName,
|
||||
R.layout.monitor_notification_single_pods_small
|
||||
).apply {
|
||||
device.apply {
|
||||
setTextViewText(R.id.headphones_label, getLabel(context))
|
||||
setImageViewResource(R.id.headphones_icon, device.iconRes)
|
||||
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(batteryHeadsetPercent))
|
||||
setTextViewText(R.id.headphones_battery_label, getBatteryLevelHeadset(context))
|
||||
if (this is HasEarDetection) {
|
||||
setViewVisibility(R.id.headphones_worn, if (isBeingWorn) View.VISIBLE else View.GONE)
|
||||
}
|
||||
if (this is HasChargeDetectionDual) {
|
||||
setViewVisibility(R.id.headphones_charging, if (isHeadsetBeingCharged) View.VISIBLE else View.GONE)
|
||||
}
|
||||
val headsetPercent = device.batteryHeadsetPercent
|
||||
setTextViewText(R.id.headphones_label, device.getLabel(context))
|
||||
setImageViewResource(R.id.headphones_icon, device.iconRes)
|
||||
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(headsetPercent))
|
||||
setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent))
|
||||
if (device is HasEarDetection) {
|
||||
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn) View.VISIBLE else View.GONE)
|
||||
}
|
||||
if (device is HasChargeDetectionDual) {
|
||||
setViewVisibility(R.id.headphones_charging, if (device.isHeadsetBeingCharged) View.VISIBLE else View.GONE)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
package eu.darken.capod.monitor.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.work.ForegroundInfo
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.BuildConfigWrap
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.hasApiLevel
|
||||
import eu.darken.capod.common.notifications.PendingIntentCompat
|
||||
import eu.darken.capod.main.ui.MainActivity
|
||||
import eu.darken.capod.pods.core.DualPodDevice
|
||||
@@ -25,10 +21,7 @@ import eu.darken.capod.pods.core.HasChargeDetection
|
||||
import eu.darken.capod.pods.core.HasEarDetection
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.SinglePodDevice
|
||||
import eu.darken.capod.pods.core.getBatteryLevelCase
|
||||
import eu.darken.capod.pods.core.getBatteryLevelHeadset
|
||||
import eu.darken.capod.pods.core.getBatteryLevelLeftPod
|
||||
import eu.darken.capod.pods.core.getBatteryLevelRightPod
|
||||
import eu.darken.capod.pods.core.formatBatteryPercent
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import javax.inject.Inject
|
||||
@@ -112,11 +105,14 @@ class MonitorNotifications @Inject constructor(
|
||||
|
||||
val batteryText = when (device) {
|
||||
is DualPodDevice -> {
|
||||
val left = device.getBatteryLevelLeftPod(context)
|
||||
val right = device.getBatteryLevelRightPod(context)
|
||||
val leftPercent = device.batteryLeftPodPercent
|
||||
val rightPercent = device.batteryRightPodPercent
|
||||
val left = formatBatteryPercent(context, leftPercent)
|
||||
val right = formatBatteryPercent(context, rightPercent)
|
||||
when {
|
||||
device is HasCase -> {
|
||||
val case = device.getBatteryLevelCase(context)
|
||||
val casePercent = device.batteryCasePercent
|
||||
val case = formatBatteryPercent(context, casePercent)
|
||||
"$left $case $right"
|
||||
}
|
||||
|
||||
@@ -125,10 +121,12 @@ class MonitorNotifications @Inject constructor(
|
||||
}
|
||||
|
||||
is SinglePodDevice -> {
|
||||
val headset = device.getBatteryLevelHeadset(context)
|
||||
val headsetPercent = device.batteryHeadsetPercent
|
||||
val headset = formatBatteryPercent(context, headsetPercent)
|
||||
when {
|
||||
device is HasCase -> {
|
||||
val case = device.getBatteryLevelCase(context)
|
||||
val casePercent = device.batteryCasePercent
|
||||
val case = formatBatteryPercent(context, casePercent)
|
||||
"$headset $case"
|
||||
}
|
||||
|
||||
@@ -160,25 +158,9 @@ class MonitorNotifications @Inject constructor(
|
||||
}.build()
|
||||
}
|
||||
|
||||
suspend fun getForegroundInfo(podDevice: PodDevice?): ForegroundInfo = builderLock.withLock {
|
||||
getBuilder(podDevice).apply {
|
||||
setChannelId(NOTIFICATION_CHANNEL_ID)
|
||||
}.toForegroundInfo()
|
||||
}
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
private fun NotificationCompat.Builder.toForegroundInfo(): ForegroundInfo = if (hasApiLevel(29)) {
|
||||
ForegroundInfo(
|
||||
NOTIFICATION_ID,
|
||||
this.build(),
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
|
||||
)
|
||||
} else {
|
||||
ForegroundInfo(
|
||||
NOTIFICATION_ID,
|
||||
this.build()
|
||||
)
|
||||
}
|
||||
fun getStartupNotification(): Notification = getBuilder(null).apply {
|
||||
setChannelId(NOTIFICATION_CHANNEL_ID)
|
||||
}.build()
|
||||
|
||||
companion object {
|
||||
val TAG = logTag("Monitor", "Notifications")
|
||||
|
||||
@@ -8,20 +8,8 @@ import java.time.Duration
|
||||
import java.time.Instant
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun DualPodDevice.getBatteryLevelLeftPod(context: Context): String =
|
||||
batteryLeftPodPercent?.let { "${(it * 100).roundToInt()}%" }
|
||||
?: context.getString(R.string.general_value_not_available_label)
|
||||
|
||||
fun DualPodDevice.getBatteryLevelRightPod(context: Context): String =
|
||||
batteryRightPodPercent?.let { "${(it * 100).roundToInt()}%" }
|
||||
?: context.getString(R.string.general_value_not_available_label)
|
||||
|
||||
fun HasCase.getBatteryLevelCase(context: Context): String =
|
||||
batteryCasePercent?.let { "${(it * 100).roundToInt()}%" }
|
||||
?: context.getString(R.string.general_value_not_available_label)
|
||||
|
||||
fun SinglePodDevice.getBatteryLevelHeadset(context: Context): String =
|
||||
batteryHeadsetPercent?.let { "${(it * 100).roundToInt()}%" }
|
||||
fun formatBatteryPercent(context: Context, percent: Float?): String =
|
||||
percent?.let { "${(it * 100).roundToInt()}%" }
|
||||
?: context.getString(R.string.general_value_not_available_label)
|
||||
|
||||
fun PodDevice.getSignalQuality(context: Context): String {
|
||||
@@ -42,18 +30,17 @@ fun getBatteryDrawable(percent: Float?): Int = when {
|
||||
else -> R.drawable.ic_baseline_battery_0_bar_24
|
||||
}
|
||||
|
||||
private val lastSeenFormatter = RelativeDateTimeFormatter.getInstance()
|
||||
|
||||
fun PodDevice.lastSeenFormatted(now: Instant): String {
|
||||
val formatter = RelativeDateTimeFormatter.getInstance()
|
||||
val duration = Duration.between(seenLastAt, now)
|
||||
return if (duration > Duration.ofMinutes(1)) {
|
||||
lastSeenFormatter.format(
|
||||
formatter.format(
|
||||
duration.toMinutes().toDouble(),
|
||||
RelativeDateTimeFormatter.Direction.LAST,
|
||||
RelativeDateTimeFormatter.RelativeUnit.MINUTES
|
||||
)
|
||||
} else {
|
||||
lastSeenFormatter.format(
|
||||
formatter.format(
|
||||
duration.seconds.toDouble(),
|
||||
RelativeDateTimeFormatter.Direction.LAST,
|
||||
RelativeDateTimeFormatter.RelativeUnit.SECONDS
|
||||
@@ -62,8 +49,9 @@ fun PodDevice.lastSeenFormatted(now: Instant): String {
|
||||
}
|
||||
|
||||
fun PodDevice.firstSeenFormatted(now: Instant): String {
|
||||
val formatter = RelativeDateTimeFormatter.getInstance()
|
||||
val duration = Duration.between(seenFirstAt, now)
|
||||
return lastSeenFormatter.format(
|
||||
return formatter.format(
|
||||
duration.toMinutes().toDouble(),
|
||||
RelativeDateTimeFormatter.Direction.LAST,
|
||||
RelativeDateTimeFormatter.RelativeUnit.MINUTES
|
||||
|
||||
@@ -12,7 +12,13 @@ import eu.darken.capod.R
|
||||
import eu.darken.capod.common.debug.DebugSettings
|
||||
import eu.darken.capod.databinding.PopupNotificationDualPodsBinding
|
||||
import eu.darken.capod.databinding.PopupNotificationSinglePodsBinding
|
||||
import eu.darken.capod.pods.core.*
|
||||
import eu.darken.capod.pods.core.DualPodDevice
|
||||
import eu.darken.capod.pods.core.HasCase
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.SinglePodDevice
|
||||
import eu.darken.capod.pods.core.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.getBatteryDrawable
|
||||
import eu.darken.capod.pods.core.getSignalQuality
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
@@ -33,43 +39,43 @@ class PopUpPodViewFactory @Inject constructor(
|
||||
|
||||
private fun createDualPods(parent: ViewGroup, device: DualPodDevice): View =
|
||||
PopupNotificationDualPodsBinding.inflate(layoutInflater, parent, false).apply {
|
||||
device.apply {
|
||||
podIcon.setImageResource(iconRes)
|
||||
podLabel.text = getLabel(context)
|
||||
signal.text = getSignalQuality(context)
|
||||
signal.isInvisible = debugSettings.isDebugModeEnabled.value
|
||||
podIcon.setImageResource(device.iconRes)
|
||||
podLabel.text = device.getLabel(context)
|
||||
signal.text = device.getSignalQuality(context)
|
||||
signal.isInvisible = debugSettings.isDebugModeEnabled.value
|
||||
|
||||
// Left
|
||||
podLeftIcon.setImageResource(device.leftPodIcon)
|
||||
podLeftBatteryIcon.setImageResource(getBatteryDrawable(batteryLeftPodPercent))
|
||||
podLeftBatteryLabel.text = getBatteryLevelLeftPod(context)
|
||||
// Left
|
||||
val leftPercent = device.batteryLeftPodPercent
|
||||
podLeftIcon.setImageResource(device.leftPodIcon)
|
||||
podLeftBatteryIcon.setImageResource(getBatteryDrawable(leftPercent))
|
||||
podLeftBatteryLabel.text = formatBatteryPercent(context, leftPercent)
|
||||
|
||||
// Case
|
||||
podCaseContainer.isVisible = device is HasCase
|
||||
(device as? HasCase)?.let { case ->
|
||||
podCaseIcon.setImageResource(case.caseIcon)
|
||||
podCaseBatteryIcon.setImageResource(getBatteryDrawable(case.batteryCasePercent))
|
||||
podCaseBatteryLabel.text = case.getBatteryLevelCase(context)
|
||||
}
|
||||
|
||||
// Right
|
||||
podRightIcon.setImageResource(device.rightPodIcon)
|
||||
podRightBatteryIcon.setImageResource(getBatteryDrawable(batteryRightPodPercent))
|
||||
podRightBatteryLabel.text = getBatteryLevelRightPod(context)
|
||||
// Case
|
||||
podCaseContainer.isVisible = device is HasCase
|
||||
(device as? HasCase)?.let { case ->
|
||||
val casePercent = case.batteryCasePercent
|
||||
podCaseIcon.setImageResource(case.caseIcon)
|
||||
podCaseBatteryIcon.setImageResource(getBatteryDrawable(casePercent))
|
||||
podCaseBatteryLabel.text = formatBatteryPercent(context, casePercent)
|
||||
}
|
||||
|
||||
// Right
|
||||
val rightPercent = device.batteryRightPodPercent
|
||||
podRightIcon.setImageResource(device.rightPodIcon)
|
||||
podRightBatteryIcon.setImageResource(getBatteryDrawable(rightPercent))
|
||||
podRightBatteryLabel.text = formatBatteryPercent(context, rightPercent)
|
||||
}.root
|
||||
|
||||
private fun createSinglePod(parent: ViewGroup, device: SinglePodDevice): View =
|
||||
PopupNotificationSinglePodsBinding.inflate(layoutInflater, parent, false).apply {
|
||||
device.apply {
|
||||
headphonesIcon.setImageResource(iconRes)
|
||||
headphonesLabel.text = getLabel(context)
|
||||
signal.text = getSignalQuality(context)
|
||||
signal.isInvisible = debugSettings.isDebugModeEnabled.value
|
||||
headphonesIcon.setImageResource(device.iconRes)
|
||||
headphonesLabel.text = device.getLabel(context)
|
||||
signal.text = device.getSignalQuality(context)
|
||||
signal.isInvisible = debugSettings.isDebugModeEnabled.value
|
||||
|
||||
headphonesBatteryIcon.setImageResource(getBatteryDrawable(batteryHeadsetPercent))
|
||||
headphonesBatteryLabel.text = getBatteryLevelHeadset(context)
|
||||
}
|
||||
val headsetPercent = device.batteryHeadsetPercent
|
||||
headphonesBatteryIcon.setImageResource(getBatteryDrawable(headsetPercent))
|
||||
headphonesBatteryLabel.text = formatBatteryPercent(context, headsetPercent)
|
||||
}.root
|
||||
|
||||
}
|
||||
@@ -5,14 +5,14 @@
|
||||
<item
|
||||
android:id="@+id/menu_item_donate"
|
||||
android:icon="@drawable/ic_baseline_heart_24"
|
||||
android:title="@string/settings_general_label"
|
||||
android:title="@string/general_donate_action"
|
||||
android:visible="false"
|
||||
tool:visible="true"
|
||||
app:showAsAction="always" />
|
||||
<item
|
||||
android:id="@+id/menu_item_upgrade"
|
||||
android:icon="@drawable/ic_baseline_stars_24"
|
||||
android:title="@string/settings_general_label"
|
||||
android:title="@string/general_upgrade_action"
|
||||
android:visible="false"
|
||||
tool:visible="true"
|
||||
app:showAsAction="always" />
|
||||
|
||||
@@ -212,5 +212,22 @@
|
||||
<string name="profiles_delete_message">¿Estás seguro de que quieres eliminar este perfil? Esto no se puede deshacer.</string>
|
||||
<string name="profiles_delete_action">Borrar</string>
|
||||
<string name="profiles_basic_info_title">Información del dispositivo</string>
|
||||
<string name="profiles_basic_info_description">Configure el nombre de su dispositivo, modelo y sincronización opcional con Bluetooth.</string>
|
||||
<string name="profiles_signal_quality_title">Calidad mínima de la señal</string>
|
||||
<string name="profiles_signal_quality_description">Solo detecta dispositivos con una intensidad de señal superior a este umbral. Valores más bajos aumentan el rango de detección pero pueden causar falsos positivos. No lo configures demasiado alto; la recepción de Bluetooth es generalmente mala y varía con la distancia y los obstáculos.</string>
|
||||
<string name="profiles_identitykey_label">Clave de Identidad</string>
|
||||
<string name="profilessettings_maindevice_identitykey_description">La Clave de Resolución de Identidad (IRK) de su dispositivo, que ayuda a CAPod a identificarlo entre los dispositivos cercanos.</string>
|
||||
<string name="profiles_maindevice_identitykey_explanation">Los AirPods cambian con frecuencia su dirección Bluetooth por motivos de privacidad. El IRK ayuda a CAPod a reconocer tu dispositivo. Necesitas acceso único a un MacBook.</string>
|
||||
<string name="profiles_maindevice_encryptionkey_label">Clave de cifrado</string>
|
||||
<string name="profiles_maindevice_encryptionkey_description">La clave de cifrado de tu dispositivo, que permite a CAPod recuperar información detallada sobre el estado.</string>
|
||||
<string name="profiles_maindevice_encryptionkey_explanation">Los AirPods envían un mensaje de estado, parte del cual está cifrado. La clave de cifrado permite a CAPod descifrar el mensaje completo. Se necesita acceso único a un MacBook.</string>
|
||||
<string name="profiles_key_invalid_format">El formato de la clave no es correcta</string>
|
||||
<string name="profiles_key_expected_format">Formato esperado: %1$s</string>
|
||||
<string name="profiles_priority_hint">El orden de los perfiles determina la prioridad. Arrastra los perfiles para reordenarlos: los perfiles que aparecen más arriba en la lista tienen prioridad cuando coinciden varios dispositivos.</string>
|
||||
<!-- Unsaved changes dialog -->
|
||||
<string name="general_unsaved_changes_title">Cambios sin guardar</string>
|
||||
<string name="general_unsaved_changes_message">Tienes cambios sin guardar. ¿Qué quieres hacer?</string>
|
||||
<string name="general_save_and_exit_action">Guardar y salir</string>
|
||||
<string name="general_discard_action">Descartar</string>
|
||||
<string name="general_keep_editing_action">Seguir editando</string>
|
||||
</resources>
|
||||
|
||||
@@ -212,5 +212,22 @@
|
||||
<string name="profiles_delete_message">¿Estás seguro de que quieres eliminar este perfil? Esto no se puede deshacer.</string>
|
||||
<string name="profiles_delete_action">Borrar</string>
|
||||
<string name="profiles_basic_info_title">Información del dispositivo</string>
|
||||
<string name="profiles_basic_info_description">Configure el nombre de su dispositivo, modelo y sincronización opcional con Bluetooth.</string>
|
||||
<string name="profiles_signal_quality_title">Calidad mínima de la señal</string>
|
||||
<string name="profiles_signal_quality_description">Solo detecta dispositivos con una intensidad de señal superior a este umbral. Valores más bajos aumentan el rango de detección pero pueden causar falsos positivos. No lo configures demasiado alto; la recepción de Bluetooth es generalmente mala y varía con la distancia y los obstáculos.</string>
|
||||
<string name="profiles_identitykey_label">Clave de Identidad</string>
|
||||
<string name="profilessettings_maindevice_identitykey_description">La Clave de Resolución de Identidad (IRK) de su dispositivo, que ayuda a CAPod a identificarlo entre los dispositivos cercanos.</string>
|
||||
<string name="profiles_maindevice_identitykey_explanation">Los AirPods cambian con frecuencia su dirección Bluetooth por motivos de privacidad. El IRK ayuda a CAPod a reconocer tu dispositivo. Necesitas acceso único a un MacBook.</string>
|
||||
<string name="profiles_maindevice_encryptionkey_label">Clave de cifrado</string>
|
||||
<string name="profiles_maindevice_encryptionkey_description">La clave de cifrado de tu dispositivo, que permite a CAPod recuperar información detallada sobre el estado.</string>
|
||||
<string name="profiles_maindevice_encryptionkey_explanation">Los AirPods envían un mensaje de estado, parte del cual está cifrado. La clave de cifrado permite a CAPod descifrar el mensaje completo. Se necesita acceso único a un MacBook.</string>
|
||||
<string name="profiles_key_invalid_format">El formato de la clave no es correcta</string>
|
||||
<string name="profiles_key_expected_format">Formato esperado: %1$s</string>
|
||||
<string name="profiles_priority_hint">El orden de los perfiles determina la prioridad. Arrastra los perfiles para reordenarlos: los perfiles que aparecen más arriba en la lista tienen prioridad cuando coinciden varios dispositivos.</string>
|
||||
<!-- Unsaved changes dialog -->
|
||||
<string name="general_unsaved_changes_title">Cambios sin guardar</string>
|
||||
<string name="general_unsaved_changes_message">Tienes cambios sin guardar. ¿Qué quieres hacer?</string>
|
||||
<string name="general_save_and_exit_action">Guardar y salir</string>
|
||||
<string name="general_discard_action">Descartar</string>
|
||||
<string name="general_keep_editing_action">Seguir editando</string>
|
||||
</resources>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<string name="settings_keep_notification_after_disconnect_label">Melding behouden na verbreken verbinding</string>
|
||||
<string name="settings_keep_notification_after_disconnect_description">Blijf de laatst bekende batterijniveaus weergeven, zelfs nadat je AirPods zijn losgekoppeld</string>
|
||||
<string name="settings_scanner_mode_label">Scannermodus</string>
|
||||
<string name="settings_scanner_mode_description">Moet de Bluetooth Low Energy-gegevensscanner prioriteit geven aan prestaties of energie besparen?</string>
|
||||
<string name="settings_scanner_mode_description">Moet de \'Bluetooth Low Energy\'-datascanner prioriteit geven aan prestaties of aan energiebesparing?</string>
|
||||
<string name="settings_autopause_label">Auto pauze</string>
|
||||
<string name="settings_autopause_description">Pauzeer de muziek wanneer u het apparaat van uw oor haalt.</string>
|
||||
<string name="settings_autopplay_label">Automatisch afspelen</string>
|
||||
@@ -49,7 +49,7 @@
|
||||
<string name="settings_category_compatibility_options_description">Niet aankomen als alles werkt ;-)</string>
|
||||
<string name="settings_compat_offloaded_filtering_disabled_title">Hardware filtering uitzetten</string>
|
||||
<string name="settings_compat_offloaded_filtering_disabled_summary">Data filtering niet aan het systeem toevertrouwen, in plaats daarvan alle data in de app ontvangen en filteren.</string>
|
||||
<string name="settings_compat_offloaded_batching_disabled_title">Hardware batching uitzetten</string>
|
||||
<string name="settings_compat_offloaded_batching_disabled_title">Schakel hardwarebatchverwerking uit.</string>
|
||||
<string name="settings_compat_offloaded_batching_disabled_summary">Laat de systeemgroep geen BLE-gegevens verzamelen voordat deze naar ons wordt doorgestuurd.</string>
|
||||
<string name="settings_onepod_mode_label">Eén pod-modus</string>
|
||||
<string name="settings_onepod_mode_description">Het dragen van beide pods is niet vereist, het dragen van een enkele pod is voldoende om reacties uit te lokken.</string>
|
||||
@@ -151,7 +151,7 @@
|
||||
<string name="permission_bluetooth_label">Bluetooth</string>
|
||||
<string name="permission_bluetooth_description">Deze app heeft de toestemming \'Bluetooth\' nodig om verbinding te maken met gekoppelde Bluetooth-apparaten.</string>
|
||||
<string name="permission_access_fine_location_label">Toegang tot een prima locatie</string>
|
||||
<string name="permission_access_fine_location_description">CAPod gebruikt de \'prima locatie\'-machtiging om Bluetooth Low Energy-gegevens te ontvangen. Je hoofdtelefoon gebruikt Bluetooth Low Energy-technologie om zijn status uit te zenden. Deze app gebruikt GEEN Bluetooth-gegevens om je locatie te bepalen.</string>
|
||||
<string name="permission_access_fine_location_description">CAPod gebruikt de \'precieze locatie\'-machtiging om Bluetooth Low Energy-gegevens te ontvangen. Je hoofdtelefoon gebruikt Bluetooth Low Energy-technologie om zijn status uit te zenden. Deze app gebruikt GEEN Bluetooth-gegevens om je locatie te bepalen.</string>
|
||||
<string name="permission_background_location_label">Toegang tot achtergrondlocatie</string>
|
||||
<string name="permission_background_location_description">CAPod gebruikt \'locatietoegang op de achtergrond\' om functies zoals \'Pop-up weergeven\' en \'Automatisch verbinden\' in te schakelen terwijl de app gesloten is. Met locatietoegang op de achtergrond kan de app Bluetooth Low Energy-gegevens ontvangen terwijl deze op de achtergrond actief is. Deze app gebruikt GEEN Bluetooth-gegevens om je locatie te bepalen.</string>
|
||||
<string name="permission_ignore_battery_optimizations_label">Batterij-optimalisaties uitschakelen</string>
|
||||
@@ -180,13 +180,50 @@
|
||||
<string name="pods_connection_state_ringing_label">Rinkelen</string>
|
||||
<string name="pods_connection_state_hanging_up_label">Ophangen</string>
|
||||
<string name="pods_connection_state_unknown_label">Onbekende verbindingsstatus</string>
|
||||
<string name="pods_unknown_label">Onbekend apparaat</string>
|
||||
<string name="pods_unknown_contact_dev">Dit is een onbekend apparaat, maar het gebruikt hetzelfde berichtformaat</string>
|
||||
<string name="pods_none_label_short">Geen apparaat</string>
|
||||
<string name="pods_charging_label">Opladen</string>
|
||||
<string name="pods_inear_label">In oor</string>
|
||||
<string name="pods_microphone_label">Microfoon</string>
|
||||
<string name="pods_yours">Uw</string>
|
||||
<string name="headset_being_worn_label">Wordt gedragen</string>
|
||||
<string name="headset_not_being_worn_label">Wordt niet gedragen</string>
|
||||
<string name="pods_case_unknown_state">Onbekende status</string>
|
||||
<string name="last_seen_x">Laatst gezien: %s</string>
|
||||
<string name="first_seen_x">Voor het eerst gezien: %s</string>
|
||||
<string name="permission_post_notifications_label">Toon notificaties</string>
|
||||
<string name="permission_post_notifications_description">"Sta Capod toe om notificaties te tonen over je Airpods, bijvoorbeeld de huidige verbindingsstatus."</string>
|
||||
<!-- Device profiles -->
|
||||
<string name="profiles_empty_title">Geen apparaatprofielen geconfigureerd</string>
|
||||
<string name="profiles_empty_description">Maak apparaat profielen voor meerdere apparaten met aangepaste instellingen en prioriteiten.</string>
|
||||
<string name="profiles_add_action">Profiel toevoegen</string>
|
||||
<string name="profiles_create_title">Profiel aanmaken</string>
|
||||
<string name="profiles_name_label">Profiel naam</string>
|
||||
<string name="profiles_name_default">Mijn koptelefoon</string>
|
||||
<string name="profiles_model_label">Apparaatmodel</string>
|
||||
<string name="profiles_paired_device_label">Gekoppeld apparaat</string>
|
||||
<string name="profiles_paired_device_none">Geen</string>
|
||||
<string name="profiles_paired_device_none_description">Geen apparaat geselecteerd</string>
|
||||
<string name="profiles_save_action">Profiel opslaan</string>
|
||||
<string name="profiles_drag_handle_description">Sleep om de volgorde aan te passen</string>
|
||||
<string name="profiles_delete_title">Verwijder profiel</string>
|
||||
<string name="profiles_delete_message">Ben je zeker dat je dit profiel wilt verwijderen? Dit kan niet ongedaan worden gemaakt.</string>
|
||||
<string name="profiles_delete_action">Verwijderen</string>
|
||||
<string name="profiles_basic_info_title">Apparaat informatie</string>
|
||||
<string name="profiles_basic_info_description">Configureer jouw apparaat naam, model en optioneel bluetooth koppeling.</string>
|
||||
<string name="profiles_signal_quality_title">Minimale signaal kwaliteit</string>
|
||||
<string name="profiles_signal_quality_description">Detecteer alleen apparaten met een signaalsterkte boven deze drempelwaarde. Lagere waarden vergroten het detectiebereik, maar kunnen valse positieven veroorzaken. Stel deze waarde niet te hoog in - Bluetooth-ontvangst is over het algemeen slecht en varieert met de afstand en obstakels.</string>
|
||||
<string name="profiles_identitykey_label">Identiteitssleutel</string>
|
||||
<string name="profilessettings_maindevice_identitykey_description">De Identity Resolving Key (IRK) van uw apparaat, waarmee CAPod uw apparaat kan identificeren tussen apparaten in de buurt.</string>
|
||||
<string name="profiles_maindevice_identitykey_explanation">AirPods veranderen regelmatig hun Bluetooth-adres om privacyredenen. De IRK helpt CAPod uw apparaat te herkennen. U hebt eenmalig toegang tot een MacBook nodig.</string>
|
||||
<string name="profiles_maindevice_encryptionkey_label">Encryptiesleutel</string>
|
||||
<string name="profiles_maindevice_encryptionkey_explanation">AirPods sturen een statusbericht, waarvan een deel versleuteld is. De encryptiesleutel stelt CAPod in staat het volledige bericht te ontsleutelen. Je hebt hiervoor eenmalige toegang tot een MacBook nodig.</string>
|
||||
<string name="profiles_key_invalid_format">Ongeldige sleutelindeling</string>
|
||||
<string name="profiles_key_expected_format">Verwachte indeling: %1$s</string>
|
||||
<string name="profiles_priority_hint">De volgorde van de profielen bepaalt de prioriteit. Versleep profielen om de volgorde te wijzigen. Profielen hoger in de lijst krijgen voorrang wanneer meerdere apparaten overeenkomen.</string>
|
||||
<!-- Unsaved changes dialog -->
|
||||
<string name="general_unsaved_changes_title">Niet-opgeslagen wijzigingen</string>
|
||||
<string name="general_unsaved_changes_title">Niet opgeslagen wijzigingen</string>
|
||||
<string name="general_unsaved_changes_message">Er zijn nog geen wijzigingen opgeslagen. Wat wil je doen?</string>
|
||||
<string name="general_save_and_exit_action">Opslaan & 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>
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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=3
|
||||
project.versioning.build=0
|
||||
project.versioning.patch=4
|
||||
project.versioning.build=1
|
||||
project.versioning.type=rc
|
||||
#############################
|
||||
|
||||
Reference in New Issue
Block a user