Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f66916a58 |
@@ -1,24 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{dart,json,yaml,yml}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.{kt,kts,swift}]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{bat,cmd,ps1}]
|
||||
end_of_line = crlf
|
||||
@@ -1,24 +0,0 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
# Scripts executed on Unix runners must keep LF line endings.
|
||||
*.sh text eol=lf
|
||||
gradlew text eol=lf
|
||||
|
||||
# Windows scripts
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
|
||||
# Binary files
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
*.ico binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
*.jar binary
|
||||
*.aar binary
|
||||
*.keystore binary
|
||||
*.jks binary
|
||||
@@ -1,3 +0,0 @@
|
||||
github: zarzet
|
||||
ko_fi: zarzet
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
name: Bug Report
|
||||
description: Report a reproducible bug or unexpected behavior
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for reporting a SpotiFLAC Mobile bug. Please provide enough detail for another person to reproduce it.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I searched existing issues and did not find this bug.
|
||||
required: true
|
||||
- label: I can reproduce this on the release channel selected below.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: What happened, and what did you expect instead?
|
||||
placeholder: Include the visible error and any relevant context.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
placeholder: |
|
||||
1. Open ...
|
||||
2. Enable ...
|
||||
3. Tap ...
|
||||
4. Observe ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: trigger
|
||||
attributes:
|
||||
label: Area
|
||||
options:
|
||||
- App startup
|
||||
- Search or navigation
|
||||
- Download or verification
|
||||
- Library or metadata
|
||||
- Playback or background service
|
||||
- Audio analysis
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: App Version
|
||||
description: Find this in Settings > About.
|
||||
placeholder: "e.g. 4.8.1 (139)"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: channel
|
||||
attributes:
|
||||
label: Release Channel
|
||||
options:
|
||||
- Stable
|
||||
- Beta
|
||||
- Local or development build
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: device
|
||||
attributes:
|
||||
label: Device and OS
|
||||
placeholder: "e.g. Samsung Galaxy S24, Android 16"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: storage
|
||||
attributes:
|
||||
label: Download Storage
|
||||
options:
|
||||
- SAF folder
|
||||
- App-specific folder
|
||||
- Not applicable
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: worker
|
||||
attributes:
|
||||
label: Download Worker
|
||||
options:
|
||||
- Native worker
|
||||
- Dart worker
|
||||
- Not applicable
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: provider
|
||||
attributes:
|
||||
label: Extension or Provider
|
||||
description: If relevant, include the extension/provider name and version.
|
||||
placeholder: "Name, version, and selected priority"
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs and Media
|
||||
description: |
|
||||
Enable detailed logging, reproduce the problem, then export logs from Settings > Logs. For an Android cold-start crash, attach `adb logcat -b crash -d`. Add screenshots or a short recording when useful. Remove credentials and personal data first.
|
||||
placeholder: Paste logs or drag files here.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
placeholder: Settings, frequency, regression version, or anything else that may help.
|
||||
@@ -1,8 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: README
|
||||
url: https://github.com/spotiflacapp/SpotiFLAC-Mobile#readme
|
||||
about: Check the README for setup instructions and FAQ
|
||||
- name: Extension Development Guide
|
||||
url: https://spotiflac.zarz.moe/docs
|
||||
about: Documentation for building SpotiFLAC Mobile extensions
|
||||
@@ -1,124 +0,0 @@
|
||||
name: Download Issue
|
||||
description: Report a wrong, missing, failed, or low-quality download
|
||||
title: "[Download]: "
|
||||
labels: ["download-issue"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Download matching is extension-driven. Include the source item, provider order, and logs so the exact decision path can be inspected.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I searched existing issues and did not find this problem.
|
||||
required: true
|
||||
- label: I updated the affected extension and retried once.
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: issue-type
|
||||
attributes:
|
||||
label: Issue Type
|
||||
options:
|
||||
- Track or album not found
|
||||
- Wrong track downloaded
|
||||
- Download fails or stalls
|
||||
- Metadata or cover is incorrect
|
||||
- Audio format or quality is incorrect
|
||||
- Duplicate or replacement behavior
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: source-url
|
||||
attributes:
|
||||
label: Source URL
|
||||
description: URL pasted or opened in SpotiFLAC Mobile, if available.
|
||||
placeholder: "https://..."
|
||||
|
||||
- type: input
|
||||
id: track-info
|
||||
attributes:
|
||||
label: Track or Album
|
||||
placeholder: "Artist - Track or album title"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: metadata-source
|
||||
attributes:
|
||||
label: Search or Metadata Extension
|
||||
placeholder: "Extension name and version"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: download-provider
|
||||
attributes:
|
||||
label: Selected Download Extension
|
||||
placeholder: "Extension name and version"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: priority
|
||||
attributes:
|
||||
label: Provider Priority and Requested Quality
|
||||
placeholder: |
|
||||
Provider order: first, second, third
|
||||
Requested quality: best / 24-bit / ...
|
||||
Automatic fallback: on/off
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: storage
|
||||
attributes:
|
||||
label: Download Storage
|
||||
options:
|
||||
- SAF folder
|
||||
- App-specific folder
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: worker
|
||||
attributes:
|
||||
label: Download Worker
|
||||
options:
|
||||
- Native worker
|
||||
- Dart worker
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What Happened?
|
||||
description: For a wrong match, identify both the expected and downloaded recording.
|
||||
placeholder: Describe the result and the expected result.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: environment
|
||||
attributes:
|
||||
label: App Version, Device, and OS
|
||||
placeholder: "4.8.1 (139), device model, Android/iOS version"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs and Screenshots
|
||||
description: Enable detailed logging, retry once, and export Settings > Logs. Remove credentials and personal data.
|
||||
placeholder: Paste logs or drag files here.
|
||||
validations:
|
||||
required: true
|
||||
@@ -1,117 +0,0 @@
|
||||
name: Extension API Feature Request
|
||||
description: Request new API features or capabilities for extension development
|
||||
title: "[Extension API]: "
|
||||
labels: ["enhancement", "extension-api"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for helping improve the SpotiFLAC Mobile Extension API!
|
||||
This form is for extension developers who need new features or capabilities that don't exist yet.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I have read the [Extension Development Guide](https://github.com/spotiflacapp/SpotiFLAC-Mobile/blob/main/docs/EXTENSION_DEVELOPMENT.md)
|
||||
required: true
|
||||
- label: I have searched existing issues and this API feature hasn't been requested yet
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: extension_goal
|
||||
attributes:
|
||||
label: What are you trying to build?
|
||||
description: Describe the extension or feature you're developing
|
||||
placeholder: "I'm building an extension that downloads from [service name] / provides metadata from [source]..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: current_limitation
|
||||
attributes:
|
||||
label: Current API Limitation
|
||||
description: What's missing or limiting in the current extension API?
|
||||
placeholder: |
|
||||
The current API doesn't support:
|
||||
- [missing feature 1]
|
||||
- [missing feature 2]
|
||||
|
||||
This prevents me from...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposed_api
|
||||
attributes:
|
||||
label: Proposed API / Feature
|
||||
description: Describe the API or feature you'd like to see added
|
||||
placeholder: |
|
||||
I would like to have:
|
||||
- A new function `api.newFeature()` that does X
|
||||
- A new manifest field `newOption` that enables Y
|
||||
- Access to Z capability...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: use_case
|
||||
attributes:
|
||||
label: Use Case Example
|
||||
description: Provide a code example of how you would use this feature
|
||||
placeholder: |
|
||||
```javascript
|
||||
// Example usage in extension code
|
||||
function download(request, progressCallback) {
|
||||
const result = api.proposedFeature(params);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: api_category
|
||||
attributes:
|
||||
label: API Category
|
||||
description: What category does this feature fall under?
|
||||
options:
|
||||
- HTTP/Network API
|
||||
- File System API
|
||||
- Storage API
|
||||
- FFmpeg/Audio Processing
|
||||
- Manifest Options
|
||||
- Runtime Functions
|
||||
- UI Integration
|
||||
- Authentication
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: How critical is this for your extension?
|
||||
options:
|
||||
- Blocker - Cannot build my extension without this
|
||||
- High - Major functionality depends on this
|
||||
- Medium - Would significantly improve my extension
|
||||
- Low - Nice to have
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: workaround
|
||||
attributes:
|
||||
label: Current Workaround
|
||||
description: Are you using any workaround currently? If so, describe it.
|
||||
placeholder: "Currently I'm working around this by..."
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, links to similar APIs, or examples from other platforms
|
||||
placeholder: "Similar feature in other platforms: ..."
|
||||
@@ -1,66 +0,0 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for suggesting a feature! Please fill out the form below.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I have searched existing issues and this feature hasn't been requested yet
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem / Motivation
|
||||
description: Is your feature request related to a problem? Please describe.
|
||||
placeholder: "A clear description of what the problem is. Ex: I'm always frustrated when..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe the solution you'd like
|
||||
placeholder: A clear description of what you want to happen...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Describe any alternative solutions or features you've considered
|
||||
placeholder: Other approaches you've thought about...
|
||||
|
||||
- type: dropdown
|
||||
id: category
|
||||
attributes:
|
||||
label: Category
|
||||
description: What category does this feature fall under?
|
||||
options:
|
||||
- UI/UX Improvement
|
||||
- Download Feature
|
||||
- Extension System/API
|
||||
- Metadata/Tagging
|
||||
- Performance
|
||||
- Settings/Configuration
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, mockups, or screenshots about the feature request
|
||||
placeholder: Add any other context or screenshots...
|
||||
@@ -1,42 +0,0 @@
|
||||
## Summary
|
||||
|
||||
<!-- What changed, why is it needed, and which user-visible behavior is affected? -->
|
||||
|
||||
## Related Issues
|
||||
|
||||
<!-- Use "Fixes #123" when this PR should close an issue. -->
|
||||
|
||||
## Type of Change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] Feature
|
||||
- [ ] Performance improvement
|
||||
- [ ] Refactor with no intended behavior change
|
||||
- [ ] Tests, build, or repository maintenance
|
||||
- [ ] Documentation or localization
|
||||
|
||||
## Validation
|
||||
|
||||
<!-- Check only commands relevant to this change. Explain skipped checks below. -->
|
||||
|
||||
- [ ] `dart format --output=none --set-exit-if-changed lib test`
|
||||
- [ ] `flutter analyze`
|
||||
- [ ] `flutter test`
|
||||
- [ ] `gofmt`, `go vet ./...`, and `go test ./...` in `go_backend/`
|
||||
- [ ] Android or iOS native checks
|
||||
- [ ] Manual reproduction or device test
|
||||
|
||||
Validation notes:
|
||||
|
||||
<!-- Devices, commands, test cases, or the reason a check was not applicable. -->
|
||||
|
||||
## Contributor Checklist
|
||||
|
||||
- [ ] This PR is focused and contains no unrelated changes.
|
||||
- [ ] Extension-specific behavior, if any, uses a generic capability or manifest contract.
|
||||
- [ ] User-facing strings, documentation, and generated files are updated where needed.
|
||||
- [ ] No credentials, downloaded media, or build artifacts are included.
|
||||
|
||||
## Screenshots or Recordings
|
||||
|
||||
<!-- Add before/after media for UI changes. Delete this section otherwise. -->
|
||||
@@ -0,0 +1,75 @@
|
||||
name: Android Build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
- name: Install Android SDK & NDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install gomobile
|
||||
run: |
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
gomobile init
|
||||
|
||||
- name: Build Go backend for Android
|
||||
working-directory: go_backend
|
||||
run: |
|
||||
mkdir -p ../android/app/libs
|
||||
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Generate app icons
|
||||
run: dart run flutter_launcher_icons
|
||||
|
||||
- name: Build APK (Release)
|
||||
run: flutter build apk --release
|
||||
|
||||
- name: Build App Bundle (Release)
|
||||
run: flutter build appbundle --release
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: SpotiFLAC-Android-APK
|
||||
path: build/app/outputs/flutter-apk/app-release.apk
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload AAB artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: SpotiFLAC-Android-AAB
|
||||
path: build/app/outputs/bundle/release/app-release.aab
|
||||
retention-days: 30
|
||||
@@ -0,0 +1,69 @@
|
||||
name: Auto Release on Version Bump
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'pubspec.yaml'
|
||||
|
||||
jobs:
|
||||
check-version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version_changed: ${{ steps.check.outputs.changed }}
|
||||
new_version: ${{ steps.check.outputs.version }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Check if version changed
|
||||
id: check
|
||||
run: |
|
||||
# Get current version
|
||||
CURRENT_VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
|
||||
|
||||
# Get previous version
|
||||
git show HEAD~1:pubspec.yaml > /tmp/old_pubspec.yaml 2>/dev/null || echo "version: 0.0.0" > /tmp/old_pubspec.yaml
|
||||
PREVIOUS_VERSION=$(grep '^version:' /tmp/old_pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
|
||||
|
||||
echo "Current version: $CURRENT_VERSION"
|
||||
echo "Previous version: $PREVIOUS_VERSION"
|
||||
|
||||
if [ "$CURRENT_VERSION" != "$PREVIOUS_VERSION" ]; then
|
||||
echo "Version changed!"
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
echo "version=v$CURRENT_VERSION" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Version unchanged"
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
create-tag-and-trigger-release:
|
||||
needs: check-version
|
||||
if: needs.check-version.outputs.version_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Create and push tag
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag ${{ needs.check-version.outputs.new_version }}
|
||||
git push origin ${{ needs.check-version.outputs.new_version }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Trigger Release workflow
|
||||
run: |
|
||||
gh workflow run release.yml -f version=${{ needs.check-version.outputs.new_version }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,77 +0,0 @@
|
||||
name: Auto Tag on Version Change
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'pubspec.yaml'
|
||||
|
||||
jobs:
|
||||
check-version:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 2 # Need previous commit to compare
|
||||
|
||||
- name: Get current version
|
||||
id: current
|
||||
run: |
|
||||
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Current version: $VERSION"
|
||||
|
||||
- name: Get previous version
|
||||
id: previous
|
||||
run: |
|
||||
git checkout HEAD~1 -- pubspec.yaml 2>/dev/null || echo "version: 0.0.0" > pubspec.yaml.old
|
||||
if [ -f pubspec.yaml.old ]; then
|
||||
VERSION=$(grep '^version:' pubspec.yaml.old | sed 's/version: //' | cut -d'+' -f1)
|
||||
else
|
||||
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
|
||||
fi
|
||||
git checkout HEAD -- pubspec.yaml
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Previous version: $VERSION"
|
||||
|
||||
- name: Check if version changed
|
||||
id: check
|
||||
run: |
|
||||
CURRENT="${{ steps.current.outputs.version }}"
|
||||
PREVIOUS="${{ steps.previous.outputs.version }}"
|
||||
|
||||
if [ "$CURRENT" != "$PREVIOUS" ]; then
|
||||
echo "Version changed from $PREVIOUS to $CURRENT"
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Version unchanged: $CURRENT"
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Check if tag exists
|
||||
id: tag_exists
|
||||
if: steps.check.outputs.changed == 'true'
|
||||
run: |
|
||||
TAG="v${{ steps.current.outputs.version }}"
|
||||
if git ls-remote --tags origin | grep -q "refs/tags/$TAG"; then
|
||||
echo "Tag $TAG already exists"
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Tag $TAG does not exist"
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create and push tag
|
||||
if: steps.check.outputs.changed == 'true' && steps.tag_exists.outputs.exists == 'false'
|
||||
run: |
|
||||
TAG="v${{ steps.current.outputs.version }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a "$TAG" -m "Release $TAG"
|
||||
git push origin "$TAG"
|
||||
echo "Created and pushed tag: $TAG"
|
||||
@@ -1,184 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect changed paths
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
dart: ${{ steps.filter.outputs.dart }}
|
||||
go: ${{ steps.filter.outputs.go }}
|
||||
android: ${{ steps.filter.outputs.android }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Filter paths
|
||||
uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
dart:
|
||||
- 'lib/**'
|
||||
- 'test/**'
|
||||
- 'assets/**'
|
||||
- 'pubspec.yaml'
|
||||
- 'pubspec.lock'
|
||||
- 'analysis_options.yaml'
|
||||
- 'l10n.yaml'
|
||||
- '.fvmrc'
|
||||
- '.github/workflows/ci.yml'
|
||||
go:
|
||||
- 'go_backend/**'
|
||||
- '.github/workflows/ci.yml'
|
||||
android:
|
||||
- 'android/**'
|
||||
- 'go_backend/**'
|
||||
- 'lib/models/settings.dart'
|
||||
- 'lib/providers/download_queue_provider*.dart'
|
||||
- 'lib/services/download_request_payload.dart'
|
||||
- 'lib/services/history_database.dart'
|
||||
- 'lib/services/platform_bridge.dart'
|
||||
- 'pubspec.yaml'
|
||||
- 'pubspec.lock'
|
||||
- '.fvmrc'
|
||||
- '.github/workflows/ci.yml'
|
||||
|
||||
flutter:
|
||||
name: Flutter analyze & test
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.dart == 'true'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version-file: .fvmrc
|
||||
cache: true
|
||||
|
||||
- name: Cache pub dependencies
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}
|
||||
restore-keys: pub-${{ runner.os }}-
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Analyze
|
||||
run: flutter analyze
|
||||
|
||||
- name: Run tests
|
||||
run: flutter test
|
||||
|
||||
go:
|
||||
name: Go vet & test
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: go_backend
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go_backend/go.mod
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
- name: Check formatting
|
||||
run: test -z "$(gofmt -l .)"
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Run tests
|
||||
run: go test ./...
|
||||
|
||||
android:
|
||||
name: Android compile & native tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.android == 'true'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "25"
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go_backend/go.mod
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version-file: .fvmrc
|
||||
cache: true
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
gradle-version: "9.6.1"
|
||||
|
||||
- name: Install Android SDK & NDK
|
||||
run: |
|
||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
|
||||
"ndk;29.0.14206865" \
|
||||
"platforms;android-37.0" \
|
||||
"build-tools;37.0.0"
|
||||
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build Go backend for Android
|
||||
working-directory: go_backend
|
||||
run: |
|
||||
go install golang.org/x/mobile/cmd/gomobile
|
||||
gomobile init
|
||||
mkdir -p ../android/app/libs
|
||||
gomobile bind \
|
||||
-target=android/arm,android/arm64 \
|
||||
-androidapi 24 \
|
||||
-o ../android/app/libs/gobackend.aar \
|
||||
.
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Configure Flutter SDK for Gradle
|
||||
run: echo "flutter.sdk=$FLUTTER_ROOT" > android/local.properties
|
||||
|
||||
- name: Compile Kotlin and run native unit tests
|
||||
run: gradle -p android :app:compileDebugKotlin :app:testDebugUnitTest
|
||||
@@ -0,0 +1,118 @@
|
||||
name: iOS Build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-ios:
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
- name: Install gomobile
|
||||
run: |
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
gomobile init
|
||||
|
||||
- name: Build Go backend for iOS (XCFramework)
|
||||
working-directory: go_backend
|
||||
run: |
|
||||
mkdir -p ../ios/Frameworks
|
||||
gomobile bind -target=ios -o ../ios/Frameworks/Gobackend.xcframework .
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
|
||||
- name: Verify XCFramework created
|
||||
run: |
|
||||
echo "=== Checking XCFramework ==="
|
||||
ls -la ios/Frameworks/
|
||||
ls -la ios/Frameworks/Gobackend.xcframework/ || (echo "ERROR: XCFramework not found!" && exit 1)
|
||||
|
||||
- name: Add XCFramework to Xcode project
|
||||
run: |
|
||||
# Install xcodeproj gem for modifying Xcode project
|
||||
sudo gem install xcodeproj
|
||||
|
||||
# Create Ruby script to add framework
|
||||
cat > add_framework.rb << 'EOF'
|
||||
require 'xcodeproj'
|
||||
|
||||
project_path = 'ios/Runner.xcodeproj'
|
||||
project = Xcodeproj::Project.open(project_path)
|
||||
|
||||
# Get the main target
|
||||
target = project.targets.find { |t| t.name == 'Runner' }
|
||||
|
||||
# Get or create Frameworks group
|
||||
frameworks_group = project.main_group.find_subpath('Frameworks', true)
|
||||
frameworks_group ||= project.main_group.new_group('Frameworks')
|
||||
|
||||
# Add XCFramework reference
|
||||
framework_path = 'Frameworks/Gobackend.xcframework'
|
||||
framework_ref = frameworks_group.new_file(framework_path, :project)
|
||||
|
||||
# Add to frameworks build phase
|
||||
frameworks_build_phase = target.frameworks_build_phase
|
||||
frameworks_build_phase.add_file_reference(framework_ref)
|
||||
|
||||
# Add to embed frameworks build phase
|
||||
embed_phase = target.build_phases.find { |p| p.is_a?(Xcodeproj::Project::Object::PBXCopyFilesBuildPhase) && p.name == 'Embed Frameworks' }
|
||||
if embed_phase
|
||||
build_file = embed_phase.add_file_reference(framework_ref)
|
||||
build_file.settings = { 'ATTRIBUTES' => ['CodeSignOnCopy', 'RemoveHeadersOnCopy'] }
|
||||
end
|
||||
|
||||
project.save
|
||||
puts "Successfully added Gobackend.xcframework to Xcode project"
|
||||
EOF
|
||||
|
||||
ruby add_framework.rb
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Generate app icons
|
||||
run: dart run flutter_launcher_icons
|
||||
|
||||
- name: Build iOS (no codesign)
|
||||
run: flutter build ios --release --no-codesign
|
||||
|
||||
- name: Create IPA (unsigned)
|
||||
run: |
|
||||
mkdir -p build/ios/ipa
|
||||
cd build/ios/iphoneos
|
||||
mkdir Payload
|
||||
cp -r Runner.app Payload/
|
||||
zip -r ../ipa/SpotiFLAC-unsigned.ipa Payload
|
||||
rm -rf Payload
|
||||
|
||||
- name: Upload IPA artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: SpotiFLAC-iOS-unsigned
|
||||
path: build/ios/ipa/SpotiFLAC-unsigned.ipa
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload XCFramework artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Gobackend-XCFramework
|
||||
path: ios/Frameworks/Gobackend.xcframework
|
||||
retention-days: 30
|
||||
@@ -1,44 +0,0 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'site/**'
|
||||
- '.github/workflows/pages.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: site
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -3,13 +3,13 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version tag (e.g., v1.0.0)"
|
||||
description: 'Version tag (e.g., v1.0.0)'
|
||||
required: true
|
||||
default: "v1.0.0"
|
||||
default: 'v1.0.0'
|
||||
|
||||
jobs:
|
||||
# Get version first (quick job)
|
||||
@@ -17,65 +17,40 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
|
||||
steps:
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# Check if version contains -preview, -beta, -rc, or -alpha (NOT -hotfix)
|
||||
VERSION_LOWER=$(echo "$VERSION" | tr '[:upper:]' '[:lower:]')
|
||||
if [[ "$VERSION_LOWER" == *"-preview"* ]] || [[ "$VERSION_LOWER" == *"-beta"* ]] || [[ "$VERSION_LOWER" == *"-rc"* ]] || [[ "$VERSION_LOWER" == *"-alpha"* ]]; then
|
||||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||
echo "Detected pre-release version: $VERSION"
|
||||
else
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
echo "Detected stable version: $VERSION"
|
||||
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Android and iOS build in PARALLEL
|
||||
build-android:
|
||||
runs-on: ubuntu-latest
|
||||
needs: get-version
|
||||
|
||||
|
||||
steps:
|
||||
- name: Free disk space
|
||||
run: |
|
||||
# Remove large unused tools (~15GB total). Docker prune was dropped:
|
||||
# it took 1-2 minutes and the rm below already frees enough.
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo rm -rf /opt/ghc
|
||||
sudo rm -rf /opt/hostedtoolcache/CodeQL
|
||||
sudo rm -rf /usr/local/share/boost
|
||||
sudo rm -rf /usr/share/swift
|
||||
sudo rm -rf /usr/local/.ghcup
|
||||
# Show available space
|
||||
df -h
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "25"
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go_backend/go.mod
|
||||
go-version: '1.21'
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
# Cache Gradle for faster builds
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -83,160 +58,84 @@ jobs:
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-${{ runner.os }}-
|
||||
|
||||
- name: Cache Android NDK
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: /usr/local/lib/android/sdk/ndk/29.0.14206865
|
||||
key: ndk-29.0.14206865
|
||||
|
||||
- name: Install Android SDK & NDK
|
||||
run: |
|
||||
# Use pre-installed Android SDK on GitHub runners
|
||||
echo "ANDROID_HOME=$ANDROID_HOME"
|
||||
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT"
|
||||
|
||||
# Accept licenses
|
||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
||||
|
||||
# Install NDK r29 (supports 16KB page size for Android 15+)
|
||||
# Keep the installed platform aligned with compileSdk/targetSdk.
|
||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-37.0" "build-tools;37.0.0"
|
||||
|
||||
# Set NDK path
|
||||
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install gomobile
|
||||
working-directory: go_backend
|
||||
run: |
|
||||
# Installed from inside the module so the go.mod-pinned x/mobile
|
||||
# version is used (reproducible + covered by the setup-go cache).
|
||||
go install golang.org/x/mobile/cmd/gomobile
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
gomobile init
|
||||
|
||||
- name: Build Go backend for Android
|
||||
working-directory: go_backend
|
||||
run: |
|
||||
mkdir -p ../android/app/libs
|
||||
# arm/arm64 only: ndk.abiFilters in app/build.gradle.kts strips
|
||||
# every other ABI from all outputs (universal APK included), so an
|
||||
# amd64 slice would never reach a shipped APK — it only bloats the
|
||||
# aar and slows this step.
|
||||
gomobile bind -target=android/arm,android/arm64 -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
||||
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version-file: .fvmrc
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
- name: Cache pub dependencies
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}
|
||||
restore-keys: pub-${{ runner.os }}-
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Generate app icons
|
||||
run: dart run flutter_launcher_icons
|
||||
|
||||
- name: Build APK (Release - unsigned)
|
||||
run: |
|
||||
flutter build apk --release --split-per-abi \
|
||||
--target-platform android-arm,android-arm64
|
||||
ls -la build/app/outputs/flutter-apk/
|
||||
test -f build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
|
||||
test -f build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk
|
||||
|
||||
- name: Sign APKs
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
id: sign_arm64
|
||||
with:
|
||||
releaseDirectory: build/app/outputs/flutter-apk
|
||||
signingKeyBase64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||
alias: ${{ secrets.KEY_ALIAS }}
|
||||
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: "36.0.0"
|
||||
- name: Build APK (Release)
|
||||
run: flutter build apk --release --split-per-abi
|
||||
|
||||
- name: Rename APKs
|
||||
run: |
|
||||
VERSION="${{ needs.get-version.outputs.version }}"
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
cd build/app/outputs/flutter-apk
|
||||
|
||||
rename_required_apk() {
|
||||
unsigned="$1"
|
||||
destination="$2"
|
||||
signed="${unsigned%.apk}-signed.apk"
|
||||
if [ -f "$signed" ]; then
|
||||
mv "$signed" "$destination"
|
||||
elif [ -f "$unsigned" ]; then
|
||||
mv "$unsigned" "$destination"
|
||||
else
|
||||
echo "ERROR: Missing APK: $unsigned"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
rename_required_apk \
|
||||
app-arm64-v8a-release.apk \
|
||||
"SpotiFLAC-${VERSION}-arm64.apk"
|
||||
rename_required_apk \
|
||||
app-armeabi-v7a-release.apk \
|
||||
"SpotiFLAC-${VERSION}-arm32.apk"
|
||||
mv app-arm64-v8a-release.apk SpotiFLAC-${VERSION}-arm64.apk || true
|
||||
mv app-armeabi-v7a-release.apk SpotiFLAC-${VERSION}-arm32.apk || true
|
||||
mv app-release.apk SpotiFLAC-${VERSION}-universal.apk || true
|
||||
ls -la
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android-apk
|
||||
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
||||
|
||||
build-ios:
|
||||
runs-on: macos-15
|
||||
needs: get-version # Only depends on version, NOT android build!
|
||||
|
||||
runs-on: macos-latest
|
||||
needs: get-version # Only depends on version, NOT android build!
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Select Xcode 26.1.1
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.1.app
|
||||
xcodebuild -version
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go_backend/go.mod
|
||||
go-version: '1.21'
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
# Cache CocoaPods
|
||||
- name: Cache CocoaPods
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ios/Pods
|
||||
key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
|
||||
restore-keys: pods-${{ runner.os }}-
|
||||
|
||||
- name: Install gomobile
|
||||
working-directory: go_backend
|
||||
run: |
|
||||
# go.mod-pinned x/mobile version (reproducible + cached by setup-go).
|
||||
go install golang.org/x/mobile/cmd/gomobile
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
gomobile init
|
||||
|
||||
- name: Build Go backend for iOS
|
||||
working-directory: go_backend
|
||||
run: |
|
||||
mkdir -p ../ios/Frameworks
|
||||
gomobile bind -target=ios -tags ios -o ../ios/Frameworks/Gobackend.xcframework .
|
||||
gomobile bind -target=ios -o ../ios/Frameworks/Gobackend.xcframework .
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
|
||||
@@ -244,118 +143,74 @@ jobs:
|
||||
run: |
|
||||
ls -la ios/Frameworks/
|
||||
ls -la ios/Frameworks/Gobackend.xcframework/ || (echo "ERROR: XCFramework not found!" && exit 1)
|
||||
|
||||
|
||||
- name: Add XCFramework to Xcode project
|
||||
run: |
|
||||
# xcodeproj usually ships with the preinstalled CocoaPods; only
|
||||
# install it when missing.
|
||||
gem list -i xcodeproj >/dev/null || sudo gem install xcodeproj
|
||||
|
||||
# Install xcodeproj gem for modifying Xcode project
|
||||
sudo gem install xcodeproj
|
||||
|
||||
# Create Ruby script to add framework
|
||||
cat > add_framework.rb << 'EOF'
|
||||
require 'xcodeproj'
|
||||
|
||||
|
||||
project_path = 'ios/Runner.xcodeproj'
|
||||
project = Xcodeproj::Project.open(project_path)
|
||||
|
||||
|
||||
# Get the main target
|
||||
target = project.targets.find { |t| t.name == 'Runner' }
|
||||
|
||||
|
||||
# Get or create Frameworks group
|
||||
frameworks_group = project.main_group.find_subpath('Frameworks', true)
|
||||
frameworks_group ||= project.main_group.new_group('Frameworks')
|
||||
|
||||
|
||||
# Add XCFramework reference
|
||||
framework_path = 'Frameworks/Gobackend.xcframework'
|
||||
framework_ref = frameworks_group.new_file(framework_path, :project)
|
||||
|
||||
|
||||
# Add to frameworks build phase
|
||||
frameworks_build_phase = target.frameworks_build_phase
|
||||
frameworks_build_phase.add_file_reference(framework_ref)
|
||||
|
||||
|
||||
# Add to embed frameworks build phase
|
||||
embed_phase = target.build_phases.find { |p| p.is_a?(Xcodeproj::Project::Object::PBXCopyFilesBuildPhase) && p.name == 'Embed Frameworks' }
|
||||
if embed_phase
|
||||
build_file = embed_phase.add_file_reference(framework_ref)
|
||||
build_file.settings = { 'ATTRIBUTES' => ['CodeSignOnCopy', 'RemoveHeadersOnCopy'] }
|
||||
end
|
||||
|
||||
|
||||
project.save
|
||||
puts "Successfully added Gobackend.xcframework to Xcode project"
|
||||
EOF
|
||||
|
||||
|
||||
ruby add_framework.rb
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version-file: .fvmrc
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
- name: Cache pub dependencies
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}
|
||||
restore-keys: pub-${{ runner.os }}-
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Normalize ffmpeg plugin shell scripts (strip CRLF)
|
||||
run: |
|
||||
find "$HOME/.pub-cache/hosted" -path "*ffmpeg_kit_flutter_new_full*/scripts/*.sh" -type f -print0 |
|
||||
while IFS= read -r -d '' f; do
|
||||
perl -pi -e 's/\r$//' "$f"
|
||||
chmod +x "$f"
|
||||
echo "Normalized line endings: $f"
|
||||
done
|
||||
|
||||
- name: Generate app icons
|
||||
run: dart run flutter_launcher_icons
|
||||
|
||||
- name: Build iOS (unsigned)
|
||||
run: |
|
||||
# Build Flutter iOS without codesigning
|
||||
flutter build ios --release --no-codesign --config-only
|
||||
|
||||
# Use xcodebuild with code signing disabled
|
||||
cd ios
|
||||
xcodebuild -workspace Runner.xcworkspace \
|
||||
-scheme Runner \
|
||||
-configuration Release \
|
||||
-sdk iphoneos \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath build/Runner.xcarchive \
|
||||
archive \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
CODE_SIGNING_REQUIRED=NO \
|
||||
CODE_SIGN_IDENTITY="" \
|
||||
DEVELOPMENT_TEAM=""
|
||||
run: flutter build ios --release --no-codesign
|
||||
|
||||
- name: Create IPA
|
||||
run: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
mkdir -p build/ios/ipa
|
||||
cd ios/build/Runner.xcarchive/Products/Applications
|
||||
cd build/ios/iphoneos
|
||||
mkdir Payload
|
||||
cp -r Runner.app Payload/
|
||||
# Use absolute path to avoid relative path issues
|
||||
zip -r $GITHUB_WORKSPACE/build/ios/ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa Payload
|
||||
zip -r ../ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa Payload
|
||||
rm -rf Payload
|
||||
|
||||
- name: Verify IPA created
|
||||
run: |
|
||||
ls -la build/ios/ipa/
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
if [ ! -f "build/ios/ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa" ]; then
|
||||
echo "ERROR: IPA not created!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload IPA artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: build/ios/ipa/SpotiFLAC-*.ipa
|
||||
@@ -365,36 +220,46 @@ jobs:
|
||||
needs: [get-version, build-android, build-ios]
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for git-cliff
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Generate changelog with git-cliff
|
||||
- name: Extract changelog for version
|
||||
id: changelog
|
||||
uses: orhun/git-cliff-action@v4
|
||||
with:
|
||||
config: cliff.toml
|
||||
args: --latest --strip header
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OUTPUT: /tmp/changelog.txt
|
||||
|
||||
- name: Show generated changelog
|
||||
run: |
|
||||
echo "Generated changelog:"
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
|
||||
|
||||
# Extract changelog section for this version
|
||||
# Look for ## [X.X.X] and capture until next ## [ or end of file
|
||||
CHANGELOG=$(awk -v ver="$VERSION_NUM" '
|
||||
/^## \[/ {
|
||||
if (found) exit
|
||||
if ($0 ~ "\\[" ver "\\]") found=1
|
||||
next
|
||||
}
|
||||
found { print }
|
||||
' CHANGELOG.md)
|
||||
|
||||
# If no changelog found, use default message
|
||||
if [ -z "$CHANGELOG" ]; then
|
||||
CHANGELOG="See CHANGELOG.md for details."
|
||||
fi
|
||||
|
||||
# Save to file for multiline support
|
||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||
echo "Extracted changelog:"
|
||||
cat /tmp/changelog.txt
|
||||
|
||||
- name: Download Android APK
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: android-apk
|
||||
path: ./release
|
||||
|
||||
- name: Download iOS IPA
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: ./release
|
||||
@@ -402,233 +267,44 @@ jobs:
|
||||
- name: Prepare release body
|
||||
run: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
REPO_OWNER="${{ github.repository_owner }}"
|
||||
REPO_NAME="${{ github.event.repository.name }}"
|
||||
CURRENT_REF=$(git rev-list -n 1 "$VERSION" 2>/dev/null || git rev-parse HEAD)
|
||||
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${CURRENT_REF}^" 2>/dev/null || true)
|
||||
|
||||
# Start with git-cliff changelog, but replace its compare footer with a
|
||||
# deterministic previous-tag lookup from git.
|
||||
sed '/^## [0-9][0-9.[:alpha:]-]*$/d; /^\*\*Full Changelog\*\*/d' /tmp/changelog.txt > /tmp/release_body.txt
|
||||
|
||||
if [ -n "$PREVIOUS_TAG" ]; then
|
||||
printf '\n**Full Changelog**: [%s...%s](https://github.com/%s/%s/compare/%s...%s)\n' \
|
||||
"$PREVIOUS_TAG" "$VERSION" "$REPO_OWNER" "$REPO_NAME" "$PREVIOUS_TAG" "$VERSION" \
|
||||
>> /tmp/release_body.txt
|
||||
fi
|
||||
|
||||
# Append download section
|
||||
cat > /tmp/release_body.txt << 'HEADER'
|
||||
## SpotiFLAC $VERSION
|
||||
|
||||
Download Spotify tracks in FLAC quality from Tidal, Qobuz & Amazon Music.
|
||||
|
||||
### What's New
|
||||
HEADER
|
||||
|
||||
# Replace $VERSION in header
|
||||
sed -i "s/\$VERSION/$VERSION/g" /tmp/release_body.txt
|
||||
|
||||
cat /tmp/changelog.txt >> /tmp/release_body.txt
|
||||
|
||||
cat >> /tmp/release_body.txt << FOOTER
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
### Downloads
|
||||
|
||||
#### Android
|
||||
- **arm64**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended for modern devices)
|
||||
- **arm32**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices)
|
||||
|
||||
#### iOS
|
||||
- **Android (arm64)**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended)
|
||||
- **Android (arm32)**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices)
|
||||
- **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required)
|
||||
|
||||
|
||||
### Installation
|
||||
**Android**: Enable "Install from unknown sources" and install the APK
|
||||
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
||||
FOOTER
|
||||
|
||||
|
||||
echo "Release body:"
|
||||
cat /tmp/release_body.txt
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ needs.get-version.outputs.version }}
|
||||
name: SpotiFLAC-Mobile ${{ needs.get-version.outputs.version }}
|
||||
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
||||
body_path: /tmp/release_body.txt
|
||||
files: ./release/*
|
||||
draft: false
|
||||
prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }}
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
update-altstore:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [get-version, build-ios, create-release]
|
||||
if: ${{ needs.get-version.outputs.is_prerelease != 'true' }}
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout main branch
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Download iOS IPA
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: ./release
|
||||
|
||||
- name: Update apps.json
|
||||
run: |
|
||||
VERSION="${{ needs.get-version.outputs.version }}"
|
||||
VERSION_NUM="${VERSION#v}"
|
||||
DATE=$(date -u +%Y-%m-%d)
|
||||
IPA_FILE=$(find ./release -name "*ios*.ipa" | head -1)
|
||||
|
||||
if [ -z "$IPA_FILE" ]; then
|
||||
echo "WARNING: IPA file not found, skipping apps.json update"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IPA_SIZE=$(stat -c%s "$IPA_FILE" 2>/dev/null || stat -f%z "$IPA_FILE")
|
||||
|
||||
if [ ! -f apps.json ]; then
|
||||
echo "WARNING: apps.json not found on main, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
jq --arg ver "$VERSION_NUM" \
|
||||
--arg date "$DATE" \
|
||||
--arg url "https://github.com/${{ github.repository }}/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa" \
|
||||
--argjson size "$IPA_SIZE" \
|
||||
'.apps[0].version = $ver | .apps[0].versionDate = $date | .apps[0].downloadURL = $url | .apps[0].size = $size' \
|
||||
apps.json > apps.json.tmp && mv apps.json.tmp apps.json
|
||||
|
||||
echo "Updated apps.json:"
|
||||
cat apps.json
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
VERSION="${{ needs.get-version.outputs.version }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add apps.json
|
||||
git diff --cached --quiet && echo "No changes to commit" || \
|
||||
(git commit -m "chore: update AltStore source to ${VERSION}" && git push)
|
||||
|
||||
notify-telegram:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [get-version, create-release]
|
||||
if: ${{ needs.get-version.outputs.is_prerelease != 'true' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download Android APK
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: android-apk
|
||||
path: ./release
|
||||
|
||||
- name: Download iOS IPA
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: ./release
|
||||
|
||||
- name: Generate changelog with git-cliff for Telegram
|
||||
uses: orhun/git-cliff-action@v4
|
||||
with:
|
||||
config: cliff.toml
|
||||
args: --latest --strip all
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OUTPUT: /tmp/cliff_tg.txt
|
||||
|
||||
- name: Convert changelog for Telegram
|
||||
id: changelog
|
||||
run: |
|
||||
if [ ! -s /tmp/cliff_tg.txt ]; then
|
||||
echo "See release notes on GitHub for details." > /tmp/changelog.txt
|
||||
else
|
||||
# Convert Markdown to Telegram HTML
|
||||
CHANGELOG=$(cat /tmp/cliff_tg.txt | \
|
||||
sed '/^## [0-9][0-9.[:alpha:]-]*$/d' | \
|
||||
sed '/^\*\*Full Changelog\*\*/d' | \
|
||||
sed 's/ by \[@[^]]*\](https:\/\/github\.com\/[^)]*)//g' | \
|
||||
sed 's/ by @[A-Za-z0-9_-]\+//g' | \
|
||||
sed 's/\[#\([0-9]*\)\]([^)]*)/#\1/g' | \
|
||||
sed 's/\[@\([^]]*\)\]([^)]*)/@\1/g' | \
|
||||
sed 's/&/\&/g' | \
|
||||
sed 's/</\</g' | \
|
||||
sed 's/>/\>/g' | \
|
||||
sed 's/\*\*\([^*]*\)\*\*/<b>\1<\/b>/g' | \
|
||||
sed 's/^### \(.*\)$/<b>\1<\/b>/g' | \
|
||||
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
|
||||
sed 's/^- /• /g')
|
||||
|
||||
# Truncate for Telegram 4096 char limit
|
||||
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
|
||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||
fi
|
||||
|
||||
echo "Telegram changelog:"
|
||||
cat /tmp/changelog.txt
|
||||
|
||||
- name: Send to Telegram Channel
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHANNEL_ID: ${{ secrets.TELEGRAM_CHANNEL_ID }}
|
||||
run: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
CHANGELOG=$(cat /tmp/changelog.txt)
|
||||
|
||||
# Find APK files
|
||||
ARM64_APK=$(find ./release -name "*arm64*.apk" | head -1)
|
||||
ARM32_APK=$(find ./release -name "*arm32*.apk" | head -1)
|
||||
|
||||
# Prepare message with changelog (HTML format)
|
||||
printf '%s\n' \
|
||||
"<b>SpotiFLAC Mobile ${VERSION} Released!</b>" \
|
||||
"" \
|
||||
"<b>What's New:</b>" \
|
||||
"${CHANGELOG}" \
|
||||
"" \
|
||||
"<a href=\"https://github.com/${{ github.repository }}/releases/tag/${VERSION}\">View Release Notes</a>" \
|
||||
> /tmp/telegram_message.txt
|
||||
|
||||
MESSAGE=$(cat /tmp/telegram_message.txt)
|
||||
|
||||
# Send message first (using HTML parse mode)
|
||||
# Use --data-urlencode for proper encoding of special chars (+, &, etc.)
|
||||
# Use || true to ensure file uploads continue even if message fails
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
--data-urlencode "chat_id=${TELEGRAM_CHANNEL_ID}" \
|
||||
--data-urlencode "text=${MESSAGE}" \
|
||||
--data-urlencode "parse_mode=HTML" \
|
||||
--data-urlencode "disable_web_page_preview=true" || true
|
||||
|
||||
# Upload arm64 APK to channel
|
||||
if [ -f "$ARM64_APK" ]; then
|
||||
echo "Uploading arm64 APK to Telegram..."
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${ARM64_APK}" \
|
||||
-F caption="SpotiFLAC Mobile ${VERSION} - arm64 (recommended)"
|
||||
fi
|
||||
|
||||
# Upload arm32 APK to channel
|
||||
if [ -f "$ARM32_APK" ]; then
|
||||
echo "Uploading arm32 APK to Telegram..."
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${ARM32_APK}" \
|
||||
-F caption="SpotiFLAC Mobile ${VERSION} - arm32"
|
||||
fi
|
||||
|
||||
# Upload iOS IPA to channel
|
||||
IOS_IPA=$(find ./release -name "*ios*.ipa" | head -1)
|
||||
if [ -f "$IOS_IPA" ]; then
|
||||
echo "Uploading iOS IPA to Telegram..."
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${IOS_IPA}" \
|
||||
-F caption="SpotiFLAC Mobile ${VERSION} - iOS (unsigned, sideload required)"
|
||||
fi
|
||||
|
||||
echo "Telegram notification sent!"
|
||||
|
||||
@@ -1,98 +1,14 @@
|
||||
# Operating-system files
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Editors and local assistants
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
.cursorignore
|
||||
.cursorrules
|
||||
.kiro/
|
||||
.playwright-mcp/
|
||||
.rtk/
|
||||
.claude/settings.local.json
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
|
||||
# Flutter and Dart
|
||||
.dart_tool/
|
||||
.fvm/
|
||||
.packages
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.metadata
|
||||
/build/
|
||||
/coverage/
|
||||
*.apk
|
||||
*.aab
|
||||
*.ipa
|
||||
# Kiro specs (optional - remove if you want to track specs)
|
||||
# .kiro/
|
||||
|
||||
# Local JavaScript tooling (the app itself has no Node dependency)
|
||||
/node_modules/
|
||||
/bun.lock
|
||||
/package-lock.json
|
||||
|
||||
# Go backend build outputs
|
||||
/go_backend/*.aar
|
||||
/go_backend/*.jar
|
||||
/go_backend/*.exe
|
||||
/go_backend/*.xcframework/
|
||||
|
||||
# Android build state and signing material
|
||||
/android/.gradle/
|
||||
/android/.kotlin/
|
||||
/android/app/build/
|
||||
/android/app/libs/*.aar
|
||||
/android/app/libs/*-sources.jar
|
||||
/android/local.properties
|
||||
/android/*.iml
|
||||
/android/key.properties
|
||||
/android/*.jks
|
||||
/android/*.keystore
|
||||
/android/app/*.jks
|
||||
|
||||
# iOS generated state
|
||||
/ios/Frameworks/
|
||||
/ios/Pods/
|
||||
/ios/.symlinks/
|
||||
/ios/Flutter/ephemeral/
|
||||
/ios/Flutter/Flutter.framework/
|
||||
/ios/Flutter/Flutter.podspec
|
||||
*.xcarchive
|
||||
DerivedData/
|
||||
|
||||
# Credentials and machine-local configuration
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.mobileprovision
|
||||
*.p12
|
||||
*.pem
|
||||
|
||||
# Local research and design material
|
||||
/design/
|
||||
/referensi/
|
||||
/extension/
|
||||
/tool/
|
||||
/spotiflac_android/
|
||||
|
||||
# Documentation published separately. Keep the in-repo extension contract
|
||||
# available because contribution templates link to it.
|
||||
/docs/*
|
||||
!/docs/EXTENSION_DEVELOPMENT.md
|
||||
|
||||
# Temporary files and logs
|
||||
/.tmp/
|
||||
nul
|
||||
NUL
|
||||
/network_requests.txt
|
||||
/AndroidManifest.xml
|
||||
*.bak
|
||||
*.orig
|
||||
*.swp
|
||||
*.tmp
|
||||
*.log
|
||||
hs_err_*.log
|
||||
flutter_*.log
|
||||
# Reference folder (if you don't want to include it)
|
||||
# referensi/
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||
identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the overall
|
||||
community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||
any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email address,
|
||||
without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official email address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
**[zarzet](https://github.com/zarzet)**.
|
||||
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of
|
||||
actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or permanent
|
||||
ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||
community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||
[https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
@@ -1,167 +0,0 @@
|
||||
# Contributing to SpotiFLAC Mobile
|
||||
|
||||
Thank you for helping improve SpotiFLAC Mobile. Bug reports, focused pull
|
||||
requests, documentation, and translations are all welcome.
|
||||
|
||||
Please follow the [Code of Conduct](CODE_OF_CONDUCT.md) when participating in
|
||||
the project.
|
||||
|
||||
## Before You Start
|
||||
|
||||
- Search the [existing issues](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues)
|
||||
before opening a new one.
|
||||
- Use the issue template that best matches the problem.
|
||||
- Keep pull requests focused. Separate unrelated fixes into separate PRs.
|
||||
- Never commit credentials, signing files, downloaded media, or generated build
|
||||
artifacts.
|
||||
|
||||
Translations are managed through the
|
||||
[SpotiFLAC Mobile Crowdin project](https://crowdin.com/project/spotiflac-mobile).
|
||||
The English source strings live in `lib/l10n/arb/app_en.arb`.
|
||||
|
||||
## Toolchain
|
||||
|
||||
The repository is the source of truth for tool versions:
|
||||
|
||||
- Flutter: `.fvmrc`
|
||||
- Dart: bundled with the pinned Flutter SDK
|
||||
- Go: `go_backend/go.mod`
|
||||
- Android SDK, NDK, and Java: `.github/workflows/ci.yml`
|
||||
- Xcode: required only for iOS builds
|
||||
|
||||
[FVM](https://fvm.app/) is recommended. If you do not use FVM, install the
|
||||
exact Flutter version declared in `.fvmrc` and replace `fvm flutter` with
|
||||
`flutter` (and `fvm dart` with `dart`) in the commands below.
|
||||
|
||||
## Development Setup
|
||||
|
||||
1. Fork and clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/SpotiFLAC-Mobile.git
|
||||
cd SpotiFLAC-Mobile
|
||||
git remote add upstream https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
|
||||
```
|
||||
|
||||
2. Install the pinned Flutter SDK and Dart dependencies:
|
||||
|
||||
```bash
|
||||
fvm install
|
||||
fvm flutter pub get
|
||||
```
|
||||
|
||||
3. Build the Go backend for Android. `ANDROID_NDK_HOME` must point to the NDK
|
||||
version used by CI and `CGO_ENABLED` must be enabled.
|
||||
|
||||
```bash
|
||||
cd go_backend
|
||||
go mod download
|
||||
go install golang.org/x/mobile/cmd/gomobile
|
||||
gomobile init
|
||||
mkdir -p ../android/app/libs
|
||||
gomobile bind \
|
||||
-target=android/arm,android/arm64 \
|
||||
-androidapi 24 \
|
||||
-o ../android/app/libs/gobackend.aar \
|
||||
.
|
||||
cd ..
|
||||
```
|
||||
|
||||
Running `go install` from `go_backend/` uses the `x/mobile` version pinned by
|
||||
`go.mod`. Do not replace it with `@latest` in project scripts.
|
||||
|
||||
4. Run the app:
|
||||
|
||||
```bash
|
||||
fvm flutter run
|
||||
```
|
||||
|
||||
For iOS, run `scripts/build_ios.sh` on macOS before opening
|
||||
`ios/Runner.xcworkspace`.
|
||||
|
||||
## Project Boundaries
|
||||
|
||||
```text
|
||||
lib/ Flutter UI, state, models, and platform orchestration
|
||||
go_backend/ Download pipeline, extension runtime, and shared backend logic
|
||||
android/ Android platform bridge and foreground worker
|
||||
ios/ iOS platform bridge and application project
|
||||
test/ Flutter unit and widget tests
|
||||
assets/ Images, fonts, and bundled resources
|
||||
docs/ Contributor-facing technical contracts
|
||||
scripts/ Reproducible project build helpers
|
||||
```
|
||||
|
||||
SpotiFLAC Mobile is extension-driven. Extension-specific behavior must be
|
||||
declared through a generic manifest field, capability, or reusable app API.
|
||||
Do not add provider-name checks such as `if source == 'provider-name'` to the
|
||||
main app. The Go backend should parse and expose the generic declaration, and
|
||||
Dart should consume that declaration without knowing which extension uses it.
|
||||
|
||||
## Generated Files
|
||||
|
||||
- After changing ARB files, run `fvm flutter gen-l10n` and commit the resulting
|
||||
localization sources.
|
||||
- Run `fvm dart run build_runner build --delete-conflicting-outputs` only when a
|
||||
model or generator input changes, then commit the relevant generated source.
|
||||
- Do not commit `build/`, `.dart_tool/`, AAR/XCFramework output, IDE state, or
|
||||
local research directories.
|
||||
|
||||
## Validation
|
||||
|
||||
Run checks that cover the code you changed. Before opening a PR, the relevant
|
||||
commands should pass.
|
||||
|
||||
Flutter and Dart:
|
||||
|
||||
```bash
|
||||
fvm dart format --output=none --set-exit-if-changed lib test
|
||||
fvm flutter analyze
|
||||
fvm flutter test
|
||||
```
|
||||
|
||||
Go backend:
|
||||
|
||||
```bash
|
||||
cd go_backend
|
||||
gofmt -w .
|
||||
go vet ./...
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Android native code, after building `gobackend.aar`:
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew :app:compileDebugKotlin :app:testDebugUnitTest
|
||||
```
|
||||
|
||||
For user-facing changes, add or update tests where practical and include
|
||||
before/after screenshots for UI changes.
|
||||
|
||||
## Code and Commit Style
|
||||
|
||||
- Follow `analysis_options.yaml`, `.editorconfig`, and existing module patterns.
|
||||
- Keep user-facing strings in the localization files.
|
||||
- Prefer small functions and explicit error handling at platform boundaries.
|
||||
- Use [Conventional Commits](https://www.conventionalcommits.org/), for example:
|
||||
|
||||
```text
|
||||
feat(download): add batch selection
|
||||
fix(storage): handle revoked folder access
|
||||
docs(contributing): refresh Android setup
|
||||
```
|
||||
|
||||
## Pull Requests
|
||||
|
||||
1. Create a branch from an up-to-date `main`.
|
||||
2. Make one focused change and include tests or verification evidence.
|
||||
3. Complete the pull request template, including any checks that were not run
|
||||
and why.
|
||||
4. Link related issues with `Fixes #123` where appropriate.
|
||||
5. Respond to review feedback with follow-up commits; maintainers may squash
|
||||
commits when merging.
|
||||
|
||||
When reporting a crash, include the SpotiFLAC Mobile version, release channel,
|
||||
device/OS, exact reproduction steps, storage mode, and exported app logs. For a
|
||||
cold-start Android crash, `adb logcat -b crash -d` is especially useful.
|
||||
@@ -1,203 +1,42 @@
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/readme/banner-readme-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="assets/readme/banner-readme-light.png">
|
||||
<img alt="SpotiFLAC Mobile" src="assets/readme/banner-readme-light.png" width="650" height="auto">
|
||||
</picture>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/25971" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/25971" alt="spotiflacapp%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/spotiflacapp/SpotiFLAC-Mobile/releases)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
<img src="icon.png" width="128" />
|
||||
|
||||
[](https://t.me/spotiflac)
|
||||
[](https://t.me/spotiflac_chat)
|
||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/readme/1.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/2.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/3.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/4.jpg?v=2" width="200" />
|
||||
<img src="assets/images/Screenshot_20260101-210622_SpotiFLAC.png" width="200" />
|
||||
<img src="assets/images/Screenshot_20260101-210626_SpotiFLAC.png" width="200" />
|
||||
<img src="assets/images/photo_2026-01-01_23-56-11.jpg" width="200" />
|
||||
<img src="assets/images/Screenshot_20260101-210653_SpotiFLAC.png" width="200" />
|
||||
<img src="assets/images/photo_2026-01-01_23-44-06.jpg" width="200" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Extensions
|
||||
|
||||
Extensions let the community add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
|
||||
|
||||
### Installing Extensions
|
||||
|
||||
1. Open the **Store** tab in the app
|
||||
2. On first launch, enter an **Extension Repository URL** when prompted
|
||||
3. Browse and install extensions with one tap
|
||||
4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
||||
5. Configure extension settings if needed
|
||||
6. Set provider priority under **Settings > Extensions > Provider Priority**
|
||||
|
||||
### Developing Extensions
|
||||
|
||||
> [!NOTE]
|
||||
> Want to build your own extension? Start with the
|
||||
> [Extension Development Guide](docs/EXTENSION_DEVELOPMENT.md). The
|
||||
> [documentation site](https://spotiflac.zarz.moe/docs) contains the expanded
|
||||
> API reference.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
SpotiFLAC Mobile combines a Flutter/Dart UI, a Go backend compiled with
|
||||
`gomobile`, and thin Android/iOS platform bridges. Toolchain versions are
|
||||
pinned in [`.fvmrc`](.fvmrc), [`go_backend/go.mod`](go_backend/go.mod), and the
|
||||
GitHub Actions workflows.
|
||||
|
||||
Start with the [Contributing Guide](CONTRIBUTING.md) for the development setup,
|
||||
project boundaries, validation commands, and pull request checklist.
|
||||
|
||||
---
|
||||
|
||||
## Related Projects
|
||||
## Other project
|
||||
|
||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||
Download music in true lossless FLAC from extension-provided sources on Windows, macOS & Linux.
|
||||
|
||||
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
||||
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
<details>
|
||||
<summary><b>Why does the Store tab ask me to enter a URL?</b></summary>
|
||||
<br>
|
||||
|
||||
Starting from version 3.8.0, SpotiFLAC Mobile uses a decentralized extension
|
||||
repository system. Extensions are hosted independently rather than on a
|
||||
built-in server, so anyone can create and host a compatible repository. Enter
|
||||
a repository URL in the Store tab to browse and install extensions.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Why is my download failing with "Song not found"?</b></summary>
|
||||
<br>
|
||||
|
||||
The track may not be available from your enabled providers. Try enabling more providers under **Settings > Extensions > Provider Priority**, or install additional download extensions from the Store.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
|
||||
<br>
|
||||
|
||||
Quality depends on what's available from the source and the installed download extension. Check each extension's quality options and service notes in the app.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Can I download playlists?</b></summary>
|
||||
<br>
|
||||
|
||||
Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Why do I need to grant storage permission?</b></summary>
|
||||
<br>
|
||||
|
||||
The app needs a writable destination for downloaded files. On Android, choose a
|
||||
folder with the system folder picker (SAF), or use the app-specific folder. If
|
||||
Android revokes a saved folder grant, select the folder again in Settings.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Is this app safe?</b></summary>
|
||||
<br>
|
||||
|
||||
SpotiFLAC Mobile is open source, so its code and build workflows can be
|
||||
inspected directly in this repository.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Why is downloading not working in my country?</b></summary>
|
||||
<br>
|
||||
|
||||
Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Can I add SpotiFLAC Mobile to AltStore or SideStore?</b></summary>
|
||||
<br>
|
||||
|
||||
Yes! Add the official source to receive updates directly within the app. Copy this link:
|
||||
|
||||
```
|
||||
https://raw.githubusercontent.com/spotiflacapp/SpotiFLAC-Mobile/refs/heads/main/apps.json
|
||||
```
|
||||
|
||||
In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link.
|
||||
|
||||
</details>
|
||||
|
||||
> [!NOTE]
|
||||
> If SpotiFLAC Mobile is useful to you, consider supporting development:
|
||||
>
|
||||
> [](https://ko-fi.com/zarzet)
|
||||
|
||||
---
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks to everyone who has contributed to SpotiFLAC Mobile!
|
||||
|
||||
<a href="https://github.com/spotiflacapp/SpotiFLAC-Mobile/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=spotiflacapp/SpotiFLAC-Mobile" />
|
||||
</a>
|
||||
|
||||
We also appreciate everyone who helped with [translations on Crowdin](https://crowdin.com/project/spotiflac-mobile), reported bugs, suggested features, and spread the word.
|
||||
|
||||
Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md) to get started!
|
||||
|
||||
---
|
||||
|
||||
## API Credits
|
||||
|
||||
| | | | | |
|
||||
|---|---|---|---|---|
|
||||
| [MusicDL](https://www.musicdl.me) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) | [Song.link](https://song.link) |
|
||||
| [IDHS](https://github.com/sjdonado/idonthavespotify) | | | | |
|
||||
|
||||
---
|
||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This repository and its contents are provided strictly for educational and research purposes. The software is provided "as-is" without warranty of any kind, express or implied, as stated in the [MIT License](LICENSE).
|
||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
||||
|
||||
- No copyrighted content is hosted, stored, mirrored, or distributed by this repository.
|
||||
- Users must ensure that their use of this software is properly authorized and complies with all applicable laws, regulations, and third-party terms of service.
|
||||
- This software is provided free of charge by the maintainer. If you paid a third party for access to this software in its original form from this repository, you may have been misled or scammed. Any redistribution or commercial use by third parties must comply with the terms of the repository license. No affiliation, endorsement, or support by the maintainer is implied unless explicitly stated in writing.
|
||||
- SpotiFLAC Mobile is an independent project. It is not affiliated with, endorsed by, or connected to any other project or version on other platforms that may share a similar name. The maintainer of this repository has no control over or responsibility for third-party projects.
|
||||
- The author(s) disclaim all liability for any direct, indirect, incidental, or consequential damages arising from the use or misuse of this software. Users assume all risk associated with its use.
|
||||
- If you are a copyright holder or authorized representative and believe this repository infringes upon your rights, please contact the maintainer with sufficient detail (including relevant URLs and proof of ownership). The matter will be promptly investigated and appropriate action will be taken, which may include removal of the referenced material.
|
||||
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
|
||||
|
||||
> [!TIP]
|
||||
> **Star the repo** to get notified about all new releases directly from GitHub.
|
||||
You are solely responsible for:
|
||||
1. Ensuring your use of this software complies with your local laws.
|
||||
2. Reading and adhering to the Terms of Service of the respective platforms.
|
||||
3. Any legal consequences resulting from the misuse of this tool.
|
||||
|
||||
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
|
||||
|
||||
@@ -9,19 +9,6 @@
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
plugins:
|
||||
riverpod_lint: 3.1.4-dev.3
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- .dart_tool/**
|
||||
- lib/**/*.g.dart
|
||||
- lib/l10n/*.dart
|
||||
language:
|
||||
strict-casts: true
|
||||
strict-inference: true
|
||||
strict-raw-types: true
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
@@ -36,16 +23,6 @@ linter:
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
always_declare_return_types: true
|
||||
avoid_dynamic_calls: true
|
||||
avoid_types_as_parameter_names: true
|
||||
strict_top_level_inference: true
|
||||
type_annotate_public_apis: true
|
||||
cancel_subscriptions: true
|
||||
close_sinks: true
|
||||
# Catches dead cross-layer chains (Dart wrapper kept alive only by its own
|
||||
# declaration) before they accumulate into another dedup campaign.
|
||||
unreachable_from_main: true
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
}
|
||||
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||
localProperties.load(reader)
|
||||
}
|
||||
}
|
||||
|
||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||
if (flutterVersionCode == null) {
|
||||
flutterVersionCode = '1'
|
||||
}
|
||||
|
||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||
if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "com.zarz.spotiflac"
|
||||
compileSdk flutter.compileSdkVersion
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.zarz.spotiflac"
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
targetSdk flutter.targetSdkVersion
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.debug
|
||||
minifyEnabled false
|
||||
shrinkResources false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Go backend library (gomobile generated)
|
||||
implementation fileTree(dir: 'libs', include: ['*.aar'])
|
||||
|
||||
// Kotlin coroutines for async Go backend calls
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
import java.util.Properties
|
||||
import java.io.FileInputStream
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
@@ -8,90 +5,48 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
// Load keystore properties for local builds
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
val keystoreProperties = Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.zarz.spotiflac"
|
||||
compileSdk = 37
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_25
|
||||
targetCompatibility = JavaVersion.VERSION_25
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
create("release") {
|
||||
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||
storeFile = file(keystoreProperties.getProperty("storeFile"))
|
||||
storePassword = keystoreProperties.getProperty("storePassword")
|
||||
}
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.zarz.spotiflac"
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = 37
|
||||
targetSdk = 34
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
multiDexEnabled = true
|
||||
|
||||
// Only include arm64-v8a for smaller APK (most modern devices)
|
||||
// Remove this line if you need to support older 32-bit devices
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug"
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
}
|
||||
|
||||
getByName("profile") {
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
}
|
||||
|
||||
release {
|
||||
// For local builds: use release signing if key.properties exists
|
||||
// For CI builds: APK is signed by GitHub Action after build
|
||||
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
// Enable code shrinking and resource shrinking
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,19 +72,8 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||
|
||||
// Include all AAR and JAR files from libs folder
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0")
|
||||
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||
implementation("androidx.activity:activity-ktx:1.13.0")
|
||||
|
||||
// NativeDownloadFinalizer imports FFmpegKit APIs directly. The Flutter
|
||||
// plugin owns the runtime AAR; compileOnly avoids packaging it twice here.
|
||||
compileOnly("com.antonkarpenko:ffmpeg-kit-full:2.2.1")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||
implementation(files("libs/gobackend.aar"))
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||
}
|
||||
|
||||
@@ -5,112 +5,29 @@
|
||||
-keep class io.flutter.view.** { *; }
|
||||
-keep class io.flutter.** { *; }
|
||||
-keep class io.flutter.plugins.** { *; }
|
||||
-keep class io.flutter.embedding.** { *; }
|
||||
|
||||
# Ignore missing Play Core classes (not used, but referenced by Flutter)
|
||||
-dontwarn com.google.android.play.core.splitcompat.**
|
||||
-dontwarn com.google.android.play.core.splitinstall.**
|
||||
-dontwarn com.google.android.play.core.tasks.**
|
||||
|
||||
# Ignore missing javax.xml.stream (not used on Android)
|
||||
-dontwarn javax.xml.stream.**
|
||||
|
||||
# Go backend (gobackend.aar) - CRITICAL for release builds
|
||||
# Go backend (gobackend.aar)
|
||||
-keep class gobackend.** { *; }
|
||||
-keep class go.** { *; }
|
||||
-keep interface gobackend.** { *; }
|
||||
-keepclassmembers class gobackend.** { *; }
|
||||
|
||||
# Go mobile binding internals
|
||||
-keep class org.golang.** { *; }
|
||||
-dontwarn org.golang.**
|
||||
|
||||
# FFmpeg Kit
|
||||
-keep class com.arthenica.ffmpegkit.** { *; }
|
||||
-keep class com.arthenica.smartexception.** { *; }
|
||||
# FFmpeg Kit (new fork package)
|
||||
-keep class com.antonkarpenko.ffmpegkit.** { *; }
|
||||
-keep class com.antonkarpenko.smartexception.** { *; }
|
||||
|
||||
# Apache Tika (if used by FFmpeg)
|
||||
-dontwarn org.apache.tika.**
|
||||
|
||||
# Keep native methods
|
||||
-keepclasseswithmembernames class * {
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
# Kotlin coroutines - expanded rules
|
||||
# Kotlin coroutines
|
||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||
-keepclassmembers class kotlinx.coroutines.** {
|
||||
volatile <fields>;
|
||||
}
|
||||
-keepclassmembernames class kotlinx.** {
|
||||
volatile <fields>;
|
||||
}
|
||||
-dontwarn kotlinx.coroutines.**
|
||||
|
||||
# Kotlin serialization
|
||||
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
|
||||
-dontwarn kotlin.**
|
||||
-keep class kotlin.** { *; }
|
||||
-keep class kotlin.Metadata { *; }
|
||||
|
||||
# Keep MainActivity and related classes
|
||||
-keep class com.zarz.spotiflac.** { *; }
|
||||
|
||||
# Prevent R8 from removing metadata
|
||||
-keepattributes *Annotation*
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
-keepattributes Signature
|
||||
-keepattributes Exceptions
|
||||
-keepattributes InnerClasses
|
||||
-keepattributes EnclosingMethod
|
||||
|
||||
# JSON parsing (used by Go backend responses)
|
||||
-keep class org.json.** { *; }
|
||||
|
||||
# Shared Preferences
|
||||
-keep class androidx.datastore.** { *; }
|
||||
-dontwarn androidx.datastore.**
|
||||
|
||||
# Flutter Plugins - CRITICAL: Prevent R8 from removing plugin implementations
|
||||
# Path Provider
|
||||
-keep class io.flutter.plugins.pathprovider.** { *; }
|
||||
-keep class dev.flutter.pigeon.** { *; }
|
||||
|
||||
# Local Notifications
|
||||
-keep class com.dexterous.** { *; }
|
||||
-keep class com.dexterous.flutterlocalnotifications.** { *; }
|
||||
|
||||
# Receive Sharing Intent
|
||||
-keep class com.kasem.receive_sharing_intent.** { *; }
|
||||
|
||||
# Permission Handler
|
||||
-keep class com.baseflow.permissionhandler.** { *; }
|
||||
|
||||
# File Picker
|
||||
-keep class com.mr.flutter.plugin.filepicker.** { *; }
|
||||
|
||||
# URL Launcher
|
||||
-keep class io.flutter.plugins.urllauncher.** { *; }
|
||||
|
||||
# Share Plus
|
||||
-keep class dev.fluttercommunity.plus.share.** { *; }
|
||||
|
||||
# Device Info Plus
|
||||
-keep class dev.fluttercommunity.plus.device_info.** { *; }
|
||||
|
||||
# Open File
|
||||
-keep class com.crazecoder.openfile.** { *; }
|
||||
|
||||
# Sqflite
|
||||
-keep class com.tekartik.sqflite.** { *; }
|
||||
|
||||
# Dynamic Color
|
||||
-keep class io.material.** { *; }
|
||||
|
||||
# Keep all Flutter plugin registrants
|
||||
-keep class io.flutter.plugins.GeneratedPluginRegistrant { *; }
|
||||
-keep class ** extends io.flutter.embedding.engine.plugins.FlutterPlugin { *; }
|
||||
|
||||
@@ -3,37 +3,27 @@
|
||||
|
||||
<!-- Permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29" />
|
||||
android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<!-- For Android 11+ (API 30-32) - full storage access -->
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application
|
||||
android:label="SpotiFLAC Mobile"
|
||||
android:label="SpotiFLAC"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="@xml/backup_rules_legacy"
|
||||
android:dataExtractionRules="@xml/backup_rules"
|
||||
android:usesCleartextTraffic="false"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:localeConfig="@xml/locale_config">
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
@@ -49,7 +39,7 @@
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
|
||||
<!-- Handle music URL sharing (Spotify, Deezer, Tidal, YT Music) -->
|
||||
<!-- Handle Spotify URL sharing -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
@@ -63,53 +53,6 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="open.spotify.com" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Handle Deezer deep links -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="www.deezer.com" />
|
||||
<data android:scheme="https" android:host="deezer.com" />
|
||||
<data android:scheme="https" android:host="deezer.page.link" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Handle Tidal deep links -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="tidal.com" />
|
||||
<data android:scheme="https" android:host="listen.tidal.com" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Handle YouTube Music deep links -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="music.youtube.com" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Extension OAuth (PKCE) redirect: spotiflac://callback?code=...&state=<extension_id> -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="spotiflac" android:host="callback" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="spotiflac" android:host="spotify-callback" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="spotiflac" android:host="session-grant" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Download Service -->
|
||||
@@ -118,34 +61,6 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<receiver
|
||||
android:name=".DownloadQueueWidgetProvider"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_download_queue_info" />
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:name="com.ryanheise.audioservice.AudioService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="true"
|
||||
android:enabled="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<receiver
|
||||
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- flutter_local_notifications receivers -->
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
||||
@@ -161,21 +76,6 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
|
||||
<!-- FileProvider for APK installation -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileProvider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
<queries>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.example.temp_project
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -1,131 +0,0 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.view.View
|
||||
import android.widget.RemoteViews
|
||||
|
||||
/**
|
||||
* Home-screen widget showing the active download. Updated only on discrete
|
||||
* events (track transition, status change, coarse progress steps) from
|
||||
* DownloadService — never per progress byte — to respect battery discipline.
|
||||
* The last pushed state is persisted so launcher-driven refreshes (reboot,
|
||||
* resize) render without the service running.
|
||||
*/
|
||||
class DownloadQueueWidgetProvider : AppWidgetProvider() {
|
||||
|
||||
override fun onEnabled(context: Context) {
|
||||
widgetPreferences(context).edit().putBoolean(KEY_ENABLED, true).apply()
|
||||
}
|
||||
|
||||
override fun onDisabled(context: Context) {
|
||||
widgetPreferences(context).edit().putBoolean(KEY_ENABLED, false).apply()
|
||||
}
|
||||
|
||||
override fun onUpdate(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetIds: IntArray,
|
||||
) {
|
||||
if (appWidgetIds.isNotEmpty()) {
|
||||
widgetPreferences(context).edit().putBoolean(KEY_ENABLED, true).apply()
|
||||
}
|
||||
render(context, appWidgetManager, appWidgetIds)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFS = "download_widget_state"
|
||||
private const val KEY_ENABLED = "enabled"
|
||||
private const val KEY_RUNNING = "running"
|
||||
private const val KEY_TITLE = "title"
|
||||
private const val KEY_SUBTITLE = "subtitle"
|
||||
private const val KEY_PERCENT = "percent" // -1 = indeterminate
|
||||
|
||||
/** Persists the state and re-renders all widget instances. */
|
||||
fun push(
|
||||
context: Context,
|
||||
running: Boolean,
|
||||
title: String = "",
|
||||
subtitle: String = "",
|
||||
percent: Int = -1,
|
||||
) {
|
||||
val prefs = widgetPreferences(context)
|
||||
// Most users never add the optional launcher widget. Keep the
|
||||
// download hot path free of AppWidget IPC and preference writes
|
||||
// for those users.
|
||||
if (!prefs.getBoolean(KEY_ENABLED, false)) return
|
||||
|
||||
val manager = AppWidgetManager.getInstance(context)
|
||||
val ids = manager.getAppWidgetIds(
|
||||
ComponentName(context, DownloadQueueWidgetProvider::class.java)
|
||||
)
|
||||
if (ids.isEmpty()) {
|
||||
prefs.edit().putBoolean(KEY_ENABLED, false).apply()
|
||||
return
|
||||
}
|
||||
prefs.edit()
|
||||
.putBoolean(KEY_RUNNING, running)
|
||||
.putString(KEY_TITLE, title)
|
||||
.putString(KEY_SUBTITLE, subtitle)
|
||||
.putInt(KEY_PERCENT, percent)
|
||||
.apply()
|
||||
render(context, manager, ids)
|
||||
}
|
||||
|
||||
private fun widgetPreferences(context: Context) =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
private fun render(
|
||||
context: Context,
|
||||
manager: AppWidgetManager,
|
||||
ids: IntArray,
|
||||
) {
|
||||
val prefs = widgetPreferences(context)
|
||||
val running = prefs.getBoolean(KEY_RUNNING, false)
|
||||
val views = RemoteViews(context.packageName, R.layout.widget_download_queue)
|
||||
|
||||
if (running) {
|
||||
views.setTextViewText(
|
||||
R.id.widget_title,
|
||||
prefs.getString(KEY_TITLE, "").orEmpty().ifEmpty { "Downloading..." }
|
||||
)
|
||||
views.setTextViewText(
|
||||
R.id.widget_subtitle,
|
||||
prefs.getString(KEY_SUBTITLE, "").orEmpty()
|
||||
)
|
||||
views.setViewVisibility(R.id.widget_progress, View.VISIBLE)
|
||||
val percent = prefs.getInt(KEY_PERCENT, -1)
|
||||
if (percent in 0..100) {
|
||||
views.setProgressBar(R.id.widget_progress, 100, percent, false)
|
||||
} else {
|
||||
views.setProgressBar(R.id.widget_progress, 100, 0, true)
|
||||
}
|
||||
} else {
|
||||
views.setTextViewText(R.id.widget_title, "SpotiFLAC")
|
||||
views.setTextViewText(R.id.widget_subtitle, "No active downloads")
|
||||
views.setViewVisibility(R.id.widget_progress, View.GONE)
|
||||
}
|
||||
|
||||
val launchIntent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
views.setOnClickPendingIntent(
|
||||
R.id.widget_root,
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
launchIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
)
|
||||
|
||||
for (id in ids) {
|
||||
manager.updateAppWidget(id, views)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.util.AtomicFile
|
||||
import androidx.core.app.NotificationCompat
|
||||
import gobackend.Gobackend
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
// Wi-Fi-only download policy: network callback, pause and resume.
|
||||
|
||||
internal fun DownloadService.configureNativeWorkerNetworkPolicy(settingsJson: String) {
|
||||
nativeWorkerDownloadNetworkMode = try {
|
||||
JSONObject(settingsJson).optString("download_network_mode", "any")
|
||||
} catch (_: Exception) {
|
||||
"any"
|
||||
}
|
||||
|
||||
unregisterNativeWorkerNetworkCallback()
|
||||
if (!NativeWorkerPolicy.requiresWifi(nativeWorkerDownloadNetworkMode)) {
|
||||
nativeWorkerNetworkPaused = false
|
||||
return
|
||||
}
|
||||
|
||||
nativeWorkerNetworkPaused = !hasUsableWifiConnection()
|
||||
val connectivityManager =
|
||||
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
synchronized(nativeWorkerWifiNetworks) {
|
||||
nativeWorkerWifiNetworks.add(network)
|
||||
}
|
||||
refreshNativeWorkerNetworkPause()
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
synchronized(nativeWorkerWifiNetworks) {
|
||||
nativeWorkerWifiNetworks.remove(network)
|
||||
}
|
||||
refreshNativeWorkerNetworkPause()
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
networkCapabilities: NetworkCapabilities,
|
||||
) {
|
||||
synchronized(nativeWorkerWifiNetworks) {
|
||||
if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
|
||||
networkCapabilities.hasCapability(
|
||||
NetworkCapabilities.NET_CAPABILITY_INTERNET,
|
||||
)
|
||||
) {
|
||||
nativeWorkerWifiNetworks.add(network)
|
||||
} else {
|
||||
nativeWorkerWifiNetworks.remove(network)
|
||||
}
|
||||
}
|
||||
refreshNativeWorkerNetworkPause()
|
||||
}
|
||||
}
|
||||
try {
|
||||
val request = NetworkRequest.Builder()
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
connectivityManager.registerNetworkCallback(request, callback)
|
||||
nativeWorkerNetworkCallback = callback
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
"DownloadService",
|
||||
"Failed to monitor Wi-Fi for native worker: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun DownloadService.hasUsableWifiConnection(): Boolean {
|
||||
val connectivityManager =
|
||||
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
if (synchronized(nativeWorkerWifiNetworks) {
|
||||
nativeWorkerWifiNetworks.isNotEmpty()
|
||||
}
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return try {
|
||||
val activeNetwork = connectivityManager.activeNetwork ?: return false
|
||||
val capabilities =
|
||||
connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
|
||||
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
internal fun DownloadService.refreshNativeWorkerNetworkPause() {
|
||||
if (!NativeWorkerPolicy.requiresWifi(nativeWorkerDownloadNetworkMode)) return
|
||||
|
||||
val shouldPause = NativeWorkerPolicy.shouldPauseForNetwork(
|
||||
nativeWorkerDownloadNetworkMode,
|
||||
hasUsableWifiConnection(),
|
||||
)
|
||||
if (shouldPause == nativeWorkerNetworkPaused) return
|
||||
|
||||
nativeWorkerNetworkPaused = shouldPause
|
||||
if (nativeWorkerJob?.isActive != true) return
|
||||
|
||||
if (shouldPause) {
|
||||
currentStatus = if (nativeWorkerVerificationPaused) {
|
||||
"verification_required"
|
||||
} else {
|
||||
"waiting_wifi"
|
||||
}
|
||||
cancelActiveNativeItemForPause()
|
||||
updateNotification(0L, 0L)
|
||||
} else {
|
||||
currentStatus = if (nativeWorkerVerificationPaused) {
|
||||
"verification_required"
|
||||
} else {
|
||||
"preparing"
|
||||
}
|
||||
updateNotification(0L, 0L)
|
||||
}
|
||||
writeNativeWorkerSnapshotAsync(
|
||||
isRunning = nativeWorkerJob?.isActive == true,
|
||||
isPaused = isNativeWorkerPaused(),
|
||||
currentItemId = "",
|
||||
message = if (isNativeWorkerPaused()) {
|
||||
nativeWorkerPauseMessage()
|
||||
} else {
|
||||
"Wi-Fi restored"
|
||||
},
|
||||
includeItems = true,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun DownloadService.unregisterNativeWorkerNetworkCallback() {
|
||||
val callback = nativeWorkerNetworkCallback
|
||||
nativeWorkerNetworkCallback = null
|
||||
synchronized(nativeWorkerWifiNetworks) {
|
||||
nativeWorkerWifiNetworks.clear()
|
||||
}
|
||||
if (callback == null) return
|
||||
try {
|
||||
val connectivityManager =
|
||||
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
connectivityManager.unregisterNetworkCallback(callback)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.util.AtomicFile
|
||||
import androidx.core.app.NotificationCompat
|
||||
import gobackend.Gobackend
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
// Album ReplayGain accumulation and journal persistence for the native worker.
|
||||
|
||||
internal fun DownloadService.writeNativeAlbumReplayGainIfComplete(): Boolean {
|
||||
val entries = synchronized(nativeReplayGainEntries) {
|
||||
nativeReplayGainEntries.map { JSONObject(it.toString()) }
|
||||
}
|
||||
if (entries.size <= 1) return true
|
||||
|
||||
val statuses = synchronized(nativeWorkerItems) {
|
||||
nativeWorkerItems.associate { it.itemId to it.status }
|
||||
}
|
||||
val requestKeys = synchronized(nativeReplayGainRequestAlbumKeys) {
|
||||
nativeReplayGainRequestAlbumKeys.toMap()
|
||||
}
|
||||
val eligible = buildEligibleNativeAlbumReplayGain(entries, statuses, requestKeys)
|
||||
if (eligible.length() <= 1) {
|
||||
return !hasPendingNativeAlbumReplayGainWork(statuses)
|
||||
}
|
||||
return writeNativeAlbumReplayGainEntries(eligible)
|
||||
}
|
||||
|
||||
internal fun DownloadService.buildEligibleNativeAlbumReplayGain(
|
||||
entries: List<JSONObject>,
|
||||
statuses: Map<String, String>,
|
||||
requestKeys: Map<String, String>
|
||||
): JSONArray {
|
||||
val eligibleIndexes = NativeReplayGainPolicy.eligibleEntryIndexes(
|
||||
entryAlbumKeys = entries.map { it.optString("album_key", "") },
|
||||
statuses = statuses,
|
||||
requestAlbumKeys = requestKeys,
|
||||
)
|
||||
val eligible = JSONArray()
|
||||
for (index in eligibleIndexes) {
|
||||
eligible.put(entries[index])
|
||||
}
|
||||
return eligible
|
||||
}
|
||||
|
||||
internal fun DownloadService.writeNativeAlbumReplayGainEntries(eligible: JSONArray): Boolean {
|
||||
if (eligible.length() <= 1) return true
|
||||
try {
|
||||
val result = JSONObject(NativeDownloadFinalizer.writeAlbumReplayGain(this, eligible.toString()))
|
||||
return result.optBoolean("success", false)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("DownloadService", "Native album ReplayGain failed: ${e.message}")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
internal fun DownloadService.hasPendingNativeAlbumReplayGainWork(statuses: Map<String, String>): Boolean {
|
||||
return NativeReplayGainPolicy.hasPendingWork(statuses)
|
||||
}
|
||||
|
||||
internal fun DownloadService.writeNativeReplayGainJournal() {
|
||||
val requestKeys = synchronized(nativeReplayGainRequestAlbumKeys) {
|
||||
nativeReplayGainRequestAlbumKeys.toMap()
|
||||
}
|
||||
if (requestKeys.isEmpty()) return
|
||||
|
||||
val entries = synchronized(nativeReplayGainEntries) {
|
||||
nativeReplayGainEntries.map { JSONObject(it.toString()) }
|
||||
}
|
||||
val statuses = synchronized(nativeWorkerItems) {
|
||||
nativeWorkerItems.associate { it.itemId to it.status }
|
||||
}
|
||||
synchronized(DownloadService.NATIVE_REPLAYGAIN_JOURNAL_FILE_LOCK) {
|
||||
val file = AtomicFile(File(filesDir, DownloadService.NATIVE_REPLAYGAIN_JOURNAL_FILE))
|
||||
val existing = readNativeReplayGainJournalLocked(file)
|
||||
val mergedEntries = mergeNativeReplayGainJournalEntries(
|
||||
existing?.optJSONArray("entries"),
|
||||
entries,
|
||||
)
|
||||
val mergedRequestKeys = mergeJsonObjectStringMap(
|
||||
existing?.optJSONObject("request_album_keys"),
|
||||
requestKeys,
|
||||
)
|
||||
val mergedStatuses = mergeJsonObjectStringMap(
|
||||
existing?.optJSONObject("statuses"),
|
||||
statuses,
|
||||
)
|
||||
val root = JSONObject()
|
||||
.put("run_id", nativeWorkerRunId)
|
||||
.put("updated_at", System.currentTimeMillis())
|
||||
.put("entries", mergedEntries)
|
||||
.put("request_album_keys", JSONObject(mergedRequestKeys))
|
||||
.put("statuses", JSONObject(mergedStatuses))
|
||||
|
||||
var stream: java.io.FileOutputStream? = null
|
||||
try {
|
||||
stream = file.startWrite()
|
||||
stream.write(root.toString().toByteArray(Charsets.UTF_8))
|
||||
file.finishWrite(stream)
|
||||
stream = null
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("DownloadService", "Failed to write native ReplayGain journal: ${e.message}")
|
||||
} finally {
|
||||
if (stream != null) {
|
||||
file.failWrite(stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun DownloadService.readNativeReplayGainJournalLocked(file: AtomicFile): JSONObject? {
|
||||
return try {
|
||||
if (!file.baseFile.exists()) return null
|
||||
val text = file.openRead().bufferedReader(Charsets.UTF_8).use {
|
||||
it.readText()
|
||||
}
|
||||
JSONObject(text)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("DownloadService", "Failed to merge native ReplayGain journal: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun DownloadService.mergeNativeReplayGainJournalEntries(
|
||||
existingEntries: JSONArray?,
|
||||
currentEntries: List<JSONObject>
|
||||
): JSONArray {
|
||||
val byKey = linkedMapOf<String, JSONObject>()
|
||||
|
||||
fun add(entry: JSONObject) {
|
||||
val trackId = entry.optString("track_id", "")
|
||||
val path = entry.optString("file_path", "")
|
||||
val key = if (trackId.isNotBlank()) {
|
||||
"track:$trackId"
|
||||
} else {
|
||||
"path:$path"
|
||||
}
|
||||
if (key != "path:") {
|
||||
byKey[key] = JSONObject(entry.toString())
|
||||
}
|
||||
}
|
||||
|
||||
if (existingEntries != null) {
|
||||
for (index in 0 until existingEntries.length()) {
|
||||
existingEntries.optJSONObject(index)?.let(::add)
|
||||
}
|
||||
}
|
||||
for (entry in currentEntries) add(entry)
|
||||
|
||||
return JSONArray().apply {
|
||||
for (entry in byKey.values) put(entry)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun DownloadService.mergeJsonObjectStringMap(
|
||||
existing: JSONObject?,
|
||||
current: Map<String, String>
|
||||
): Map<String, String> {
|
||||
val merged = linkedMapOf<String, String>()
|
||||
if (existing != null) {
|
||||
for (key in existing.keys()) {
|
||||
merged[key] = existing.optString(key, "")
|
||||
}
|
||||
}
|
||||
for ((key, value) in current) {
|
||||
merged[key] = value
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
internal fun DownloadService.clearNativeReplayGainJournal() {
|
||||
synchronized(DownloadService.NATIVE_REPLAYGAIN_JOURNAL_FILE_LOCK) {
|
||||
try {
|
||||
AtomicFile(File(filesDir, DownloadService.NATIVE_REPLAYGAIN_JOURNAL_FILE)).delete()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun DownloadService.flushNativeAlbumReplayGainJournalIfComplete() {
|
||||
val root = synchronized(DownloadService.NATIVE_REPLAYGAIN_JOURNAL_FILE_LOCK) {
|
||||
try {
|
||||
val file = File(filesDir, DownloadService.NATIVE_REPLAYGAIN_JOURNAL_FILE)
|
||||
if (!file.exists()) return
|
||||
val text = AtomicFile(file).openRead().bufferedReader(Charsets.UTF_8).use {
|
||||
it.readText()
|
||||
}
|
||||
JSONObject(text)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("DownloadService", "Failed to read native ReplayGain journal: ${e.message}")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val entriesArray = root.optJSONArray("entries") ?: return
|
||||
val entries = mutableListOf<JSONObject>()
|
||||
for (index in 0 until entriesArray.length()) {
|
||||
entriesArray.optJSONObject(index)?.let { entries.add(JSONObject(it.toString())) }
|
||||
}
|
||||
val statusesJson = root.optJSONObject("statuses") ?: JSONObject()
|
||||
val statuses = mutableMapOf<String, String>()
|
||||
for (key in statusesJson.keys()) {
|
||||
statuses[key] = statusesJson.optString(key, "")
|
||||
}
|
||||
val requestKeysJson = root.optJSONObject("request_album_keys") ?: JSONObject()
|
||||
val requestKeys = mutableMapOf<String, String>()
|
||||
for (key in requestKeysJson.keys()) {
|
||||
requestKeys[key] = requestKeysJson.optString(key, "")
|
||||
}
|
||||
|
||||
val eligible = buildEligibleNativeAlbumReplayGain(entries, statuses, requestKeys)
|
||||
if (eligible.length() <= 1 && hasPendingNativeAlbumReplayGainWork(statuses)) {
|
||||
return
|
||||
}
|
||||
if (writeNativeAlbumReplayGainEntries(eligible)) {
|
||||
clearNativeReplayGainJournal()
|
||||
}
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.util.AtomicFile
|
||||
import androidx.core.app.NotificationCompat
|
||||
import gobackend.Gobackend
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
// Native-worker item state snapshots for the Flutter side.
|
||||
|
||||
internal fun DownloadService.writeNativeWorkerSnapshot(
|
||||
isRunning: Boolean,
|
||||
isPaused: Boolean,
|
||||
currentItemId: String,
|
||||
message: String,
|
||||
lastResult: JSONObject? = null,
|
||||
settingsJson: String = "",
|
||||
includeItems: Boolean = false,
|
||||
snapshotSerial: Long = snapshotWriteSerial.incrementAndGet()
|
||||
) {
|
||||
try {
|
||||
synchronized(snapshotWriteLock) {
|
||||
if (includeItems) {
|
||||
if (snapshotSerial < latestCommittedStateSnapshotSerial) return
|
||||
} else {
|
||||
if (snapshotSerial < latestCommittedProgressSnapshotSerial) return
|
||||
}
|
||||
|
||||
val counts = nativeWorkerCounts()
|
||||
val snapshot = JSONObject()
|
||||
.put("contract_version", DownloadService.NATIVE_WORKER_CONTRACT_VERSION)
|
||||
.put("run_id", nativeWorkerRunId.ifBlank { readNativeWorkerRunIdFromSnapshotFile() })
|
||||
.put("is_running", isRunning)
|
||||
.put("is_paused", isPaused)
|
||||
.put("total", counts.total)
|
||||
.put("completed", counts.completed)
|
||||
.put("failed", counts.failed)
|
||||
.put("skipped", counts.skipped)
|
||||
.put("current_item_id", currentItemId)
|
||||
.put("message", message)
|
||||
.put("updated_at", System.currentTimeMillis())
|
||||
.put("snapshot_serial", snapshotSerial)
|
||||
.put("state_serial", if (includeItems) snapshotSerial else latestCommittedStateSnapshotSerial)
|
||||
.put("snapshot_mode", if (includeItems) "compact_items" else "delta")
|
||||
// Snapshot of the header before the per-item payload is
|
||||
// attached; served to pollers that already consumed this
|
||||
// items payload (see getNativeWorkerSnapshot).
|
||||
val headerCandidate = if (includeItems) snapshot.toString() else null
|
||||
snapshot.put("item_ids", nativeWorkerItemIds())
|
||||
if (includeItems) {
|
||||
snapshot.put("items", nativeWorkerItemsSnapshot(includeStatic = false))
|
||||
} else {
|
||||
nativeWorkerItemSnapshot(currentItemId, includeStatic = false)?.let {
|
||||
snapshot.put("item_delta", it)
|
||||
}
|
||||
}
|
||||
if (settingsJson.isNotBlank() && includeItems) {
|
||||
snapshot.put("settings_json", settingsJson)
|
||||
}
|
||||
if (lastResult != null) {
|
||||
snapshot.put("last_result", lastResult)
|
||||
}
|
||||
|
||||
synchronized(DownloadService.NATIVE_WORKER_STATE_FILE_LOCK) {
|
||||
val targetFileName = if (includeItems) {
|
||||
DownloadService.NATIVE_WORKER_STATE_FILE
|
||||
} else {
|
||||
DownloadService.NATIVE_WORKER_PROGRESS_FILE
|
||||
}
|
||||
val file = AtomicFile(File(filesDir, targetFileName))
|
||||
var stream: java.io.FileOutputStream? = null
|
||||
try {
|
||||
stream = file.startWrite()
|
||||
stream.write(snapshot.toString().toByteArray(Charsets.UTF_8))
|
||||
file.finishWrite(stream)
|
||||
stream = null
|
||||
if (includeItems) {
|
||||
latestCommittedStateSnapshotSerial = snapshotSerial
|
||||
if (headerCandidate != null) {
|
||||
DownloadService.lastStateHeaderJson = headerCandidate
|
||||
DownloadService.lastStateHeaderSerial = snapshotSerial
|
||||
}
|
||||
} else {
|
||||
latestCommittedProgressSnapshotSerial = snapshotSerial
|
||||
}
|
||||
} finally {
|
||||
if (stream != null) {
|
||||
file.failWrite(stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("DownloadService", "Failed to write native worker snapshot: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun DownloadService.writeNativeWorkerSnapshotAsync(
|
||||
isRunning: Boolean,
|
||||
isPaused: Boolean,
|
||||
currentItemId: String,
|
||||
message: String,
|
||||
lastResult: JSONObject? = null,
|
||||
settingsJson: String = "",
|
||||
includeItems: Boolean = false
|
||||
) {
|
||||
val snapshotSerial = snapshotWriteSerial.incrementAndGet()
|
||||
serviceScope.launch {
|
||||
writeNativeWorkerSnapshot(
|
||||
isRunning = isRunning,
|
||||
isPaused = isPaused,
|
||||
currentItemId = currentItemId,
|
||||
message = message,
|
||||
lastResult = lastResult,
|
||||
settingsJson = settingsJson,
|
||||
includeItems = includeItems,
|
||||
snapshotSerial = snapshotSerial
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun DownloadService.readNativeWorkerRunIdFromSnapshotFile(): String {
|
||||
return try {
|
||||
synchronized(DownloadService.NATIVE_WORKER_STATE_FILE_LOCK) {
|
||||
val file = File(filesDir, DownloadService.NATIVE_WORKER_STATE_FILE)
|
||||
if (!file.exists()) {
|
||||
""
|
||||
} else {
|
||||
val text = AtomicFile(file).openRead().bufferedReader(Charsets.UTF_8).use {
|
||||
it.readText()
|
||||
}
|
||||
JSONObject(text).optString("run_id", "")
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
internal fun DownloadService.updateNativeWorkerItem(itemId: String, updater: (DownloadService.NativeWorkerItem) -> Unit) {
|
||||
synchronized(nativeWorkerItems) {
|
||||
nativeWorkerItems.firstOrNull { it.itemId == itemId }?.let(updater)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun DownloadService.updateNativeWorkerItemProgress(itemId: String) {
|
||||
try {
|
||||
val raw = Gobackend.getAllDownloadProgress()
|
||||
val root = JSONObject(raw)
|
||||
val items = root.optJSONObject("items") ?: return
|
||||
val progress = items.optJSONObject(itemId) ?: return
|
||||
val backendStatus = progress.optString("status", "downloading")
|
||||
val bytesReceived = progress.optLong("bytes_received", 0L)
|
||||
val bytesTotal = progress.optLong("bytes_total", 0L)
|
||||
if (backendStatus == "preparing") {
|
||||
currentStatus = "preparing"
|
||||
updateNativeWorkerItem(itemId) {
|
||||
it.status = "preparing"
|
||||
it.progress = 0.0
|
||||
it.bytesReceived = 0L
|
||||
it.bytesTotal = 0L
|
||||
}
|
||||
lastProgress = 0L
|
||||
lastTotal = 0L
|
||||
updateNotification(0L, 0L)
|
||||
return
|
||||
}
|
||||
val progressValue = if (bytesTotal > 0L) {
|
||||
bytesReceived.toDouble() / bytesTotal.toDouble()
|
||||
} else {
|
||||
progress.optDouble("progress", 0.0)
|
||||
}.coerceIn(0.0, 1.0)
|
||||
currentStatus = if (backendStatus == "finalizing") {
|
||||
"finalizing"
|
||||
} else {
|
||||
"downloading"
|
||||
}
|
||||
updateNativeWorkerItem(itemId) {
|
||||
it.status = currentStatus
|
||||
it.progress = progressValue
|
||||
it.bytesReceived = bytesReceived
|
||||
it.bytesTotal = bytesTotal
|
||||
}
|
||||
if (bytesTotal > 0L) {
|
||||
lastProgress = bytesReceived
|
||||
lastTotal = bytesTotal
|
||||
updateNotification(bytesReceived, bytesTotal)
|
||||
} else if (progressValue > 0.0) {
|
||||
val percentProgress = (progressValue * DownloadService.NOTIFICATION_PERCENT_TOTAL).toLong()
|
||||
.coerceIn(0L, DownloadService.NOTIFICATION_PERCENT_TOTAL)
|
||||
lastProgress = percentProgress
|
||||
lastTotal = DownloadService.NOTIFICATION_PERCENT_TOTAL
|
||||
updateNotification(percentProgress, DownloadService.NOTIFICATION_PERCENT_TOTAL)
|
||||
} else {
|
||||
lastProgress = 0L
|
||||
lastTotal = 0L
|
||||
updateNotification(0L, 0L)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun DownloadService.nativeWorkerCounts(): DownloadService.NativeWorkerCounts {
|
||||
var total = 0
|
||||
var completed = 0
|
||||
var failed = 0
|
||||
var skipped = 0
|
||||
synchronized(nativeWorkerItems) {
|
||||
total = nativeWorkerItems.size
|
||||
for (item in nativeWorkerItems) {
|
||||
when (item.status) {
|
||||
"completed" -> completed++
|
||||
"failed" -> failed++
|
||||
"skipped" -> skipped++
|
||||
}
|
||||
}
|
||||
}
|
||||
return DownloadService.NativeWorkerCounts(
|
||||
total = total,
|
||||
completed = completed,
|
||||
failed = failed,
|
||||
skipped = skipped
|
||||
)
|
||||
}
|
||||
|
||||
internal fun DownloadService.nativeWorkerItemSnapshot(itemId: String, includeStatic: Boolean): JSONObject? {
|
||||
if (itemId.isBlank()) return null
|
||||
synchronized(nativeWorkerItems) {
|
||||
val item = nativeWorkerItems.firstOrNull { it.itemId == itemId } ?: return null
|
||||
return nativeWorkerItemSnapshotLocked(item, includeStatic)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun DownloadService.nativeWorkerItemIds(): JSONArray {
|
||||
val array = JSONArray()
|
||||
synchronized(nativeWorkerItems) {
|
||||
for (item in nativeWorkerItems) {
|
||||
array.put(item.itemId)
|
||||
}
|
||||
}
|
||||
return array
|
||||
}
|
||||
|
||||
internal fun DownloadService.nativeWorkerItemsSnapshot(includeStatic: Boolean): JSONArray {
|
||||
val array = JSONArray()
|
||||
synchronized(nativeWorkerItems) {
|
||||
for (item in nativeWorkerItems) {
|
||||
array.put(nativeWorkerItemSnapshotLocked(item, includeStatic))
|
||||
}
|
||||
}
|
||||
return array
|
||||
}
|
||||
|
||||
internal fun DownloadService.nativeWorkerItemSnapshotLocked(item: DownloadService.NativeWorkerItem, includeStatic: Boolean): JSONObject {
|
||||
val json = JSONObject()
|
||||
.put("item_id", item.itemId)
|
||||
.put("status", item.status)
|
||||
.put("progress", item.progress)
|
||||
.put("bytes_received", item.bytesReceived)
|
||||
.put("bytes_total", item.bytesTotal)
|
||||
if (includeStatic) {
|
||||
json.put("track_name", item.trackName)
|
||||
.put("artist_name", item.artistName)
|
||||
.put("item_json", item.itemJson)
|
||||
}
|
||||
if (item.error.isNotBlank()) {
|
||||
json.put("error", item.error)
|
||||
}
|
||||
item.resultJson?.let { json.put("result", it) }
|
||||
return json
|
||||
}
|
||||
|
||||
@@ -1,471 +0,0 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.embedding.android.FlutterActivityLaunchConfigs.BackgroundMode
|
||||
import io.flutter.embedding.android.FlutterFragment
|
||||
import io.flutter.embedding.android.RenderMode
|
||||
import io.flutter.embedding.android.TransparencyMode
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.FlutterShellArgs
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import com.ryanheise.audioservice.AudioServicePlugin
|
||||
import gobackend.Gobackend
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.json.JSONTokener
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.security.MessageDigest
|
||||
import java.util.Locale
|
||||
|
||||
// SAF/MediaStore URI IO helpers: temp copies, writes, sidecars, and the
|
||||
// SAF post-processing pipeline.
|
||||
|
||||
internal fun MainActivity.errorJson(message: String): String {
|
||||
val obj = JSONObject()
|
||||
obj.put("success", false)
|
||||
obj.put("error", message)
|
||||
obj.put("message", message)
|
||||
return obj.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether a content URI belongs to the MediaStore provider.
|
||||
* Samsung One UI may return MediaStore URIs from SAF tree traversal,
|
||||
* which require READ_MEDIA_AUDIO / READ_EXTERNAL_STORAGE permission
|
||||
* instead of SAF tree permission.
|
||||
*/
|
||||
internal fun MainActivity.isMediaStoreUri(uri: Uri): Boolean {
|
||||
val authority = uri.authority ?: return false
|
||||
return authority == "media" ||
|
||||
authority.startsWith("media.") ||
|
||||
authority.contains("media")
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve extension from a MediaStore URI by querying DISPLAY_NAME or MIME_TYPE.
|
||||
*/
|
||||
internal fun MainActivity.resolveMediaStoreExt(uri: Uri, fallbackExt: String?): String {
|
||||
try {
|
||||
contentResolver.query(uri, arrayOf(android.provider.MediaStore.MediaColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val name = cursor.getString(0)?.lowercase(Locale.ROOT) ?: ""
|
||||
val ext = extFromFileName(name)
|
||||
if (ext.isNotBlank()) return ext
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
|
||||
try {
|
||||
val mime = contentResolver.getType(uri)
|
||||
val ext = extFromMimeType(mime)
|
||||
if (ext.isNotBlank()) return ext
|
||||
} catch (_: Exception) {}
|
||||
|
||||
return fallbackExt ?: ""
|
||||
}
|
||||
|
||||
internal fun MainActivity.extFromFileName(name: String): String {
|
||||
return when {
|
||||
name.endsWith(".m4a") -> ".m4a"
|
||||
name.endsWith(".mp4") -> ".mp4"
|
||||
name.endsWith(".aac") -> ".aac"
|
||||
name.endsWith(".mp3") -> ".mp3"
|
||||
name.endsWith(".opus") -> ".opus"
|
||||
name.endsWith(".flac") -> ".flac"
|
||||
name.endsWith(".ogg") -> ".ogg"
|
||||
name.endsWith(".wave") -> ".wav"
|
||||
name.endsWith(".wav") -> ".wav"
|
||||
name.endsWith(".aiff") -> ".aiff"
|
||||
name.endsWith(".aifc") -> ".aifc"
|
||||
name.endsWith(".aif") -> ".aif"
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
internal fun MainActivity.extFromMimeType(mime: String?): String {
|
||||
return when (mime) {
|
||||
"audio/mp4" -> ".m4a"
|
||||
"audio/aac" -> ".aac"
|
||||
"audio/eac3" -> ".m4a"
|
||||
"audio/ac3" -> ".m4a"
|
||||
"audio/ac4" -> ".m4a"
|
||||
"audio/mpeg" -> ".mp3"
|
||||
"audio/ogg" -> ".opus"
|
||||
"audio/flac" -> ".flac"
|
||||
"audio/wav", "audio/x-wav", "audio/wave", "audio/vnd.wave" -> ".wav"
|
||||
"audio/aiff", "audio/x-aiff" -> ".aiff"
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
internal fun MainActivity.copyUriToTemp(uri: Uri, fallbackExt: String? = null): String? {
|
||||
var tempFile: File? = null
|
||||
var success = false
|
||||
|
||||
try {
|
||||
val mime = try { contentResolver.getType(uri) } catch (_: Exception) { null }
|
||||
val nameHint = (
|
||||
try { DocumentFile.fromSingleUri(this, uri)?.name } catch (_: Exception) { null }
|
||||
?: uri.lastPathSegment
|
||||
?: ""
|
||||
).lowercase(Locale.ROOT)
|
||||
val extFromName = extFromFileName(nameHint)
|
||||
val extFromMime = extFromMimeType(mime)
|
||||
val ext = if (extFromName.isNotBlank()) extFromName else if (extFromMime.isNotBlank()) extFromMime else (fallbackExt ?: "")
|
||||
val suffix: String? = if (ext.isNotBlank()) ext else null
|
||||
tempFile = File.createTempFile("saf_", suffix, cacheDir)
|
||||
|
||||
contentResolver.openInputStream(uri)?.use { input ->
|
||||
FileOutputStream(tempFile).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} ?: return null
|
||||
|
||||
success = true
|
||||
return tempFile.absolutePath
|
||||
} catch (e: SecurityException) {
|
||||
// SAF permission denied - try MediaStore fallback for Samsung One UI
|
||||
// which may return MediaStore URIs from SAF tree traversal
|
||||
if (isMediaStoreUri(uri)) {
|
||||
android.util.Log.d(
|
||||
"SpotiFLAC",
|
||||
"SAF denied for MediaStore URI, trying MediaStore fallback: $uri",
|
||||
)
|
||||
val result = copyMediaStoreUriToTemp(uri, fallbackExt)
|
||||
if (result != null) {
|
||||
success = true
|
||||
return result
|
||||
}
|
||||
}
|
||||
android.util.Log.w(
|
||||
"SpotiFLAC",
|
||||
"SAF read denied for $uri: ${e.message}",
|
||||
)
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
"SpotiFLAC",
|
||||
"Failed copying SAF uri $uri to temp: ${e.message}",
|
||||
)
|
||||
return null
|
||||
} finally {
|
||||
if (!success) {
|
||||
try {
|
||||
tempFile?.delete()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback for Samsung One UI: read a MediaStore content URI using
|
||||
* READ_MEDIA_AUDIO / READ_EXTERNAL_STORAGE permission instead of SAF.
|
||||
* This handles the case where SAF tree traversal returns MediaStore URIs
|
||||
* that the SAF document provider cannot access.
|
||||
*/
|
||||
internal fun MainActivity.copyMediaStoreUriToTemp(uri: Uri, fallbackExt: String?): String? {
|
||||
var tempFile: File? = null
|
||||
try {
|
||||
val ext = resolveMediaStoreExt(uri, fallbackExt)
|
||||
val suffix: String? = if (ext.isNotBlank()) ext else null
|
||||
tempFile = File.createTempFile("ms_", suffix, cacheDir)
|
||||
|
||||
contentResolver.openInputStream(uri)?.use { input ->
|
||||
FileOutputStream(tempFile).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} ?: run {
|
||||
tempFile.delete()
|
||||
return null
|
||||
}
|
||||
|
||||
android.util.Log.d(
|
||||
"SpotiFLAC",
|
||||
"MediaStore fallback succeeded for $uri",
|
||||
)
|
||||
return tempFile.absolutePath
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
"SpotiFLAC",
|
||||
"MediaStore fallback also failed for $uri: ${e.message}",
|
||||
)
|
||||
try { tempFile?.delete() } catch (_: Exception) {}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun MainActivity.buildUriDisplayName(
|
||||
uri: Uri,
|
||||
displayNameHint: String? = null,
|
||||
fallbackExt: String? = null,
|
||||
): String {
|
||||
val explicitName = displayNameHint?.trim().orEmpty()
|
||||
if (explicitName.isNotEmpty()) return explicitName
|
||||
|
||||
val docName = try { DocumentFile.fromSingleUri(this, uri)?.name } catch (_: Exception) { null }
|
||||
val uriName = uri.lastPathSegment
|
||||
val resolvedName = (docName ?: uriName ?: "").trim()
|
||||
if (resolvedName.isNotEmpty()) return resolvedName
|
||||
|
||||
val ext = when {
|
||||
fallbackExt.isNullOrBlank().not() -> fallbackExt
|
||||
isMediaStoreUri(uri) -> resolveMediaStoreExt(uri, fallbackExt)
|
||||
else -> ""
|
||||
}
|
||||
return if (ext.isNullOrBlank()) "audio" else "audio$ext"
|
||||
}
|
||||
|
||||
internal fun MainActivity.buildLibraryCoverCacheKey(stablePath: String, lastModified: Long): String {
|
||||
val normalizedPath = stablePath.trim()
|
||||
if (normalizedPath.isEmpty()) return ""
|
||||
return if (lastModified > 0L) "$normalizedPath|$lastModified" else normalizedPath
|
||||
}
|
||||
|
||||
internal fun MainActivity.readAudioMetadataFromUri(
|
||||
uri: Uri,
|
||||
displayNameHint: String? = null,
|
||||
fallbackExt: String? = null,
|
||||
coverCacheKey: String = "",
|
||||
): JSONObject? {
|
||||
val displayName = buildUriDisplayName(uri, displayNameHint, fallbackExt)
|
||||
|
||||
// Skip /proc/self/fd/ attempt when known to fail (e.g. Samsung SELinux).
|
||||
if (procSelfFdReadable != false) {
|
||||
try {
|
||||
contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
|
||||
val directPath = "/proc/self/fd/${pfd.fd}"
|
||||
val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON(
|
||||
directPath,
|
||||
displayName,
|
||||
coverCacheKey,
|
||||
)
|
||||
if (metadataJson.isNotBlank()) {
|
||||
val obj = JSONObject(metadataJson)
|
||||
val filenameFallback = obj.optBoolean("metadataFromFilename", false)
|
||||
if (!obj.has("error") && !filenameFallback) {
|
||||
procSelfFdReadable = true
|
||||
return obj
|
||||
}
|
||||
// Go could not read real metadata from the fd path –
|
||||
// remember so we skip the attempt for remaining files.
|
||||
if (procSelfFdReadable == null) {
|
||||
procSelfFdReadable = false
|
||||
android.util.Log.d(
|
||||
"SpotiFLAC",
|
||||
"Direct /proc/self/fd read not usable on this device, " +
|
||||
"using temp-file fallback for remaining files",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (procSelfFdReadable == null) {
|
||||
procSelfFdReadable = false
|
||||
android.util.Log.d(
|
||||
"SpotiFLAC",
|
||||
"Direct /proc/self/fd read not usable on this device, " +
|
||||
"using temp-file fallback for remaining files",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val tempPath = try {
|
||||
copyUriToTemp(uri, fallbackExt)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
"SpotiFLAC",
|
||||
"SAF metadata fallback copy failed for $uri: ${e.message}",
|
||||
)
|
||||
null
|
||||
} ?: return null
|
||||
|
||||
try {
|
||||
val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON(
|
||||
tempPath,
|
||||
displayName,
|
||||
coverCacheKey,
|
||||
)
|
||||
if (metadataJson.isBlank()) return null
|
||||
val obj = JSONObject(metadataJson)
|
||||
return if (obj.has("error")) null else obj
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
"SpotiFLAC",
|
||||
"SAF metadata temp read failed for $uri: ${e.message}",
|
||||
)
|
||||
return null
|
||||
} finally {
|
||||
try {
|
||||
File(tempPath).delete()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun MainActivity.writeUriFromPath(uri: Uri, srcPath: String): Boolean {
|
||||
val srcFile = File(srcPath)
|
||||
if (!srcFile.exists()) return false
|
||||
contentResolver.openOutputStream(uri, "wt")?.use { output ->
|
||||
FileInputStream(srcFile).use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} ?: return false
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parent DocumentFile directory for a SAF document URI.
|
||||
* The child URI must be a tree-based document URI (e.g. from SAF tree scan).
|
||||
* Returns a DocumentFile that supports findFile() for sibling lookup.
|
||||
*/
|
||||
internal fun MainActivity.safParentDir(childUri: Uri): DocumentFile? {
|
||||
try {
|
||||
val docId = android.provider.DocumentsContract.getDocumentId(childUri)
|
||||
if (docId.isNullOrEmpty()) return null
|
||||
val lastSlash = docId.lastIndexOf('/')
|
||||
if (lastSlash <= 0) return null
|
||||
|
||||
val parentDocId = docId.substring(0, lastSlash)
|
||||
val treeDocId = android.provider.DocumentsContract.getTreeDocumentId(childUri)
|
||||
if (treeDocId.isNullOrEmpty()) return null
|
||||
|
||||
val parentUri = android.provider.DocumentsContract.buildDocumentUriUsingTree(
|
||||
childUri, parentDocId
|
||||
)
|
||||
return DocumentFile.fromTreeUri(this, parentUri)
|
||||
?: DocumentFile.fromSingleUri(this, parentUri)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SpotiFLAC", "Failed to get SAF parent dir: ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a ".lrc" sidecar next to a SAF audio document. The sidecar reuses
|
||||
* the audio file's base name (e.g. "Song.flac" -> "Song.lrc") and is created
|
||||
* in the same parent directory. Used by re-enrich when the user's lyrics
|
||||
* mode requests an external/both sidecar. Best-effort: failures are logged
|
||||
* and swallowed so they never abort the metadata enrichment itself.
|
||||
*/
|
||||
internal fun MainActivity.writeSafSidecarLrc(audioUri: Uri, lrcContent: String): Boolean {
|
||||
if (lrcContent.isBlank()) return false
|
||||
try {
|
||||
val parent = safParentDir(audioUri) ?: run {
|
||||
android.util.Log.w("SpotiFLAC", "LRC sidecar: no SAF parent dir")
|
||||
return false
|
||||
}
|
||||
val audioName = try {
|
||||
DocumentFile.fromSingleUri(this, audioUri)?.name
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return false
|
||||
val baseName = audioName.substringBeforeLast('.', audioName)
|
||||
val lrcName = "$baseName.lrc"
|
||||
|
||||
val target = SafDownloadHandler.createOrReuseDocumentFile(
|
||||
parent,
|
||||
"application/octet-stream",
|
||||
lrcName
|
||||
) ?: run {
|
||||
android.util.Log.w("SpotiFLAC", "LRC sidecar: failed to create $lrcName")
|
||||
return false
|
||||
}
|
||||
|
||||
contentResolver.openOutputStream(target.uri, "wt")?.use { output ->
|
||||
output.write(lrcContent.toByteArray(Charsets.UTF_8))
|
||||
} ?: return false
|
||||
android.util.Log.d("SpotiFLAC", "LRC sidecar written: $lrcName")
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SpotiFLAC", "LRC sidecar write failed: ${e.message}")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
internal fun MainActivity.runPostProcessingSafV2(fileUriStr: String, metadataJson: String): String {
|
||||
val uri = Uri.parse(fileUriStr)
|
||||
val doc = DocumentFile.fromSingleUri(this, uri)
|
||||
?: return errorJson("SAF file not found")
|
||||
|
||||
val tempInput = copyUriToTemp(uri) ?: return errorJson("Failed to copy SAF file to temp")
|
||||
val tempDir = File(tempInput).parentFile?.absolutePath ?: ""
|
||||
if (tempDir.isNotBlank()) {
|
||||
try {
|
||||
Gobackend.allowDownloadDir(tempDir)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
val inputObj = JSONObject()
|
||||
inputObj.put("path", tempInput)
|
||||
inputObj.put("uri", fileUriStr)
|
||||
inputObj.put("name", doc.name ?: File(tempInput).name)
|
||||
inputObj.put("mime_type", doc.type ?: contentResolver.getType(uri) ?: "")
|
||||
inputObj.put("size", doc.length())
|
||||
inputObj.put("is_saf", true)
|
||||
|
||||
val response = Gobackend.runPostProcessingV2JSON(inputObj.toString(), metadataJson)
|
||||
val respObj = JSONObject(response)
|
||||
if (!respObj.optBoolean("success", false)) {
|
||||
try {
|
||||
File(tempInput).delete()
|
||||
} catch (_: Exception) {}
|
||||
return response
|
||||
}
|
||||
|
||||
val newPath = respObj.optString("new_file_path", "")
|
||||
val outputPath = if (newPath.isNotBlank()) newPath else tempInput
|
||||
val outputFile = File(outputPath)
|
||||
if (!outputFile.exists()) {
|
||||
try {
|
||||
File(tempInput).delete()
|
||||
} catch (_: Exception) {}
|
||||
respObj.put("success", false)
|
||||
respObj.put("error", "postProcess output not found")
|
||||
return respObj.toString()
|
||||
}
|
||||
|
||||
val newName = outputFile.name
|
||||
if (!newName.isNullOrBlank() && doc.name != null && doc.name != newName) {
|
||||
try {
|
||||
doc.renameTo(newName)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
val writeOk = writeUriFromPath(uri, outputFile.absolutePath)
|
||||
if (!writeOk) {
|
||||
respObj.put("success", false)
|
||||
respObj.put("error", "failed to write postProcess output to SAF")
|
||||
return respObj.toString()
|
||||
}
|
||||
|
||||
try {
|
||||
if (outputPath != tempInput) {
|
||||
outputFile.delete()
|
||||
}
|
||||
File(tempInput).delete()
|
||||
} catch (_: Exception) {}
|
||||
|
||||
respObj.put("new_file_path", uri.toString())
|
||||
respObj.put("file_path", uri.toString())
|
||||
return respObj.toString()
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Pure finalization decisions shared by the Android background pipeline.
|
||||
*
|
||||
* Keeping format and naming policy free of Android/FFmpeg dependencies lets
|
||||
* local JVM tests cover the branches that otherwise live inside the native
|
||||
* finalizer's I/O-heavy orchestration.
|
||||
*/
|
||||
internal object NativeFinalizationPolicy {
|
||||
fun normalizeAudioCodec(codec: String?): String? {
|
||||
val normalized = normalizeOptional(codec)
|
||||
?.lowercase(Locale.ROOT)
|
||||
?.replace('-', '_')
|
||||
?: return null
|
||||
return when (normalized) {
|
||||
"mp4a" -> "aac"
|
||||
"ec_3" -> "eac3"
|
||||
"ac_3" -> "ac3"
|
||||
"ac_4" -> "ac4"
|
||||
"mp4" -> "m4a"
|
||||
"ogg" -> "opus"
|
||||
else -> normalized
|
||||
}
|
||||
}
|
||||
|
||||
fun audioFormatForCodec(codec: String?): String? {
|
||||
return when (normalizeAudioCodec(codec)) {
|
||||
"flac" -> "FLAC"
|
||||
"alac" -> "ALAC"
|
||||
"aac" -> "AAC"
|
||||
"eac3" -> "EAC3"
|
||||
"ac3" -> "AC3"
|
||||
"ac4" -> "AC4"
|
||||
"mp3" -> "MP3"
|
||||
"opus" -> "OPUS"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun isLossyAudioCodec(codec: String?): Boolean {
|
||||
return when (normalizeAudioCodec(codec)) {
|
||||
"aac", "eac3", "ac3", "ac4", "mp3", "opus", "m4a" -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun isLosslessAudioCodec(codec: String?): Boolean {
|
||||
val normalized = normalizeAudioCodec(codec) ?: return false
|
||||
if (normalized.startsWith("pcm_")) return true
|
||||
return normalized in setOf(
|
||||
"alac",
|
||||
"flac",
|
||||
"wavpack",
|
||||
"ape",
|
||||
"tta",
|
||||
"mlp",
|
||||
"truehd",
|
||||
"shorten",
|
||||
)
|
||||
}
|
||||
|
||||
fun displayAudioQuality(
|
||||
filePath: String,
|
||||
fileName: String,
|
||||
bitDepth: Int?,
|
||||
sampleRate: Int?,
|
||||
bitrateKbps: Int?,
|
||||
audioCodec: String? = null,
|
||||
storedQuality: String?,
|
||||
): String? {
|
||||
val format = audioFormatForCodec(audioCodec)
|
||||
?: audioFormatForPath(filePath, fileName)
|
||||
if (
|
||||
format == "OPUS" ||
|
||||
format == "MP3" ||
|
||||
format == "AAC" ||
|
||||
format == "EAC3" ||
|
||||
format == "AC3" ||
|
||||
format == "AC4" ||
|
||||
(format == "M4A" && (bitDepth == null || bitDepth <= 0))
|
||||
) {
|
||||
return if (bitrateKbps != null && bitrateKbps >= 16) {
|
||||
"$format ${bitrateKbps}kbps"
|
||||
} else {
|
||||
nonPlaceholderQuality(storedQuality) ?: format
|
||||
}
|
||||
}
|
||||
|
||||
if (bitDepth != null && bitDepth > 0 && sampleRate != null && sampleRate > 0) {
|
||||
return "$bitDepth-bit/${sampleRateLabel(sampleRate)}kHz"
|
||||
}
|
||||
return nonPlaceholderQuality(storedQuality) ?: normalizeOptional(storedQuality)
|
||||
}
|
||||
|
||||
fun qualityVariantFilenameLabel(
|
||||
measuredQuality: String,
|
||||
bitDepth: Int?,
|
||||
sampleRate: Int?,
|
||||
bitrateKbps: Int?,
|
||||
audioCodec: String?,
|
||||
): String? {
|
||||
if (isLossyAudioCodec(audioCodec)) {
|
||||
val bitrate = bitrateKbps ?: Regex(
|
||||
"\\b(\\d+)\\s*kbps\\b",
|
||||
RegexOption.IGNORE_CASE,
|
||||
).find(measuredQuality)?.groupValues?.getOrNull(1)?.toIntOrNull()
|
||||
return bitrate?.takeIf { it >= 16 }?.let { "${it}kbps" }
|
||||
}
|
||||
|
||||
var resolvedBitDepth = bitDepth
|
||||
var resolvedSampleRate = sampleRate
|
||||
if (resolvedBitDepth == null || resolvedSampleRate == null) {
|
||||
val match = Regex(
|
||||
"\\b(\\d+)\\s*(?:-|\\s)?bit\\s*[/_-]\\s*(\\d+(?:\\.\\d+)?)\\s*k?hz\\b",
|
||||
RegexOption.IGNORE_CASE,
|
||||
).find(measuredQuality)
|
||||
resolvedBitDepth =
|
||||
resolvedBitDepth ?: match?.groupValues?.getOrNull(1)?.toIntOrNull()
|
||||
resolvedSampleRate =
|
||||
resolvedSampleRate
|
||||
?: match?.groupValues?.getOrNull(2)?.toDoubleOrNull()?.let { rate ->
|
||||
if (rate < 1000) {
|
||||
(rate * 1000).roundToInt()
|
||||
} else {
|
||||
rate.roundToInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
resolvedBitDepth == null ||
|
||||
resolvedBitDepth <= 0 ||
|
||||
resolvedSampleRate == null ||
|
||||
resolvedSampleRate <= 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return "${resolvedBitDepth}bit-${sampleRateLabel(resolvedSampleRate)}kHz"
|
||||
}
|
||||
|
||||
fun applyQualityVariantFilenameLabel(
|
||||
fileName: String,
|
||||
stagingLabel: String,
|
||||
qualityLabel: String,
|
||||
): String {
|
||||
if (stagingLabel.isNotEmpty() && fileName.contains(stagingLabel)) {
|
||||
return fileName.replace(stagingLabel, qualityLabel)
|
||||
}
|
||||
if (fileName.contains(qualityLabel)) return fileName
|
||||
val dotIndex = fileName.lastIndexOf('.')
|
||||
val hasExtension = dotIndex > 0
|
||||
val stem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
|
||||
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
|
||||
return "$stem - $qualityLabel$extension"
|
||||
}
|
||||
|
||||
fun removeQualityVariantStagingLabel(
|
||||
fileName: String,
|
||||
stagingLabel: String,
|
||||
): String {
|
||||
if (stagingLabel.isEmpty() || !fileName.contains(stagingLabel)) return fileName
|
||||
val dotIndex = fileName.lastIndexOf('.')
|
||||
val hasExtension = dotIndex > 0
|
||||
val stem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
|
||||
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
|
||||
val cleanedStem = stem
|
||||
.replace(stagingLabel, "")
|
||||
.replace(Regex("[\\s_-]+$"), "")
|
||||
.trim()
|
||||
.ifBlank { "track" }
|
||||
return "$cleanedStem$extension"
|
||||
}
|
||||
|
||||
fun resolveQualityVariantFilename(
|
||||
fileName: String,
|
||||
stagingLabel: String,
|
||||
qualityLabel: String,
|
||||
collisionOnly: Boolean,
|
||||
cleanNameExists: Boolean,
|
||||
): String {
|
||||
if (!collisionOnly || cleanNameExists) {
|
||||
return applyQualityVariantFilenameLabel(fileName, stagingLabel, qualityLabel)
|
||||
}
|
||||
return removeQualityVariantStagingLabel(fileName, stagingLabel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user-facing name that a deferred SAF download was assigned
|
||||
* before its audio was materialized in the app cache. Container and
|
||||
* decryption passes may replace [currentFileName] with a temporary
|
||||
* `native_saf_work_*` name, which must never become the published name.
|
||||
*/
|
||||
fun logicalOutputFileName(
|
||||
deferredSafPublish: Boolean,
|
||||
resultSafFileName: String?,
|
||||
requestSafFileName: String?,
|
||||
currentFileName: String,
|
||||
): String {
|
||||
if (!deferredSafPublish) return currentFileName
|
||||
return normalizeOptional(resultSafFileName)
|
||||
?: normalizeOptional(requestSafFileName)
|
||||
?: currentFileName
|
||||
}
|
||||
|
||||
fun resolvePreferredDecryptionExtension(
|
||||
inputPath: String,
|
||||
requested: String,
|
||||
): String {
|
||||
val normalizedRequest = normalizeExtension(requested)
|
||||
if (normalizedRequest.isNotBlank()) return normalizedRequest
|
||||
val lower = inputPath.lowercase(Locale.ROOT)
|
||||
return when {
|
||||
lower.endsWith(".m4a") -> ".flac"
|
||||
lower.endsWith(".flac") -> ".flac"
|
||||
lower.endsWith(".mp3") -> ".mp3"
|
||||
lower.endsWith(".opus") -> ".opus"
|
||||
lower.endsWith(".mp4") -> ".mp4"
|
||||
else -> ".flac"
|
||||
}
|
||||
}
|
||||
|
||||
fun formatIndexTag(number: Int, total: Int): String {
|
||||
if (number <= 0) return "0"
|
||||
return if (total > 0) "$number/$total" else number.toString()
|
||||
}
|
||||
|
||||
private fun audioFormatForPath(filePath: String, fileName: String): String? {
|
||||
for (candidate in listOf(filePath, fileName)) {
|
||||
val lower = candidate.trim().lowercase(Locale.ROOT)
|
||||
when {
|
||||
lower.endsWith(".opus") || lower.endsWith(".ogg") -> return "OPUS"
|
||||
lower.endsWith(".mp3") -> return "MP3"
|
||||
lower.endsWith(".aac") -> return "AAC"
|
||||
lower.endsWith(".m4a") || lower.endsWith(".mp4") -> return "M4A"
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun nonPlaceholderQuality(quality: String?): String? {
|
||||
val normalized = normalizeOptional(quality) ?: return null
|
||||
val bitrateMatch =
|
||||
Regex("\\b(\\d+)\\s*kbps\\b", RegexOption.IGNORE_CASE).find(normalized)
|
||||
if (bitrateMatch != null) {
|
||||
val bitrate = bitrateMatch.groupValues.getOrNull(1)?.toIntOrNull()
|
||||
if (bitrate != null && bitrate < 16) return null
|
||||
}
|
||||
val key = normalized
|
||||
.lowercase(Locale.ROOT)
|
||||
.replace(Regex("[^a-z0-9]+"), "_")
|
||||
.trim('_')
|
||||
val placeholders = setOf(
|
||||
"best",
|
||||
"lossless",
|
||||
"hi_res",
|
||||
"hires",
|
||||
"hi_res_lossless",
|
||||
"hires_lossless",
|
||||
"high",
|
||||
"cd",
|
||||
"flac_best_available",
|
||||
)
|
||||
return if (placeholders.contains(key)) null else normalized
|
||||
}
|
||||
|
||||
private fun sampleRateLabel(sampleRate: Int): String {
|
||||
val khz = sampleRate / 1000.0
|
||||
val precision = if (sampleRate % 1000 == 0) 0 else 1
|
||||
return "%.${precision}f".format(Locale.US, khz)
|
||||
}
|
||||
|
||||
private fun normalizeOptional(value: String?): String? {
|
||||
val trimmed = value?.trim().orEmpty()
|
||||
if (trimmed.isEmpty() || trimmed.equals("null", ignoreCase = true)) {
|
||||
return null
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private fun normalizeExtension(extension: String?): String {
|
||||
val trimmed = extension?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return ""
|
||||
val lower = trimmed.lowercase(Locale.ROOT)
|
||||
return if (lower.startsWith(".")) lower else ".$lower"
|
||||
}
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteException
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.antonkarpenko.ffmpegkit.FFmpegKit
|
||||
import com.antonkarpenko.ffmpegkit.FFmpegKitConfig
|
||||
import com.antonkarpenko.ffmpegkit.FFmpegSession
|
||||
import com.antonkarpenko.ffmpegkit.FFmpegSessionCompleteCallback
|
||||
import com.antonkarpenko.ffmpegkit.LogRedirectionStrategy
|
||||
import com.antonkarpenko.ffmpegkit.ReturnCode
|
||||
import com.zarz.spotiflac.SafDownloadHandler.mimeTypeForExt
|
||||
import com.zarz.spotiflac.SafDownloadHandler.normalizeExt
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.applyQualityVariantFilenameLabel
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.displayAudioQuality
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.formatIndexTag
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.isLosslessAudioCodec
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.isLossyAudioCodec
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.normalizeAudioCodec
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.resolvePreferredDecryptionExtension
|
||||
import gobackend.Gobackend
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.pow
|
||||
|
||||
|
||||
// FFmpeg execution, probing, and container helpers for NativeDownloadFinalizer.
|
||||
|
||||
internal fun NativeDownloadFinalizer.isMP4ContainerFile(path: String): Boolean {
|
||||
return try {
|
||||
File(path).inputStream().use { stream ->
|
||||
val header = ByteArray(12)
|
||||
val read = stream.read(header)
|
||||
read >= 8 &&
|
||||
header[4] == 'f'.code.toByte() &&
|
||||
header[5] == 't'.code.toByte() &&
|
||||
header[6] == 'y'.code.toByte() &&
|
||||
header[7] == 'p'.code.toByte()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.formatForPath(path: String): String {
|
||||
return when (normalizeExt(File(path).extension)) {
|
||||
".mp3" -> "mp3"
|
||||
".opus", ".ogg" -> "opus"
|
||||
".m4a", ".mp4", ".aac" -> "m4a"
|
||||
else -> "flac"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.scanReplayGain(path: String, shouldCancel: () -> Boolean = { false }): NativeDownloadFinalizer.ReplayGainScan? {
|
||||
val command = "-hide_banner -nostats -i ${q(path)} -filter_complex ebur128=peak=true:framelog=quiet -f null -"
|
||||
val result = runFFmpeg(command, shouldCancel)
|
||||
val output = result.second
|
||||
val integrated = Regex("I:\\s+(-?\\d+\\.?\\d*)\\s+LUFS")
|
||||
.findAll(output)
|
||||
.lastOrNull()
|
||||
?.groupValues
|
||||
?.getOrNull(1)
|
||||
?.toDoubleOrNull() ?: return null
|
||||
val truePeak = Regex("Peak:\\s+(-?\\d+\\.?\\d*)\\s+dBFS")
|
||||
.findAll(output)
|
||||
.mapNotNull { it.groupValues.getOrNull(1)?.toDoubleOrNull() }
|
||||
.maxOrNull()
|
||||
val gain = -18.0 - integrated
|
||||
val peak = if (truePeak != null) 10.0.pow(truePeak / 20.0) else 1.0
|
||||
return NativeDownloadFinalizer.ReplayGainScan(
|
||||
trackGain = "${if (gain >= 0) "+" else ""}${"%.2f".format(Locale.US, gain)} dB",
|
||||
trackPeak = "%.6f".format(Locale.US, peak),
|
||||
integratedLufs = integrated,
|
||||
truePeakLinear = peak,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.runFFmpeg(command: String, shouldCancel: () -> Boolean = { false }): Pair<Boolean, String> {
|
||||
checkCancelled(shouldCancel)
|
||||
installNativeFFmpegCallbackFilter()
|
||||
val latch = CountDownLatch(1)
|
||||
var completedSession: FFmpegSession? = null
|
||||
val session = FFmpegSession.create(
|
||||
FFmpegKitConfig.parseArguments(command),
|
||||
{ finishedSession ->
|
||||
completedSession = finishedSession
|
||||
latch.countDown()
|
||||
},
|
||||
null,
|
||||
null,
|
||||
LogRedirectionStrategy.NEVER_PRINT_LOGS,
|
||||
)
|
||||
val sessionId = session.sessionId
|
||||
synchronized(activeFFmpegSessionLock) {
|
||||
activeFFmpegSessionIds.add(sessionId)
|
||||
nativeFFmpegSessionIds.add(sessionId)
|
||||
}
|
||||
FFmpegKitConfig.asyncFFmpegExecute(session)
|
||||
try {
|
||||
var cancelRequested = false
|
||||
while (!latch.await(200, TimeUnit.MILLISECONDS)) {
|
||||
if (shouldCancel()) {
|
||||
cancelRequested = true
|
||||
try {
|
||||
FFmpegKit.cancel(sessionId)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if (cancelRequested) {
|
||||
latch.await(5, TimeUnit.SECONDS)
|
||||
throw CancellationException("Native FFmpeg session cancelled")
|
||||
}
|
||||
val finalSession = completedSession ?: session
|
||||
val output = finalSession.getAllLogsAsString(1000) ?: ""
|
||||
checkCancelled(shouldCancel)
|
||||
return ReturnCode.isSuccess(finalSession.returnCode) to output
|
||||
} finally {
|
||||
synchronized(activeFFmpegSessionLock) {
|
||||
activeFFmpegSessionIds.remove(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.installNativeFFmpegCallbackFilter() {
|
||||
synchronized(ffmpegCompleteCallbackLock) {
|
||||
val current = FFmpegKitConfig.getFFmpegSessionCompleteCallback()
|
||||
if (current !== nativeFilteringFFmpegCompleteCallback) {
|
||||
forwardedFFmpegCompleteCallback = current
|
||||
FFmpegKitConfig.enableFFmpegSessionCompleteCallback(nativeFilteringFFmpegCompleteCallback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.withFFmpegCommandPump(
|
||||
shouldCancel: () -> Boolean = { false },
|
||||
block: () -> String,
|
||||
): String {
|
||||
val running = AtomicBoolean(true)
|
||||
val handled = mutableSetOf<String>()
|
||||
val pump = Thread {
|
||||
while (running.get()) {
|
||||
try {
|
||||
val raw = Gobackend.getAllPendingFFmpegCommandsJSON()
|
||||
val commands = org.json.JSONArray(raw)
|
||||
for (index in 0 until commands.length()) {
|
||||
val command = commands.optJSONObject(index) ?: continue
|
||||
val id = command.optString("command_id", "")
|
||||
val commandLine = command.optString("command", "")
|
||||
if (id.isBlank() || commandLine.isBlank() || handled.contains(id)) {
|
||||
continue
|
||||
}
|
||||
handled.add(id)
|
||||
// Every claimed command must get a result delivered to
|
||||
// the Go side, even on failure or cancellation: the
|
||||
// backend blocks until one arrives and never retries a
|
||||
// claimed id, so bailing out here would strand the
|
||||
// gomobile call the main thread is sitting in forever.
|
||||
val result = try {
|
||||
if (shouldCancel()) {
|
||||
Pair(false, "cancelled")
|
||||
} else {
|
||||
runFFmpeg(commandLine, shouldCancel)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Pair(false, e.message ?: "FFmpeg execution failed")
|
||||
}
|
||||
try {
|
||||
Gobackend.setFFmpegCommandResultByID(
|
||||
id,
|
||||
result.first,
|
||||
result.second,
|
||||
if (result.first) "" else result.second,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to deliver FFmpeg result for $id: ${e.message}")
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
try {
|
||||
Thread.sleep(100)
|
||||
} catch (_: InterruptedException) {
|
||||
// Keep pumping until `running` flips: on cancel the Go call
|
||||
// may still be waiting for a result for an in-flight
|
||||
// command, and it is delivered as failed above.
|
||||
}
|
||||
}
|
||||
}
|
||||
pump.isDaemon = true
|
||||
pump.start()
|
||||
return try {
|
||||
block()
|
||||
} finally {
|
||||
running.set(false)
|
||||
pump.interrupt()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Staged sibling name for conversion outputs: "song.flac" -> "song.partial.flac".
|
||||
* The ".partial<ext>" shape is ignored by library scans and duplicate checks,
|
||||
* while the real trailing extension still lets FFmpeg infer the muxer. A
|
||||
* process kill mid-conversion therefore never leaves a partial file under a
|
||||
* real audio name in the user's music folder.
|
||||
*/
|
||||
internal fun NativeDownloadFinalizer.stagedConversionPath(finalPath: String): String {
|
||||
val file = File(finalPath)
|
||||
val ext = file.extension
|
||||
val name = if (ext.isBlank()) "${file.name}.partial" else "${file.nameWithoutExtension}.partial.$ext"
|
||||
return File(file.parentFile, name).absolutePath
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.promoteStagedConversion(stagedPath: String, finalPath: String): Boolean {
|
||||
val staged = File(stagedPath)
|
||||
fsyncQuietly(staged)
|
||||
val final = File(finalPath)
|
||||
if (staged.renameTo(final)) return true
|
||||
return final.delete() && staged.renameTo(final)
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.buildOutputPath(inputPath: String, extension: String): String {
|
||||
val ext = normalizeExt(extension).ifBlank { ".tmp" }
|
||||
val file = File(inputPath)
|
||||
val base = file.nameWithoutExtension.ifBlank { "track" }
|
||||
val candidate = File(file.parentFile, "$base$ext").absolutePath
|
||||
if (candidate != inputPath) return candidate
|
||||
return File(file.parentFile, "${base}_converted$ext").absolutePath
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.desiredFileName(input: NativeDownloadFinalizer.FinalizeInput, state: NativeDownloadFinalizer.FinalizeState, extension: String): String {
|
||||
val ext = normalizeExt(extension).ifBlank { normalizeExt(File(state.fileName).extension).ifBlank { ".flac" } }
|
||||
val rawName = input.result.optString("quality_variant_file_name", "")
|
||||
.ifBlank { input.request.optString("saf_file_name", "") }
|
||||
.ifBlank { state.fileName }
|
||||
.ifBlank { "${trackString(input, "artistName", input.request.optString("artist_name", "Artist"))} - ${trackString(input, "name", input.request.optString("track_name", "Track"))}" }
|
||||
val knownExts = listOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg", ".lrc")
|
||||
var base = rawName.trim()
|
||||
val lower = base.lowercase(Locale.ROOT)
|
||||
for (knownExt in knownExts) {
|
||||
if (lower.endsWith(knownExt)) {
|
||||
base = base.dropLast(knownExt.length)
|
||||
break
|
||||
}
|
||||
}
|
||||
base = base
|
||||
.replace("/", " ")
|
||||
.replace(Regex("[\\\\:*?\"<>|]"), " ")
|
||||
.trim()
|
||||
.trim('.', ' ')
|
||||
.ifBlank { "track" }
|
||||
return "$base$ext"
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.shouldForceContainerConversion(input: NativeDownloadFinalizer.FinalizeInput, state: NativeDownloadFinalizer.FinalizeState): Boolean {
|
||||
if (input.result.optBoolean("requires_container_conversion", false)) return true
|
||||
if (input.request.optBoolean("requires_container_conversion", false)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.probePrimaryAudioCodec(path: String, shouldCancel: () -> Boolean = { false }): String {
|
||||
val result = runFFmpeg("-hide_banner -nostdin -i ${q(path)} -map 0:a:0 -frames:a 1 -f null -", shouldCancel)
|
||||
val output = result.second
|
||||
val match = Regex("Audio:\\s*([^,\\s]+)", RegexOption.IGNORE_CASE).find(output)
|
||||
return match?.groupValues?.getOrNull(1)
|
||||
?.trim()
|
||||
?.lowercase(Locale.ROOT)
|
||||
?.replace('-', '_')
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the file on [path] starts with the native FLAC magic
|
||||
* bytes (`fLaC`). A file may contain a FLAC audio stream yet live inside
|
||||
* an MP4/fMP4 container (e.g. some Amazon Music downloads); native FLAC
|
||||
* tag writers require the raw fLaC header, so we must detect that mismatch
|
||||
* before skipping the container conversion step.
|
||||
*/
|
||||
internal fun NativeDownloadFinalizer.isNativeFlacFile(path: String): Boolean {
|
||||
return try {
|
||||
RandomAccessFile(path, "r").use { raf ->
|
||||
if (raf.length() < 4L) return false
|
||||
val header = ByteArray(4)
|
||||
raf.readFully(header)
|
||||
header[0] == 0x66.toByte() && // 'f'
|
||||
header[1] == 0x4C.toByte() && // 'L'
|
||||
header[2] == 0x61.toByte() && // 'a'
|
||||
header[3] == 0x43.toByte() // 'C'
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Native FLAC magic probe failed for $path: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.requestedDecryptionOutputExt(input: NativeDownloadFinalizer.FinalizeInput): String {
|
||||
val descriptor = input.result.optJSONObject("decryption")
|
||||
return normalizeExt(
|
||||
descriptor?.optString("output_extension", "")
|
||||
?.ifBlank { input.result.optString("output_extension", "") }
|
||||
)
|
||||
}
|
||||
@@ -1,489 +0,0 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteException
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.antonkarpenko.ffmpegkit.FFmpegKit
|
||||
import com.antonkarpenko.ffmpegkit.FFmpegKitConfig
|
||||
import com.antonkarpenko.ffmpegkit.FFmpegSession
|
||||
import com.antonkarpenko.ffmpegkit.FFmpegSessionCompleteCallback
|
||||
import com.antonkarpenko.ffmpegkit.LogRedirectionStrategy
|
||||
import com.antonkarpenko.ffmpegkit.ReturnCode
|
||||
import com.zarz.spotiflac.SafDownloadHandler.mimeTypeForExt
|
||||
import com.zarz.spotiflac.SafDownloadHandler.normalizeExt
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.applyQualityVariantFilenameLabel
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.displayAudioQuality
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.formatIndexTag
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.isLosslessAudioCodec
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.isLossyAudioCodec
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.logicalOutputFileName
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.removeQualityVariantStagingLabel
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.resolveQualityVariantFilename
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.normalizeAudioCodec
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.resolvePreferredDecryptionExtension
|
||||
import gobackend.Gobackend
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.pow
|
||||
|
||||
|
||||
// Metadata embedding, cover download, external LRC, and quality-variant
|
||||
// filename helpers for NativeDownloadFinalizer.
|
||||
|
||||
internal fun NativeDownloadFinalizer.qualityVariantFilenameLabel(state: NativeDownloadFinalizer.FinalizeState): String? {
|
||||
return NativeFinalizationPolicy.qualityVariantFilenameLabel(
|
||||
measuredQuality = state.quality,
|
||||
bitDepth = state.bitDepth,
|
||||
sampleRate = state.sampleRate,
|
||||
bitrateKbps = state.bitrateKbps,
|
||||
audioCodec = state.audioCodec,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.finalizeQualityVariantFilename(
|
||||
context: Context,
|
||||
input: NativeDownloadFinalizer.FinalizeInput,
|
||||
state: NativeDownloadFinalizer.FinalizeState,
|
||||
) {
|
||||
if (!input.request.optBoolean("allow_quality_variant", false)) return
|
||||
val stagingLabel = input.request.optString("quality_variant", "").trim()
|
||||
val qualityLabel = qualityVariantFilenameLabel(state)
|
||||
if (qualityLabel == null) {
|
||||
Log.w(TAG, "Keeping temporary quality label because final audio specifications are unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
val logicalFileName = logicalOutputFileName(
|
||||
deferredSafPublish = isDeferredSafPublish(input),
|
||||
resultSafFileName = input.result.optString("saf_final_file_name", ""),
|
||||
requestSafFileName = input.request.optString("saf_file_name", ""),
|
||||
currentFileName = state.fileName,
|
||||
)
|
||||
val variantName = applyQualityVariantFilenameLabel(
|
||||
fileName = logicalFileName,
|
||||
stagingLabel = stagingLabel,
|
||||
qualityLabel = qualityLabel,
|
||||
)
|
||||
val cleanName = removeQualityVariantStagingLabel(logicalFileName, stagingLabel)
|
||||
val collisionOnly = input.request.optBoolean("quality_variant_collision_only", false)
|
||||
val preferredName = variantName
|
||||
if (preferredName == logicalFileName && preferredName == state.fileName) return
|
||||
input.result.put("quality_variant_file_name", preferredName)
|
||||
if (isDeferredSafPublish(input)) {
|
||||
state.fileName = preferredName
|
||||
return
|
||||
}
|
||||
|
||||
if (state.filePath.startsWith("content://")) {
|
||||
val tempPath = SafDownloadHandler.copyContentUriToTemp(context, state.filePath) ?: return
|
||||
try {
|
||||
val treeUri = input.request.optString("saf_tree_uri", "")
|
||||
val relativeDir = input.request.optString("saf_relative_dir", "")
|
||||
val writeResult = if (collisionOnly) {
|
||||
SafDownloadHandler.writeFileToSafCollisionAware(
|
||||
context = context,
|
||||
treeUriStr = treeUri,
|
||||
relativeDir = relativeDir,
|
||||
cleanFileName = cleanName,
|
||||
variantFileName = variantName,
|
||||
mimeType = mimeTypeForExt(File(variantName).extension),
|
||||
srcPath = tempPath,
|
||||
preservedSuffix = qualityLabel,
|
||||
)
|
||||
} else {
|
||||
SafDownloadHandler.writeFileToSafUnique(
|
||||
context = context,
|
||||
treeUriStr = treeUri,
|
||||
relativeDir = relativeDir,
|
||||
fileName = preferredName,
|
||||
mimeType = mimeTypeForExt(File(preferredName).extension),
|
||||
srcPath = tempPath,
|
||||
preservedSuffix = qualityLabel,
|
||||
)
|
||||
} ?: return
|
||||
SafDownloadHandler.deleteContentUri(context, state.filePath)
|
||||
state.filePath = writeResult.uri
|
||||
state.fileName = writeResult.fileName
|
||||
} finally {
|
||||
File(tempPath).delete()
|
||||
}
|
||||
} else {
|
||||
val source = File(state.filePath)
|
||||
val cleanTarget = File(source.parentFile, cleanName)
|
||||
val lockTarget = if (collisionOnly) cleanTarget else File(source.parentFile, preferredName)
|
||||
val lockKey = lockTarget.absolutePath.lowercase(Locale.ROOT)
|
||||
val lock = qualityVariantNameLocks.computeIfAbsent(lockKey) { Any() }
|
||||
synchronized(lock) {
|
||||
val selectedName = if (collisionOnly) {
|
||||
resolveQualityVariantFilename(
|
||||
fileName = logicalFileName,
|
||||
stagingLabel = stagingLabel,
|
||||
qualityLabel = qualityLabel,
|
||||
collisionOnly = true,
|
||||
cleanNameExists = cleanTarget.absolutePath != source.absolutePath && cleanTarget.exists(),
|
||||
)
|
||||
} else {
|
||||
preferredName
|
||||
}
|
||||
input.result.put("quality_variant_file_name", selectedName)
|
||||
if (selectedName == state.fileName) return@synchronized
|
||||
val target = uniqueLocalFile(source.parentFile, selectedName)
|
||||
if (!source.renameTo(target)) {
|
||||
Log.w(TAG, "Could not rename quality variant output: ${source.absolutePath}")
|
||||
return@synchronized
|
||||
}
|
||||
state.filePath = target.absolutePath
|
||||
state.fileName = target.name
|
||||
}
|
||||
}
|
||||
|
||||
input.result.put("file_path", state.filePath)
|
||||
input.result.put("file_name", state.fileName)
|
||||
input.result.optJSONObject("replaygain")?.let { replayGain ->
|
||||
replayGain.put("file_path", state.filePath)
|
||||
replayGain.put("file_name", state.fileName)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.uniqueLocalFile(parent: File?, preferredName: String): File {
|
||||
val directory = parent ?: return File(preferredName)
|
||||
var candidate = File(directory, preferredName)
|
||||
if (!candidate.exists()) return candidate
|
||||
val dotIndex = preferredName.lastIndexOf('.')
|
||||
val hasExtension = dotIndex > 0
|
||||
val stem = if (hasExtension) preferredName.substring(0, dotIndex) else preferredName
|
||||
val extension = if (hasExtension) preferredName.substring(dotIndex) else ""
|
||||
var counter = 2
|
||||
while (candidate.exists()) {
|
||||
candidate = File(directory, "$stem ($counter)$extension")
|
||||
counter++
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.writeExternalLrc(context: Context, input: NativeDownloadFinalizer.FinalizeInput, state: NativeDownloadFinalizer.FinalizeState) {
|
||||
if (!input.request.optBoolean("embed_metadata", false) || !input.request.optBoolean("embed_lyrics", false)) return
|
||||
val lyricsMode = input.request.optString("lyrics_mode", "")
|
||||
if (lyricsMode != "external" && lyricsMode != "both") return
|
||||
val lrc = resolveLyricsLrc(input)
|
||||
if (lrc.isBlank() || lrc == "[instrumental:true]") return
|
||||
val audioFileName = if (isDeferredSafRequest(input)) {
|
||||
desiredFileName(input, state, File(state.filePath).extension)
|
||||
} else {
|
||||
state.fileName
|
||||
}
|
||||
val baseName = audioFileName.replace(Regex("\\.[^.]+$"), "")
|
||||
if (isDeferredSafRequest(input)) {
|
||||
state.pendingExternalLrc = lrc
|
||||
state.pendingExternalLrcFileName = "$baseName.lrc"
|
||||
return
|
||||
}
|
||||
if (state.filePath.startsWith("content://")) {
|
||||
val treeUri = input.request.optString("saf_tree_uri", "")
|
||||
val relativeDir = input.request.optString("saf_relative_dir", "")
|
||||
val temp = File(context.cacheDir, "native_lrc_${System.nanoTime()}.lrc")
|
||||
temp.writeText(lrc)
|
||||
try {
|
||||
SafDownloadHandler.writeFileToSaf(
|
||||
context = context,
|
||||
treeUriStr = treeUri,
|
||||
relativeDir = relativeDir,
|
||||
fileName = "$baseName.lrc",
|
||||
mimeType = "application/octet-stream",
|
||||
srcPath = temp.absolutePath,
|
||||
)
|
||||
} finally {
|
||||
temp.delete()
|
||||
}
|
||||
} else {
|
||||
val target = File(File(state.filePath).parentFile, "$baseName.lrc")
|
||||
target.writeText(lrc)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.resolveLyricsLrc(input: NativeDownloadFinalizer.FinalizeInput): String {
|
||||
val existing = input.result.optString("lyrics_lrc", "").trim()
|
||||
if (existing.isNotEmpty()) return existing
|
||||
|
||||
val spotifyId = trackString(input, "id", input.request.optString("spotify_id", ""))
|
||||
val trackName = trackString(input, "name", input.request.optString("track_name", ""))
|
||||
val artistName = trackString(input, "artistName", input.request.optString("artist_name", ""))
|
||||
if (trackName.isBlank() || artistName.isBlank()) return ""
|
||||
|
||||
return try {
|
||||
val fetched = Gobackend.getLyricsLRC(
|
||||
spotifyId,
|
||||
trackName,
|
||||
artistName,
|
||||
"",
|
||||
lyricsDurationMs(input),
|
||||
).trim()
|
||||
if (fetched.isNotEmpty()) {
|
||||
input.result.put("lyrics_lrc", fetched)
|
||||
}
|
||||
fetched
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.lyricsDurationMs(input: NativeDownloadFinalizer.FinalizeInput): Long {
|
||||
val requestDuration = input.request.optLong("duration_ms", 0L)
|
||||
val trackDuration = trackInt(input, "duration", 0).toLong()
|
||||
val duration = if (requestDuration > 0L) requestDuration else trackDuration
|
||||
if (duration <= 0L) return 0L
|
||||
return if (duration > 10000L) duration else duration * 1000L
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.embedBasicMetadata(context: Context, path: String, input: NativeDownloadFinalizer.FinalizeInput, format: String) {
|
||||
if (!input.request.optBoolean("embed_metadata", false)) return
|
||||
val title = resultString(input, "title").ifBlank {
|
||||
trackString(input, "name", requestString(input, "track_name"))
|
||||
}
|
||||
val artist = resultString(input, "artist").ifBlank {
|
||||
trackString(input, "artistName", requestString(input, "artist_name"))
|
||||
}
|
||||
val album = resultString(input, "album").ifBlank {
|
||||
trackString(input, "albumName", requestString(input, "album_name"))
|
||||
}
|
||||
val albumArtist = resultString(input, "album_artist").ifBlank {
|
||||
trackString(input, "albumArtist", requestString(input, "album_artist"))
|
||||
}
|
||||
val date = resultString(input, "release_date").ifBlank {
|
||||
resultString(input, "date").ifBlank {
|
||||
trackString(input, "releaseDate", requestString(input, "release_date"))
|
||||
}
|
||||
}
|
||||
val trackNumberValue = positiveOrNull(input.result.optInt("track_number", 0), trackInt(input, "trackNumber", input.request.optInt("track_number", 0))) ?: 0
|
||||
val totalTracksValue = positiveOrNull(input.result.optInt("total_tracks", 0), trackInt(input, "totalTracks", input.request.optInt("total_tracks", 0))) ?: 0
|
||||
val discNumberValue = positiveOrNull(input.result.optInt("disc_number", 0), trackInt(input, "discNumber", input.request.optInt("disc_number", 0))) ?: 0
|
||||
val totalDiscsValue = positiveOrNull(input.result.optInt("total_discs", 0), trackInt(input, "totalDiscs", input.request.optInt("total_discs", 0))) ?: 0
|
||||
val trackNumber = formatIndexTag(trackNumberValue, totalTracksValue)
|
||||
val discNumber = formatIndexTag(discNumberValue, totalDiscsValue)
|
||||
val isrc = resultString(input, "isrc").ifBlank {
|
||||
trackString(input, "isrc", requestString(input, "isrc"))
|
||||
}
|
||||
val composer = resultString(input, "composer").ifBlank {
|
||||
trackString(input, "composer", requestString(input, "composer"))
|
||||
}
|
||||
val genre = resultString(input, "genre").ifBlank { requestString(input, "genre") }
|
||||
val label = resultString(input, "label").ifBlank { requestString(input, "label") }
|
||||
val copyright = resultString(input, "copyright").ifBlank { requestString(input, "copyright") }
|
||||
val lyricsMode = input.request.optString("lyrics_mode", "embed")
|
||||
val shouldResolveLyrics = input.request.optBoolean("embed_lyrics", false) &&
|
||||
(lyricsMode == "embed" || lyricsMode == "both")
|
||||
val lyrics = if (shouldResolveLyrics) resolveLyricsLrc(input) else ""
|
||||
val shouldEmbedLyrics = shouldResolveLyrics &&
|
||||
lyrics.isNotBlank() &&
|
||||
lyrics != "[instrumental:true]"
|
||||
// FLAC, MP3, Opus, and M4A all have native Go tag writers that edit the
|
||||
// tag block atomically without an ffmpeg remux (which drops foreign
|
||||
// frames and rewrites the whole container). The Go side answers
|
||||
// method=ffmpeg when it cannot handle the file natively.
|
||||
if (format == "flac" || format == "mp3" || format == "opus" || format == "m4a") {
|
||||
val nativeCover = downloadCoverForMetadata(context, input)
|
||||
val handledNatively = try {
|
||||
val fields = JSONObject()
|
||||
.put("title", title)
|
||||
.put("artist", artist)
|
||||
.put("album", album)
|
||||
.put("album_artist", albumArtist)
|
||||
.put("date", date)
|
||||
.put("isrc", isrc)
|
||||
.put("composer", composer)
|
||||
.put("genre", genre)
|
||||
.put("label", label)
|
||||
.put("copyright", copyright)
|
||||
if (trackNumberValue > 0) fields.put("track_number", trackNumberValue.toString())
|
||||
if (totalTracksValue > 0) fields.put("track_total", totalTracksValue.toString())
|
||||
if (discNumberValue > 0) fields.put("disc_number", discNumberValue.toString())
|
||||
if (totalDiscsValue > 0) fields.put("disc_total", totalDiscsValue.toString())
|
||||
if (nativeCover != null) fields.put("cover_path", nativeCover.absolutePath)
|
||||
if (shouldEmbedLyrics) {
|
||||
fields.put("lyrics", lyrics)
|
||||
fields.put("unsyncedlyrics", lyrics)
|
||||
}
|
||||
val response = Gobackend.editFileMetadata(path, fields.toString())
|
||||
val method = try {
|
||||
JSONObject(response).optString("method", "")
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
method != "ffmpeg"
|
||||
} catch (e: Exception) {
|
||||
if (format == "flac") throw e
|
||||
Log.w(TAG, "Native tag embed failed for $format: ${e.message}; falling back to ffmpeg")
|
||||
false
|
||||
} finally {
|
||||
nativeCover?.delete()
|
||||
}
|
||||
if (handledNatively) return
|
||||
}
|
||||
|
||||
val ext = normalizeExt(File(path).extension).ifBlank { ".tmp" }
|
||||
val inputFile = File(path)
|
||||
// ".partial<ext>" keeps the temp invisible to library scans while FFmpeg
|
||||
// still infers the muxer from the real trailing extension.
|
||||
val temp = File(inputFile.parentFile, "${inputFile.nameWithoutExtension}_tagged.partial$ext")
|
||||
val isM4a = format == "m4a"
|
||||
val isOpus = format == "opus"
|
||||
val coverFile = if (isM4a || isOpus) downloadCoverForMetadata(context, input) else null
|
||||
val labelKey = if (isM4a) "organization" else "label"
|
||||
val metadataPairs = mutableListOf(
|
||||
"title" to title,
|
||||
"artist" to artist,
|
||||
"album" to album,
|
||||
"album_artist" to albumArtist,
|
||||
"date" to date,
|
||||
"track" to trackNumber,
|
||||
"disc" to discNumber,
|
||||
"isrc" to isrc,
|
||||
"composer" to composer,
|
||||
"genre" to genre,
|
||||
labelKey to label,
|
||||
"copyright" to copyright,
|
||||
"lyrics" to if (shouldEmbedLyrics) lyrics else "",
|
||||
"unsyncedlyrics" to if (shouldEmbedLyrics) lyrics else "",
|
||||
)
|
||||
if (isOpus && coverFile != null) {
|
||||
createMetadataBlockPicture(coverFile)?.let {
|
||||
metadataPairs.add("METADATA_BLOCK_PICTURE" to it)
|
||||
}
|
||||
}
|
||||
val metadataArgs = metadataPairs
|
||||
.filter { it.second.isNotBlank() && it.second != "0" }
|
||||
.joinToString(" ") { "-metadata ${it.first}=${q(it.second)}" }
|
||||
if (metadataArgs.isBlank() && coverFile == null) return
|
||||
val mp3Flags = if (format == "mp3") "-id3v2_version 3 " else ""
|
||||
var adoptedTemp = false
|
||||
var originalDeleted = false
|
||||
|
||||
fun buildEmbedCommand(forceMov: Boolean): String {
|
||||
return if (isM4a && coverFile != null) {
|
||||
"-v error -hide_banner -i ${q(path)} -i ${q(coverFile.absolutePath)} " +
|
||||
"-map 0:a -c:a copy -map_metadata 0 -map 1:v -c:v copy " +
|
||||
"-disposition:v:0 attached_pic " +
|
||||
"-metadata:s:v ${q("title=Album cover")} " +
|
||||
"-metadata:s:v ${q("comment=Cover (front)")} " +
|
||||
"$metadataArgs -f ${if (forceMov) "mov" else "mp4"} ${q(temp.absolutePath)} -y"
|
||||
} else {
|
||||
val movFlag = if (forceMov) "-f mov " else ""
|
||||
"-v error -hide_banner -i ${q(path)} -map 0 -c copy -map_metadata 0 $metadataArgs $mp3Flags$movFlag${q(temp.absolutePath)} -y"
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
var result = runFFmpeg(buildEmbedCommand(false))
|
||||
// MOV muxer fallback for codecs the MP4 muxer rejects (e.g. AC-4).
|
||||
if (!result.first && (isM4a || ext.equals(".mp4", ignoreCase = true))) {
|
||||
temp.delete()
|
||||
result = runFFmpeg(buildEmbedCommand(true))
|
||||
}
|
||||
if (result.first && temp.exists()) {
|
||||
fsyncQuietly(temp)
|
||||
// Rename directly over the original: a process kill between a
|
||||
// delete-first and the rename would lose the file entirely.
|
||||
adoptedTemp = temp.renameTo(inputFile)
|
||||
if (!adoptedTemp && inputFile.delete()) {
|
||||
originalDeleted = true
|
||||
adoptedTemp = temp.renameTo(inputFile)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!adoptedTemp && !originalDeleted) {
|
||||
temp.delete()
|
||||
}
|
||||
coverFile?.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort fsync so a file's bytes are durable before it is renamed
|
||||
* over another file; fsync on a fresh handle flushes the page cache pages
|
||||
* written earlier by ffmpeg in this process.
|
||||
*/
|
||||
internal fun NativeDownloadFinalizer.fsyncQuietly(file: File) {
|
||||
try {
|
||||
RandomAccessFile(file, "rw").use { it.fd.sync() }
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.createMetadataBlockPicture(coverFile: File): String? {
|
||||
return try {
|
||||
if (!coverFile.exists() || coverFile.length() <= 0L) return null
|
||||
val imageData = coverFile.readBytes()
|
||||
if (imageData.isEmpty()) return null
|
||||
val mimeType = detectCoverMimeType(coverFile, imageData)
|
||||
val mimeBytes = mimeType.toByteArray(Charsets.UTF_8)
|
||||
val descriptionBytes = ByteArray(0)
|
||||
val blockSize = 4 + 4 + mimeBytes.size + 4 + descriptionBytes.size + 4 + 4 + 4 + 4 + 4 + imageData.size
|
||||
val buffer = ByteBuffer.allocate(blockSize)
|
||||
buffer.putInt(3)
|
||||
buffer.putInt(mimeBytes.size)
|
||||
buffer.put(mimeBytes)
|
||||
buffer.putInt(descriptionBytes.size)
|
||||
buffer.put(descriptionBytes)
|
||||
buffer.putInt(0)
|
||||
buffer.putInt(0)
|
||||
buffer.putInt(0)
|
||||
buffer.putInt(0)
|
||||
buffer.putInt(imageData.size)
|
||||
buffer.put(imageData)
|
||||
Base64.encodeToString(buffer.array(), Base64.NO_WRAP)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to create Opus cover picture block: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.detectCoverMimeType(coverFile: File, imageData: ByteArray): String {
|
||||
val ext = coverFile.extension.lowercase(Locale.ROOT)
|
||||
if (ext == "png") return "image/png"
|
||||
if (ext == "jpg" || ext == "jpeg") return "image/jpeg"
|
||||
if (imageData.size >= 8 &&
|
||||
imageData[0] == 0x89.toByte() &&
|
||||
imageData[1] == 0x50.toByte() &&
|
||||
imageData[2] == 0x4E.toByte() &&
|
||||
imageData[3] == 0x47.toByte()
|
||||
) {
|
||||
return "image/png"
|
||||
}
|
||||
return "image/jpeg"
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.downloadCoverForMetadata(context: Context, input: NativeDownloadFinalizer.FinalizeInput): File? {
|
||||
val coverUrl = metadataCoverUrl(input).ifBlank { resultString(input, "cover_url") }
|
||||
if (coverUrl.isBlank()) return null
|
||||
|
||||
val safeItemId = input.itemId.ifBlank { "item" }.replace(Regex("[^A-Za-z0-9._-]"), "_")
|
||||
val output = File.createTempFile("native_cover_${safeItemId}_", ".jpg", context.cacheDir)
|
||||
return try {
|
||||
Gobackend.downloadCoverToFile(
|
||||
coverUrl,
|
||||
output.absolutePath,
|
||||
input.request.optBoolean("embed_max_quality_cover", true)
|
||||
)
|
||||
if (output.exists() && output.length() > 0L) {
|
||||
output
|
||||
} else {
|
||||
output.delete()
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to download metadata cover: ${e.message}")
|
||||
output.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteException
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.antonkarpenko.ffmpegkit.FFmpegKit
|
||||
import com.antonkarpenko.ffmpegkit.FFmpegKitConfig
|
||||
import com.antonkarpenko.ffmpegkit.FFmpegSession
|
||||
import com.antonkarpenko.ffmpegkit.FFmpegSessionCompleteCallback
|
||||
import com.antonkarpenko.ffmpegkit.LogRedirectionStrategy
|
||||
import com.antonkarpenko.ffmpegkit.ReturnCode
|
||||
import com.zarz.spotiflac.SafDownloadHandler.mimeTypeForExt
|
||||
import com.zarz.spotiflac.SafDownloadHandler.normalizeExt
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.applyQualityVariantFilenameLabel
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.displayAudioQuality
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.formatIndexTag
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.isLosslessAudioCodec
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.isLossyAudioCodec
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.normalizeAudioCodec
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.removeQualityVariantStagingLabel
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.resolvePreferredDecryptionExtension
|
||||
import gobackend.Gobackend
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.pow
|
||||
|
||||
|
||||
// Deferred SAF publish helpers for NativeDownloadFinalizer.
|
||||
|
||||
internal fun NativeDownloadFinalizer.promoteStagedSafOutputIfNeeded(
|
||||
context: Context,
|
||||
input: NativeDownloadFinalizer.FinalizeInput,
|
||||
state: NativeDownloadFinalizer.FinalizeState,
|
||||
) {
|
||||
if (!state.filePath.startsWith("content://")) return
|
||||
if (!input.result.optBoolean("saf_staged_output", false)) return
|
||||
val stagedName = input.result.optString("saf_staged_file_name", "").trim()
|
||||
if (stagedName.isNotEmpty() && state.fileName != stagedName) return
|
||||
|
||||
val localInput = materializeForFFmpeg(context, input, state)
|
||||
try {
|
||||
replaceStatePath(context, input, state, localInput, deleteOld = true)
|
||||
} finally {
|
||||
File(localInput).delete()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.isDeferredSafPublish(input: NativeDownloadFinalizer.FinalizeInput): Boolean {
|
||||
return input.request.optBoolean("defer_saf_publish", false) &&
|
||||
input.result.optBoolean("saf_deferred_publish", false)
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.isDeferredSafRequest(input: NativeDownloadFinalizer.FinalizeInput): Boolean {
|
||||
return input.request.optString("storage_mode", "") == "saf" &&
|
||||
input.request.optBoolean("defer_saf_publish", false)
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.publishDeferredSafOutput(
|
||||
context: Context,
|
||||
input: NativeDownloadFinalizer.FinalizeInput,
|
||||
state: NativeDownloadFinalizer.FinalizeState,
|
||||
) {
|
||||
if (!isDeferredSafPublish(input)) return
|
||||
if (state.filePath.startsWith("content://")) return
|
||||
|
||||
val outputFile = File(state.filePath)
|
||||
if (!outputFile.exists() || outputFile.length() <= 0L) {
|
||||
throw IllegalStateException("deferred SAF output missing or empty")
|
||||
}
|
||||
|
||||
val finalName = desiredFileName(input, state, outputFile.extension)
|
||||
val treeUri = input.result.optString("saf_tree_uri", "")
|
||||
.ifBlank { input.request.optString("saf_tree_uri", "") }
|
||||
val relativeDir = input.result.optString("saf_relative_dir", "")
|
||||
.ifBlank { input.request.optString("saf_relative_dir", "") }
|
||||
val mimeType = mimeTypeForExt(outputFile.extension)
|
||||
val preserveQualityVariant = input.request.optBoolean("allow_quality_variant", false)
|
||||
val qualityLabel = qualityVariantFilenameLabel(state).orEmpty()
|
||||
val collisionOnly = preserveQualityVariant &&
|
||||
input.request.optBoolean("quality_variant_collision_only", false)
|
||||
val stagingLabel = input.request.optString("quality_variant", "").trim()
|
||||
val logicalVariantName = input.request.optString("saf_file_name", "")
|
||||
.ifBlank { finalName }
|
||||
val cleanName = removeQualityVariantStagingLabel(logicalVariantName, stagingLabel)
|
||||
val variantName = if (qualityLabel.isNotEmpty()) {
|
||||
applyQualityVariantFilenameLabel(logicalVariantName, stagingLabel, qualityLabel)
|
||||
} else {
|
||||
finalName
|
||||
}
|
||||
|
||||
var alreadyExists = false
|
||||
val published = when {
|
||||
collisionOnly -> SafDownloadHandler.writeFileToSafCollisionAware(
|
||||
context = context,
|
||||
treeUriStr = treeUri,
|
||||
relativeDir = relativeDir,
|
||||
cleanFileName = cleanName,
|
||||
variantFileName = variantName,
|
||||
mimeType = mimeType,
|
||||
srcPath = outputFile.absolutePath,
|
||||
preservedSuffix = qualityLabel,
|
||||
)
|
||||
preserveQualityVariant -> SafDownloadHandler.writeFileToSafUnique(
|
||||
context = context,
|
||||
treeUriStr = treeUri,
|
||||
relativeDir = relativeDir,
|
||||
fileName = finalName,
|
||||
mimeType = mimeType,
|
||||
srcPath = outputFile.absolutePath,
|
||||
preservedSuffix = qualityLabel,
|
||||
)
|
||||
else -> SafDownloadHandler.writeFileToSafIfAbsent(
|
||||
context = context,
|
||||
treeUriStr = treeUri,
|
||||
relativeDir = relativeDir,
|
||||
fileName = finalName,
|
||||
mimeType = mimeType,
|
||||
srcPath = outputFile.absolutePath,
|
||||
)?.let { result ->
|
||||
alreadyExists = result.alreadyExists
|
||||
SafDownloadHandler.UniqueWriteResult(result.uri, result.fileName)
|
||||
}
|
||||
} ?: throw IllegalStateException("failed to publish deferred SAF output")
|
||||
val newUri = published.uri
|
||||
val publishedName = published.fileName
|
||||
|
||||
Log.i(TAG, "Published deferred SAF output once: file=$publishedName bytes=${outputFile.length()}")
|
||||
outputFile.delete()
|
||||
state.filePath = newUri
|
||||
state.fileName = publishedName
|
||||
input.result.put("file_path", newUri)
|
||||
input.result.put("file_name", publishedName)
|
||||
if (alreadyExists) {
|
||||
input.result.put("already_exists", true)
|
||||
input.result.put("message", "File already exists")
|
||||
input.result.put("publish_collision_existing", true)
|
||||
}
|
||||
input.result.optJSONObject("replaygain")?.let { replayGain ->
|
||||
replayGain.put("file_path", newUri)
|
||||
replayGain.put("file_name", publishedName)
|
||||
}
|
||||
if (state.pendingExternalLrc != null) {
|
||||
state.pendingExternalLrcFileName = "${publishedName.replace(Regex("\\.[^.]+$"), "")}.lrc"
|
||||
}
|
||||
input.result.put("saf_deferred_published", true)
|
||||
publishPendingDeferredExternalLrc(context, input, state)
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.publishPendingDeferredExternalLrc(
|
||||
context: Context,
|
||||
input: NativeDownloadFinalizer.FinalizeInput,
|
||||
state: NativeDownloadFinalizer.FinalizeState,
|
||||
) {
|
||||
val lrc = state.pendingExternalLrc ?: return
|
||||
val fileName = state.pendingExternalLrcFileName ?: return
|
||||
val treeUri = input.result.optString("saf_tree_uri", "")
|
||||
.ifBlank { input.request.optString("saf_tree_uri", "") }
|
||||
val relativeDir = input.result.optString("saf_relative_dir", "")
|
||||
.ifBlank { input.request.optString("saf_relative_dir", "") }
|
||||
val temp = File(context.cacheDir, "native_lrc_${System.nanoTime()}.lrc")
|
||||
try {
|
||||
temp.writeText(lrc)
|
||||
val newUri = SafDownloadHandler.writeFileToSaf(
|
||||
context = context,
|
||||
treeUriStr = treeUri,
|
||||
relativeDir = relativeDir,
|
||||
fileName = fileName,
|
||||
mimeType = "application/octet-stream",
|
||||
srcPath = temp.absolutePath,
|
||||
)
|
||||
if (newUri == null) {
|
||||
Log.w(TAG, "Failed to publish deferred external LRC: $fileName")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to publish deferred external LRC: ${e.message}")
|
||||
} finally {
|
||||
temp.delete()
|
||||
state.pendingExternalLrc = null
|
||||
state.pendingExternalLrcFileName = null
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
/**
|
||||
* Pure album ReplayGain completion policy used by DownloadService.
|
||||
*/
|
||||
internal object NativeReplayGainPolicy {
|
||||
private val blockingStatuses = setOf(
|
||||
"failed",
|
||||
"skipped",
|
||||
"queued",
|
||||
"downloading",
|
||||
"finalizing",
|
||||
)
|
||||
private val pendingStatuses = setOf("queued", "downloading", "finalizing")
|
||||
|
||||
fun eligibleEntryIndexes(
|
||||
entryAlbumKeys: List<String>,
|
||||
statuses: Map<String, String>,
|
||||
requestAlbumKeys: Map<String, String>,
|
||||
): List<Int> {
|
||||
val blockedKeys = mutableSetOf<String>()
|
||||
val expectedCompletedByKey = mutableMapOf<String, Int>()
|
||||
for ((itemId, albumKey) in requestAlbumKeys) {
|
||||
when (statuses[itemId]) {
|
||||
"completed" -> {
|
||||
expectedCompletedByKey[albumKey] =
|
||||
(expectedCompletedByKey[albumKey] ?: 0) + 1
|
||||
}
|
||||
in blockingStatuses -> blockedKeys.add(albumKey)
|
||||
}
|
||||
}
|
||||
|
||||
val indexesByKey = entryAlbumKeys.indices.groupBy { entryAlbumKeys[it] }
|
||||
val eligible = mutableListOf<Int>()
|
||||
for ((albumKey, indexes) in indexesByKey) {
|
||||
if (
|
||||
albumKey.isBlank() ||
|
||||
albumKey in blockedKeys ||
|
||||
indexes.size <= 1
|
||||
) {
|
||||
continue
|
||||
}
|
||||
val expected = expectedCompletedByKey[albumKey] ?: continue
|
||||
if (indexes.size == expected) {
|
||||
eligible.addAll(indexes)
|
||||
}
|
||||
}
|
||||
return eligible
|
||||
}
|
||||
|
||||
fun hasPendingWork(statuses: Map<String, String>): Boolean =
|
||||
statuses.values.any { it in pendingStatuses }
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
internal object NativeWorkerPolicy {
|
||||
const val MAX_RATE_LIMIT_RETRIES = 1
|
||||
private const val DEFAULT_RATE_LIMIT_DELAY_SECONDS = 30
|
||||
private const val MIN_RATE_LIMIT_DELAY_SECONDS = 5
|
||||
private const val MAX_RATE_LIMIT_DELAY_SECONDS = 300
|
||||
|
||||
private val retryAfterPattern = Regex(
|
||||
"""retry[- ]?after(?: seconds)?[:= ]+(\d+)""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
)
|
||||
|
||||
fun shouldRetryRateLimit(
|
||||
errorType: String?,
|
||||
errorMessage: String?,
|
||||
attempts: Int,
|
||||
): Boolean {
|
||||
if (attempts >= MAX_RATE_LIMIT_RETRIES) return false
|
||||
if (errorType.equals("rate_limit", ignoreCase = true)) return true
|
||||
|
||||
val message = errorMessage.orEmpty()
|
||||
return message.contains("429") ||
|
||||
message.contains("rate limit", ignoreCase = true) ||
|
||||
message.contains("too many requests", ignoreCase = true)
|
||||
}
|
||||
|
||||
fun rateLimitDelaySeconds(
|
||||
retryAfterSeconds: Int?,
|
||||
errorMessage: String?,
|
||||
): Int {
|
||||
val parsedFromMessage = retryAfterPattern
|
||||
.find(errorMessage.orEmpty())
|
||||
?.groupValues
|
||||
?.getOrNull(1)
|
||||
?.toIntOrNull()
|
||||
return (retryAfterSeconds?.takeIf { it > 0 }
|
||||
?: parsedFromMessage
|
||||
?: DEFAULT_RATE_LIMIT_DELAY_SECONDS)
|
||||
.coerceIn(
|
||||
MIN_RATE_LIMIT_DELAY_SECONDS,
|
||||
MAX_RATE_LIMIT_DELAY_SECONDS,
|
||||
)
|
||||
}
|
||||
|
||||
fun isVerificationRequired(
|
||||
errorType: String?,
|
||||
errorMessage: String?,
|
||||
): Boolean {
|
||||
if (errorType.equals("verification_required", ignoreCase = true)) {
|
||||
return true
|
||||
}
|
||||
val message = errorMessage.orEmpty()
|
||||
return message.contains("verification required", ignoreCase = true) ||
|
||||
message.contains("challenge required", ignoreCase = true)
|
||||
}
|
||||
|
||||
fun requiresWifi(downloadNetworkMode: String?): Boolean =
|
||||
downloadNetworkMode.equals("wifi_only", ignoreCase = true)
|
||||
|
||||
fun shouldPauseForNetwork(
|
||||
downloadNetworkMode: String?,
|
||||
hasWifi: Boolean,
|
||||
): Boolean = requiresWifi(downloadNetworkMode) && !hasWifi
|
||||
|
||||
fun shouldNotifyQueueComplete(
|
||||
cancelRequested: Boolean,
|
||||
completed: Int,
|
||||
failed: Int,
|
||||
): Boolean = !cancelRequested && completed + failed > 0
|
||||
}
|
||||
@@ -1,793 +0,0 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Shared SAF download wrapper for foreground activity calls and service-owned
|
||||
* native workers.
|
||||
*/
|
||||
object SafDownloadHandler {
|
||||
private val safDirLock = Any()
|
||||
private const val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180
|
||||
private const val STAGED_SAF_MIME_TYPE = "application/octet-stream"
|
||||
|
||||
// Serializes the exists-check/create/write/publish sequence per target
|
||||
// file so concurrent downloads that sanitize to the same display name
|
||||
// cannot interleave writes into one document. Different names keep
|
||||
// downloading in parallel; the second same-name caller blocks, then hits
|
||||
// the exists check and reports already_exists.
|
||||
private val safNameLocks = java.util.concurrent.ConcurrentHashMap<String, Any>()
|
||||
|
||||
data class UniqueWriteResult(val uri: String, val fileName: String)
|
||||
data class ExistingAwareWriteResult(
|
||||
val uri: String,
|
||||
val fileName: String,
|
||||
val alreadyExists: Boolean,
|
||||
)
|
||||
|
||||
private fun <T> withSafNameLock(
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
fileName: String,
|
||||
block: () -> T
|
||||
): T {
|
||||
val key = "$treeUriStr|$relativeDir|${fileName.lowercase(Locale.ROOT)}"
|
||||
val lock = safNameLocks.computeIfAbsent(key) { Any() }
|
||||
return synchronized(lock) { block() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes and fsyncs a SAF output stream so the copied bytes are durable
|
||||
* before the staged document is renamed to its final name; without this a
|
||||
* power loss right after the rename can leave a truncated "complete" file.
|
||||
*/
|
||||
fun syncOutputStream(output: OutputStream) {
|
||||
try {
|
||||
output.flush()
|
||||
(output as? FileOutputStream)?.fd?.sync()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
fun handle(context: Context, requestJson: String, downloader: (String) -> String): String {
|
||||
val req = JSONObject(requestJson)
|
||||
val storageMode = req.optString("storage_mode", "")
|
||||
val treeUriStr = req.optString("saf_tree_uri", "")
|
||||
if (storageMode != "saf" || treeUriStr.isBlank()) {
|
||||
return downloader(requestJson)
|
||||
}
|
||||
|
||||
val relativeDir = sanitizeRelativeDir(req.optString("saf_relative_dir", ""))
|
||||
val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
|
||||
val fileName = buildSafFileName(req, outputExt)
|
||||
return withSafNameLock(treeUriStr, relativeDir, fileName) {
|
||||
handleSafLocked(context, req, downloader, treeUriStr, relativeDir, outputExt, fileName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSafLocked(
|
||||
context: Context,
|
||||
req: JSONObject,
|
||||
downloader: (String) -> String,
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
outputExt: String,
|
||||
fileName: String
|
||||
): String {
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val mimeType = mimeTypeForExt(outputExt)
|
||||
val deferSafPublish = req.optBoolean("defer_saf_publish", false)
|
||||
// Downloads are always written under a staged ".partial" name so a
|
||||
// killed process can never leave a half-written file under the final
|
||||
// name (which the exists check would then accept as complete forever).
|
||||
// Callers that set stage_saf_output own the promotion to the final
|
||||
// name (the native finalizer); for everyone else — the foreground
|
||||
// Dart queue — this handler promotes right after a successful write.
|
||||
val finalizerPromotesStaged = req.optBoolean("stage_saf_output", false) && !deferSafPublish
|
||||
val useStagedOutput = !deferSafPublish
|
||||
val stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName) else fileName
|
||||
val stagedMimeType = if (useStagedOutput) STAGED_SAF_MIME_TYPE else mimeType
|
||||
|
||||
val existingDir = findDocumentDir(context, treeUri, relativeDir)
|
||||
if (existingDir != null) {
|
||||
val existing = existingDir.findFile(fileName)
|
||||
if (existing != null && existing.isFile && existing.length() > 0) {
|
||||
deleteStaleStagedFiles(existingDir, fileName, outputExt)
|
||||
val obj = JSONObject()
|
||||
obj.put("success", true)
|
||||
obj.put("message", "File already exists")
|
||||
obj.put("file_path", existing.uri.toString())
|
||||
obj.put("file_name", existing.name ?: fileName)
|
||||
obj.put("already_exists", true)
|
||||
return obj.toString()
|
||||
}
|
||||
}
|
||||
|
||||
val targetDir = ensureDocumentDir(context, treeUri, relativeDir)
|
||||
?: return errorJson("Failed to access SAF directory")
|
||||
|
||||
if (deferSafPublish) {
|
||||
deleteStaleStagedFiles(targetDir, fileName, outputExt)
|
||||
val workingExt = outputExt.ifBlank { ".tmp" }
|
||||
val workingFile = File.createTempFile("native_saf_work_", workingExt, context.cacheDir)
|
||||
Log.i("SpotiFLAC", "SAF deferred native output: target=$fileName working=${workingFile.name}")
|
||||
return try {
|
||||
req.put("output_path", workingFile.absolutePath)
|
||||
req.put("output_ext", outputExt)
|
||||
req.remove("output_fd")
|
||||
val response = downloader(req.toString())
|
||||
val respObj = JSONObject(response)
|
||||
if (respObj.optBoolean("success", false)) {
|
||||
val reportedPath = respObj.optString("file_path", "").trim()
|
||||
if (reportedPath.isEmpty() || reportedPath.startsWith("/proc/self/fd/")) {
|
||||
respObj.put("file_path", workingFile.absolutePath)
|
||||
} else if (reportedPath != workingFile.absolutePath) {
|
||||
workingFile.delete()
|
||||
}
|
||||
respObj.put("file_name", respObj.optString("file_name", "").ifBlank { fileName })
|
||||
respObj.put("saf_deferred_publish", true)
|
||||
respObj.put("saf_final_file_name", fileName)
|
||||
respObj.put("saf_relative_dir", relativeDir)
|
||||
respObj.put("saf_tree_uri", treeUriStr)
|
||||
respObj.put("saf_output_ext", outputExt)
|
||||
respObj.put("saf_final_mime_type", mimeType)
|
||||
} else {
|
||||
workingFile.delete()
|
||||
}
|
||||
respObj.toString()
|
||||
} catch (e: Exception) {
|
||||
workingFile.delete()
|
||||
errorJson("SAF deferred download failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Remove any stale partial from a previous killed attempt before
|
||||
// creating the staged document: reusing it would let a shorter new
|
||||
// write leave the old tail bytes in place (fd truncation is
|
||||
// best-effort on some providers).
|
||||
deleteStaleStagedFiles(targetDir, fileName, outputExt)
|
||||
var document = createOrReuseDocumentFile(targetDir, stagedMimeType, stagedFileName)
|
||||
?: return errorJson("Failed to create SAF file")
|
||||
|
||||
val pfd = context.contentResolver.openFileDescriptor(document.uri, "rw")
|
||||
?: return errorJson("Failed to open SAF file")
|
||||
|
||||
var detachedFd: Int? = null
|
||||
try {
|
||||
detachedFd = pfd.detachFd()
|
||||
req.put("output_path", "")
|
||||
req.put("output_fd", detachedFd)
|
||||
req.put("output_ext", outputExt)
|
||||
val response = downloader(req.toString())
|
||||
val respObj = JSONObject(response)
|
||||
if (respObj.optBoolean("success", false)) {
|
||||
var finalFileName = fileName
|
||||
val goFilePath = respObj.optString("file_path", "")
|
||||
if (goFilePath.isNotEmpty() &&
|
||||
!goFilePath.startsWith("content://") &&
|
||||
!goFilePath.startsWith("/proc/self/fd/")
|
||||
) {
|
||||
try {
|
||||
val srcFile = File(goFilePath)
|
||||
if (!srcFile.exists() || srcFile.length() <= 0) {
|
||||
throw IllegalStateException("extension output missing or empty: $goFilePath")
|
||||
}
|
||||
val actualExt = normalizeExt(srcFile.extension)
|
||||
if (actualExt.isNotBlank()) {
|
||||
respObj.put("actual_extension", actualExt)
|
||||
}
|
||||
if (actualExt.isNotBlank() && actualExt != outputExt) {
|
||||
val actualFileName = buildSafFileName(req, actualExt)
|
||||
val actualStagedFileName = if (useStagedOutput) {
|
||||
buildStagedSafFileName(actualFileName)
|
||||
} else {
|
||||
actualFileName
|
||||
}
|
||||
val actualMimeType = mimeTypeForExt(actualExt)
|
||||
val replacement = createOrReuseDocumentFile(
|
||||
targetDir,
|
||||
if (useStagedOutput) STAGED_SAF_MIME_TYPE else actualMimeType,
|
||||
actualStagedFileName
|
||||
) ?: throw IllegalStateException(
|
||||
"failed to create SAF output with actual extension"
|
||||
)
|
||||
if (replacement.uri != document.uri) {
|
||||
document.delete()
|
||||
document = replacement
|
||||
}
|
||||
finalFileName = actualFileName
|
||||
}
|
||||
context.contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
|
||||
srcFile.inputStream().use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
syncOutputStream(output)
|
||||
} ?: throw IllegalStateException("failed to open SAF output stream")
|
||||
srcFile.delete()
|
||||
} catch (e: Exception) {
|
||||
document.delete()
|
||||
android.util.Log.w(
|
||||
"SpotiFLAC",
|
||||
"Failed to copy extension output to SAF: ${e.message}"
|
||||
)
|
||||
return errorJson("Failed to copy extension output to SAF: ${e.message}")
|
||||
}
|
||||
}
|
||||
respObj.put("file_path", document.uri.toString())
|
||||
respObj.put("file_name", document.name ?: fileName)
|
||||
if (finalizerPromotesStaged) {
|
||||
respObj.put("saf_staged_output", true)
|
||||
respObj.put("saf_staged_file_name", document.name ?: stagedFileName)
|
||||
} else if (useStagedOutput) {
|
||||
// Legacy caller (foreground Dart queue): publish here by
|
||||
// renaming the staged file to its final name.
|
||||
val published = replaceFinalDocument(targetDir, document, finalFileName)
|
||||
if (published == null) {
|
||||
document.delete()
|
||||
return errorJson("Failed to publish SAF download")
|
||||
}
|
||||
respObj.put("file_path", published.uri.toString())
|
||||
respObj.put("file_name", published.name ?: finalFileName)
|
||||
}
|
||||
} else {
|
||||
document.delete()
|
||||
}
|
||||
return respObj.toString()
|
||||
} catch (e: Exception) {
|
||||
document.delete()
|
||||
return errorJson("SAF download failed: ${e.message}")
|
||||
} finally {
|
||||
if (detachedFd == null) {
|
||||
try {
|
||||
pfd.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps [document] into place under [finalName] without a window where the
|
||||
* previous file is gone but the new one is not yet in place. Any existing
|
||||
* file is renamed aside first and only deleted once the staged file holds
|
||||
* the final name; a failed swap restores it. Returns the published
|
||||
* document, or null when the swap failed (the caller owns [document]).
|
||||
*/
|
||||
private fun replaceFinalDocument(
|
||||
targetDir: DocumentFile,
|
||||
document: DocumentFile,
|
||||
finalName: String
|
||||
): DocumentFile? {
|
||||
val existingFinal = targetDir.findFile(finalName)
|
||||
var aside: DocumentFile? = null
|
||||
if (existingFinal != null && existingFinal.uri != document.uri) {
|
||||
val asideName = buildReplacedSafFileName(finalName)
|
||||
try {
|
||||
targetDir.findFile(asideName)?.delete()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
if (!existingFinal.renameTo(asideName)) {
|
||||
return null
|
||||
}
|
||||
aside = targetDir.findFile(asideName) ?: existingFinal
|
||||
}
|
||||
if (!document.renameTo(finalName)) {
|
||||
aside?.renameTo(finalName)
|
||||
return null
|
||||
}
|
||||
aside?.delete()
|
||||
return targetDir.findFile(finalName) ?: document
|
||||
}
|
||||
|
||||
private fun buildReplacedSafFileName(fileName: String): String {
|
||||
return "${sanitizeFilename(fileName)}.replaced"
|
||||
}
|
||||
|
||||
fun copyContentUriToTemp(context: Context, uriStr: String): String? {
|
||||
return try {
|
||||
val uri = Uri.parse(uriStr)
|
||||
val extension = DocumentFile.fromSingleUri(context, uri)
|
||||
?.name
|
||||
?.substringAfterLast('.', "")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { ".$it" }
|
||||
?: ".tmp"
|
||||
val temp = File.createTempFile("native_saf_", extension, context.cacheDir)
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
temp.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} ?: return null
|
||||
temp.absolutePath
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SpotiFLAC", "Failed to copy SAF URI to temp: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun writeFileToSaf(
|
||||
context: Context,
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
srcPath: String
|
||||
): String? {
|
||||
val finalName = sanitizeFilename(fileName)
|
||||
return withSafNameLock(treeUriStr, sanitizeRelativeDir(relativeDir), finalName) {
|
||||
writeFileToSafLocked(context, treeUriStr, relativeDir, finalName, srcPath)
|
||||
}
|
||||
}
|
||||
|
||||
fun writeFileToSafUnique(
|
||||
context: Context,
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
srcPath: String,
|
||||
preservedSuffix: String = "",
|
||||
): UniqueWriteResult? {
|
||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||
val preferredName = sanitizeFilenamePreservingSuffix(fileName, preservedSuffix)
|
||||
return withSafNameLock(treeUriStr, safeRelativeDir, preferredName) {
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val targetDir = ensureDocumentDir(context, treeUri, safeRelativeDir) ?: return@withSafNameLock null
|
||||
val availableName = findAvailableFileName(
|
||||
targetDir,
|
||||
preferredName,
|
||||
preservedSuffix,
|
||||
)
|
||||
val uri = writeFileToSafLocked(
|
||||
context,
|
||||
treeUriStr,
|
||||
safeRelativeDir,
|
||||
availableName,
|
||||
srcPath,
|
||||
) ?: return@withSafNameLock null
|
||||
UniqueWriteResult(uri = uri, fileName = availableName)
|
||||
}
|
||||
}
|
||||
|
||||
fun writeFileToSafCollisionAware(
|
||||
context: Context,
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
cleanFileName: String,
|
||||
variantFileName: String,
|
||||
mimeType: String,
|
||||
srcPath: String,
|
||||
preservedSuffix: String = "",
|
||||
): UniqueWriteResult? {
|
||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||
val cleanName = sanitizeFilename(cleanFileName)
|
||||
val preferredVariant = sanitizeFilenamePreservingSuffix(
|
||||
variantFileName,
|
||||
preservedSuffix,
|
||||
)
|
||||
return withSafNameLock(treeUriStr, safeRelativeDir, cleanName) {
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val targetDir = ensureDocumentDir(context, treeUri, safeRelativeDir)
|
||||
?: return@withSafNameLock null
|
||||
val selectedName = if (targetDir.findFile(cleanName) == null) {
|
||||
cleanName
|
||||
} else {
|
||||
findAvailableFileName(targetDir, preferredVariant, preservedSuffix)
|
||||
}
|
||||
val uri = writeFileToSafLocked(
|
||||
context,
|
||||
treeUriStr,
|
||||
safeRelativeDir,
|
||||
selectedName,
|
||||
srcPath,
|
||||
) ?: return@withSafNameLock null
|
||||
UniqueWriteResult(uri = uri, fileName = selectedName)
|
||||
}
|
||||
}
|
||||
|
||||
fun writeFileToSafIfAbsent(
|
||||
context: Context,
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
srcPath: String,
|
||||
): ExistingAwareWriteResult? {
|
||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||
val finalName = sanitizeFilename(fileName)
|
||||
return withSafNameLock(treeUriStr, safeRelativeDir, finalName) {
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val targetDir = ensureDocumentDir(context, treeUri, safeRelativeDir)
|
||||
?: return@withSafNameLock null
|
||||
val existing = targetDir.findFile(finalName)
|
||||
if (existing != null && existing.isFile && existing.length() > 0L) {
|
||||
return@withSafNameLock ExistingAwareWriteResult(
|
||||
uri = existing.uri.toString(),
|
||||
fileName = existing.name ?: finalName,
|
||||
alreadyExists = true,
|
||||
)
|
||||
}
|
||||
val uri = writeFileToSafLocked(
|
||||
context,
|
||||
treeUriStr,
|
||||
safeRelativeDir,
|
||||
finalName,
|
||||
srcPath,
|
||||
) ?: return@withSafNameLock null
|
||||
ExistingAwareWriteResult(
|
||||
uri = uri,
|
||||
fileName = finalName,
|
||||
alreadyExists = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sanitizeFilenamePreservingSuffix(fileName: String, suffix: String): String {
|
||||
val sanitized = sanitizeFilename(fileName)
|
||||
val trimmedSuffix = suffix.trim()
|
||||
if (trimmedSuffix.isEmpty() || sanitized.contains(trimmedSuffix)) return sanitized
|
||||
|
||||
val dotIndex = fileName.lastIndexOf('.')
|
||||
val hasExtension = dotIndex > 0 && dotIndex < fileName.length - 1
|
||||
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
|
||||
val rawStem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
|
||||
val rawPrefix = rawStem.replace(trimmedSuffix, "").trim(' ', '_', '-')
|
||||
val safeSuffix = sanitizeFilename(trimmedSuffix)
|
||||
val reserved = " - $safeSuffix$extension"
|
||||
val prefixBytes = (MAX_SAF_DISPLAY_NAME_UTF8_BYTES - reserved.toByteArray(Charsets.UTF_8).size)
|
||||
.coerceAtLeast(1)
|
||||
val safePrefix = truncateUtf8Bytes(sanitizeFilename(rawPrefix), prefixBytes)
|
||||
.trim()
|
||||
.trim('.', ' ', '_', '-')
|
||||
.ifBlank { "track" }
|
||||
return "$safePrefix$reserved"
|
||||
}
|
||||
|
||||
private fun findAvailableFileName(
|
||||
parent: DocumentFile,
|
||||
preferredName: String,
|
||||
preservedSuffix: String,
|
||||
): String {
|
||||
if (parent.findFile(preferredName) == null) return preferredName
|
||||
for (counter in 2..9999) {
|
||||
val candidate = appendFilenameCounter(
|
||||
preferredName,
|
||||
counter.toLong(),
|
||||
preservedSuffix,
|
||||
)
|
||||
if (parent.findFile(candidate) == null) return candidate
|
||||
}
|
||||
return appendFilenameCounter(
|
||||
preferredName,
|
||||
System.currentTimeMillis(),
|
||||
preservedSuffix,
|
||||
)
|
||||
}
|
||||
|
||||
private fun appendFilenameCounter(
|
||||
fileName: String,
|
||||
counter: Long,
|
||||
preservedSuffix: String,
|
||||
): String {
|
||||
val dotIndex = fileName.lastIndexOf('.')
|
||||
val hasExtension = dotIndex > 0 && dotIndex < fileName.length - 1
|
||||
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
|
||||
val originalStem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
|
||||
val safePreservedSuffix = preservedSuffix.trim()
|
||||
val hasPreservedSuffix = safePreservedSuffix.isNotEmpty() && originalStem.contains(safePreservedSuffix)
|
||||
val stem = if (hasPreservedSuffix) {
|
||||
originalStem.replace(safePreservedSuffix, "").trim(' ', '_', '-')
|
||||
} else {
|
||||
originalStem
|
||||
}
|
||||
val suffix = if (hasPreservedSuffix) {
|
||||
" - $safePreservedSuffix ($counter)"
|
||||
} else {
|
||||
" ($counter)"
|
||||
}
|
||||
val reservedBytes = extension.toByteArray(Charsets.UTF_8).size +
|
||||
suffix.toByteArray(Charsets.UTF_8).size
|
||||
val maxStemBytes = (MAX_SAF_DISPLAY_NAME_UTF8_BYTES - reservedBytes).coerceAtLeast(1)
|
||||
val safeStem = truncateUtf8Bytes(stem, maxStemBytes).trim().trim('.', ' ').ifBlank { "track" }
|
||||
return "$safeStem$suffix$extension"
|
||||
}
|
||||
|
||||
private fun writeFileToSafLocked(
|
||||
context: Context,
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
finalName: String,
|
||||
srcPath: String
|
||||
): String? {
|
||||
var stagedDocument: DocumentFile? = null
|
||||
return try {
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val targetDir = ensureDocumentDir(context, treeUri, relativeDir) ?: return null
|
||||
val ext = normalizeExt(finalName.substringAfterLast('.', ""))
|
||||
val stagedName = buildStagedSafFileName(finalName)
|
||||
deleteStaleStagedFiles(targetDir, finalName, ext)
|
||||
val document = createOrReuseDocumentFile(targetDir, STAGED_SAF_MIME_TYPE, stagedName)
|
||||
?: return null
|
||||
stagedDocument = document
|
||||
val outputStream = context.contentResolver.openOutputStream(document.uri, "wt")
|
||||
if (outputStream == null) {
|
||||
document.delete()
|
||||
stagedDocument = null
|
||||
return null
|
||||
}
|
||||
outputStream.use { output ->
|
||||
File(srcPath).inputStream().use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
syncOutputStream(output)
|
||||
}
|
||||
|
||||
val published = replaceFinalDocument(targetDir, document, finalName)
|
||||
if (published == null) {
|
||||
document.delete()
|
||||
return null
|
||||
}
|
||||
stagedDocument = null
|
||||
published.uri.toString()
|
||||
} catch (e: Exception) {
|
||||
stagedDocument?.delete()
|
||||
android.util.Log.w("SpotiFLAC", "Failed to write file to SAF: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteContentUri(context: Context, uriStr: String): Boolean {
|
||||
return try {
|
||||
DocumentFile.fromSingleUri(context, Uri.parse(uriStr))?.delete() == true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
internal fun normalizeExt(ext: String?): String {
|
||||
val trimmed = ext?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return ""
|
||||
return if (trimmed.startsWith(".")) trimmed.lowercase(Locale.ROOT) else ".${trimmed.lowercase(Locale.ROOT)}"
|
||||
}
|
||||
|
||||
internal fun mimeTypeForExt(ext: String?): String {
|
||||
return when (normalizeExt(ext)) {
|
||||
".m4a", ".mp4" -> "audio/mp4"
|
||||
".mp3" -> "audio/mpeg"
|
||||
".opus", ".ogg" -> "audio/ogg"
|
||||
".flac" -> "audio/flac"
|
||||
".wav" -> "audio/wav"
|
||||
".aiff", ".aif", ".aifc" -> "audio/aiff"
|
||||
".lrc" -> "application/octet-stream"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
private fun forceFilenameExt(name: String, outputExt: String): String {
|
||||
val normalizedExt = normalizeExt(outputExt)
|
||||
if (normalizedExt.isBlank()) return sanitizeFilename(name)
|
||||
|
||||
val safeName = sanitizeFilename(name)
|
||||
val lower = safeName.lowercase(Locale.ROOT)
|
||||
val knownExts = listOf(".flac", ".m4a", ".mp4", ".mp3", ".opus", ".lrc")
|
||||
for (knownExt in knownExts) {
|
||||
if (lower.endsWith(knownExt)) {
|
||||
return safeName.dropLast(knownExt.length) + normalizedExt
|
||||
}
|
||||
}
|
||||
return safeName + normalizedExt
|
||||
}
|
||||
|
||||
private fun buildStagedSafFileName(fileName: String): String {
|
||||
val safeName = sanitizeFilename(fileName)
|
||||
return "$safeName.partial"
|
||||
}
|
||||
|
||||
private fun buildLegacyStagedSafFileName(fileName: String, outputExt: String): String {
|
||||
val safeName = sanitizeFilename(fileName)
|
||||
val ext = normalizeExt(outputExt)
|
||||
if (ext.isNotBlank() && safeName.lowercase(Locale.ROOT).endsWith(ext)) {
|
||||
return safeName.dropLast(ext.length).trimEnd('.', ' ') + ".partial$ext"
|
||||
}
|
||||
val dot = safeName.lastIndexOf('.')
|
||||
if (dot > 0 && dot < safeName.lastIndex) {
|
||||
return safeName.substring(0, dot).trimEnd('.', ' ') +
|
||||
".partial" +
|
||||
safeName.substring(dot)
|
||||
}
|
||||
return "$safeName.partial"
|
||||
}
|
||||
|
||||
private fun deleteStaleStagedFiles(parent: DocumentFile, fileName: String, outputExt: String) {
|
||||
val stagedNames = linkedSetOf(
|
||||
buildStagedSafFileName(fileName),
|
||||
buildLegacyStagedSafFileName(fileName, outputExt),
|
||||
buildReplacedSafFileName(fileName)
|
||||
)
|
||||
for (stagedName in stagedNames) {
|
||||
try {
|
||||
parent.findFile(stagedName)?.delete()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun sanitizeFilename(name: String): String {
|
||||
var sanitized = name
|
||||
.replace("/", " ")
|
||||
.replace(Regex("[\\\\:*?\"<>|]"), " ")
|
||||
.filter { ch ->
|
||||
val code = ch.code
|
||||
!((code < 0x20 && ch != '\t' && ch != '\n' && ch != '\r') ||
|
||||
code == 0x7F ||
|
||||
(Character.isISOControl(ch) && ch != '\t' && ch != '\n' && ch != '\r'))
|
||||
}
|
||||
.trim()
|
||||
.trim('.', ' ')
|
||||
|
||||
sanitized = sanitized
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.replace(Regex("_+"), "_")
|
||||
.trim('_', ' ')
|
||||
|
||||
sanitized = truncateSafDisplayName(sanitized, MAX_SAF_DISPLAY_NAME_UTF8_BYTES)
|
||||
sanitized = sanitized.trim().trim('.', ' ').trim('_', ' ')
|
||||
return if (sanitized.isBlank()) "Unknown" else sanitized
|
||||
}
|
||||
|
||||
private fun truncateSafDisplayName(name: String, maxBytes: Int): String {
|
||||
if (maxBytes <= 0 || name.toByteArray(Charsets.UTF_8).size <= maxBytes) return name
|
||||
|
||||
val dotIndex = name.lastIndexOf('.')
|
||||
val ext = if (
|
||||
dotIndex > 0 &&
|
||||
dotIndex < name.length - 1 &&
|
||||
name.length - dotIndex <= 10
|
||||
) {
|
||||
name.substring(dotIndex)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val stem = if (ext.isNotEmpty()) name.substring(0, dotIndex) else name
|
||||
val maxStemBytes = (maxBytes - ext.toByteArray(Charsets.UTF_8).size).coerceAtLeast(1)
|
||||
return truncateUtf8Bytes(stem, maxStemBytes).trim().trim('.', ' ').trim('_', ' ') + ext
|
||||
}
|
||||
|
||||
private fun truncateUtf8Bytes(value: String, maxBytes: Int): String {
|
||||
if (maxBytes <= 0 || value.toByteArray(Charsets.UTF_8).size <= maxBytes) return value
|
||||
|
||||
val builder = StringBuilder()
|
||||
var usedBytes = 0
|
||||
var index = 0
|
||||
while (index < value.length) {
|
||||
val codePoint = value.codePointAt(index)
|
||||
val char = String(Character.toChars(codePoint))
|
||||
val charBytes = char.toByteArray(Charsets.UTF_8).size
|
||||
if (usedBytes + charBytes > maxBytes) break
|
||||
builder.append(char)
|
||||
usedBytes += charBytes
|
||||
index += Character.charCount(codePoint)
|
||||
}
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
internal fun sanitizeRelativeDir(relativeDir: String): String {
|
||||
if (relativeDir.isBlank()) return ""
|
||||
return relativeDir
|
||||
.split("/")
|
||||
.map { sanitizeFilename(it) }
|
||||
.filter { it.isNotBlank() && it != "." && it != ".." }
|
||||
.joinToString("/")
|
||||
}
|
||||
|
||||
internal fun ensureDocumentDir(
|
||||
context: Context,
|
||||
treeUri: Uri,
|
||||
relativeDir: String
|
||||
): DocumentFile? {
|
||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||
if (safeRelativeDir.isBlank()) {
|
||||
return DocumentFile.fromTreeUri(context, treeUri)
|
||||
}
|
||||
|
||||
synchronized(safDirLock) {
|
||||
var current = DocumentFile.fromTreeUri(context, treeUri) ?: return null
|
||||
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
|
||||
for (part in parts) {
|
||||
val existing = current.findFile(part)
|
||||
current = if (existing != null && existing.isDirectory) {
|
||||
existing
|
||||
} else {
|
||||
val created = current.createDirectory(part) ?: return null
|
||||
val createdName = created.name ?: part
|
||||
if (createdName != part) {
|
||||
created.delete()
|
||||
current.findFile(part) ?: return null
|
||||
} else {
|
||||
created
|
||||
}
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
}
|
||||
|
||||
internal fun findDocumentDir(
|
||||
context: Context,
|
||||
treeUri: Uri,
|
||||
relativeDir: String
|
||||
): DocumentFile? {
|
||||
var current = DocumentFile.fromTreeUri(context, treeUri) ?: return null
|
||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||
if (safeRelativeDir.isBlank()) return current
|
||||
|
||||
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
|
||||
for (part in parts) {
|
||||
val existing = current.findFile(part)
|
||||
if (existing == null || !existing.isDirectory) return null
|
||||
current = existing
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
internal fun createOrReuseDocumentFile(
|
||||
parent: DocumentFile,
|
||||
mimeType: String,
|
||||
fileName: String
|
||||
): DocumentFile? {
|
||||
val safeFileName = sanitizeFilename(fileName)
|
||||
if (safeFileName.isBlank()) return null
|
||||
|
||||
synchronized(safDirLock) {
|
||||
val existing = parent.findFile(safeFileName)
|
||||
if (existing != null && existing.isFile) {
|
||||
return existing
|
||||
}
|
||||
|
||||
val created = parent.createFile(mimeType, safeFileName) ?: return null
|
||||
val createdName = created.name ?: safeFileName
|
||||
if (createdName == safeFileName) {
|
||||
return created
|
||||
}
|
||||
|
||||
val winner = parent.findFile(safeFileName)
|
||||
if (winner != null && winner.isFile) {
|
||||
if (winner.uri != created.uri) {
|
||||
try {
|
||||
created.delete()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
return winner
|
||||
}
|
||||
|
||||
return created
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
|
||||
val provided = req.optString("saf_file_name", "")
|
||||
if (provided.isNotBlank()) return forceFilenameExt(provided, outputExt)
|
||||
|
||||
val trackName = req.optString("track_name", "track")
|
||||
val artistName = req.optString("artist_name", "")
|
||||
val baseName = if (artistName.isNotBlank()) "$artistName - $trackName" else trackName
|
||||
return forceFilenameExt(baseName, outputExt)
|
||||
}
|
||||
|
||||
private fun errorJson(message: String): String {
|
||||
val obj = JSONObject()
|
||||
obj.put("success", false)
|
||||
obj.put("error", message)
|
||||
obj.put("message", message)
|
||||
return obj.toString()
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,4 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,4 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/widgetBackground" />
|
||||
<corners android:radius="16dp" />
|
||||
</shape>
|
||||
@@ -1,52 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/widget_root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/widget_download_bg"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:padding="12dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:contentDescription="@null"
|
||||
android:src="@mipmap/ic_launcher" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="@color/widgetTextPrimary"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_subtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="@color/widgetTextSecondary"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/widget_progress"
|
||||
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="4dp"
|
||||
android:layout_marginTop="6dp"
|
||||
android:max="100"
|
||||
android:progressTint="@color/widgetAccent" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -6,9 +6,4 @@
|
||||
android:drawable="@drawable/ic_launcher_foreground"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
<monochrome>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_monochrome"
|
||||
android:inset="16%" />
|
||||
</monochrome>
|
||||
</adaptive-icon>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 11 KiB |
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="widgetBackground">@android:color/system_neutral1_900</color>
|
||||
<color name="widgetTextPrimary">@android:color/system_neutral1_50</color>
|
||||
<color name="widgetTextSecondary">@android:color/system_neutral2_200</color>
|
||||
<color name="widgetAccent">@android:color/system_accent1_200</color>
|
||||
</resources>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="widgetBackground">#1C1B1F</color>
|
||||
<color name="widgetTextPrimary">#E6E1E5</color>
|
||||
<color name="widgetTextSecondary">#CAC4D0</color>
|
||||
</resources>
|
||||
@@ -1,8 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="widgetBackground">@android:color/system_neutral1_10</color>
|
||||
<color name="widgetTextPrimary">@android:color/system_neutral1_900</color>
|
||||
<color name="widgetTextSecondary">@android:color/system_neutral2_700</color>
|
||||
<color name="widgetAccent">@android:color/system_accent1_600</color>
|
||||
</resources>
|
||||
@@ -1,8 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#000000</color>
|
||||
<color name="widgetBackground">#F3F3F3</color>
|
||||
<color name="widgetTextPrimary">#1C1B1F</color>
|
||||
<color name="widgetTextSecondary">#49454F</color>
|
||||
<color name="widgetAccent">#1DB954</color>
|
||||
<color name="ic_launcher_background">#1a1a2e</color>
|
||||
</resources>
|
||||
@@ -1,8 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<automotiveApp>
|
||||
<uses name="media" />
|
||||
</automotiveApp>
|
||||
@@ -1,30 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<!--
|
||||
SpotiFLAC Mobile provides an explicit, schema-aware Backup & Restore
|
||||
feature. Platform backup must not copy device-bound SAF grants, paths,
|
||||
queues, extension credentials, or signed sessions to another install.
|
||||
-->
|
||||
<cloud-backup>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
<exclude domain="device_root" path="." />
|
||||
<exclude domain="device_file" path="." />
|
||||
<exclude domain="device_database" path="." />
|
||||
<exclude domain="device_sharedpref" path="." />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
<exclude domain="device_root" path="." />
|
||||
<exclude domain="device_file" path="." />
|
||||
<exclude domain="device_database" path="." />
|
||||
<exclude domain="device_sharedpref" path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content>
|
||||
<!-- Android 11 and lower use this legacy rule format. -->
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
<exclude domain="device_root" path="." />
|
||||
<exclude domain="device_file" path="." />
|
||||
<exclude domain="device_database" path="." />
|
||||
<exclude domain="device_sharedpref" path="." />
|
||||
</full-backup-content>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<external-files-path name="external_files" path="." />
|
||||
<cache-path name="cache" path="." />
|
||||
<files-path name="files" path="." />
|
||||
</paths>
|
||||
@@ -1,16 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<locale android:name="en" />
|
||||
<locale android:name="ru" />
|
||||
<locale android:name="es-ES" />
|
||||
<locale android:name="id" />
|
||||
<locale android:name="pt-PT" />
|
||||
<locale android:name="ja" />
|
||||
<locale android:name="tr" />
|
||||
<locale android:name="de" />
|
||||
<locale android:name="fr" />
|
||||
<locale android:name="hi" />
|
||||
<locale android:name="ko" />
|
||||
<locale android:name="nl" />
|
||||
<locale android:name="zh" />
|
||||
</locale-config>
|
||||
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
|
||||
<!-- Allow local loopback cleartext for FFmpeg live decrypt tunnel only. -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">localhost</domain>
|
||||
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:initialLayout="@layout/widget_download_queue"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="40dp"
|
||||
android:resizeMode="horizontal"
|
||||
android:updatePeriodMillis="0"
|
||||
android:widgetCategory="home_screen" />
|
||||
@@ -1,237 +0,0 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class NativeFinalizationPolicyTest {
|
||||
@Test
|
||||
fun matchesSharedCrossPipelineQualityCases() {
|
||||
val stream = checkNotNull(
|
||||
javaClass.getResourceAsStream("/finalization_quality_cases.tsv"),
|
||||
)
|
||||
stream.bufferedReader().useLines { lines ->
|
||||
for (line in lines) {
|
||||
if (line.isBlank() || line.startsWith("#")) continue
|
||||
val fields = line.split('\t')
|
||||
assertEquals("invalid shared fixture: $line", 6, fields.size)
|
||||
val actual = NativeFinalizationPolicy.qualityVariantFilenameLabel(
|
||||
measuredQuality = fields[4],
|
||||
bitDepth = fields[1].toIntOrNull(),
|
||||
sampleRate = fields[2].toIntOrNull(),
|
||||
bitrateKbps = fields[3].toIntOrNull(),
|
||||
audioCodec = fields[0],
|
||||
)
|
||||
val expected = fields[5].takeUnless { it == "<null>" }
|
||||
assertEquals("shared fixture: $line", expected, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun codecAliasesDriveLossyAndLosslessDecisions() {
|
||||
assertEquals("aac", NativeFinalizationPolicy.normalizeAudioCodec("mp4a"))
|
||||
assertEquals("eac3", NativeFinalizationPolicy.normalizeAudioCodec("ec-3"))
|
||||
assertEquals("opus", NativeFinalizationPolicy.normalizeAudioCodec("ogg"))
|
||||
assertTrue(NativeFinalizationPolicy.isLossyAudioCodec("ac-4"))
|
||||
assertTrue(NativeFinalizationPolicy.isLosslessAudioCodec("pcm_s24le"))
|
||||
assertTrue(NativeFinalizationPolicy.isLosslessAudioCodec("ALAC"))
|
||||
assertFalse(NativeFinalizationPolicy.isLosslessAudioCodec("aac"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun displayQualityUsesMeasuredLosslessSpecifications() {
|
||||
assertEquals(
|
||||
"24-bit/96kHz",
|
||||
NativeFinalizationPolicy.displayAudioQuality(
|
||||
filePath = "/music/track.flac",
|
||||
fileName = "track.flac",
|
||||
bitDepth = 24,
|
||||
sampleRate = 96000,
|
||||
bitrateKbps = null,
|
||||
audioCodec = "flac",
|
||||
storedQuality = "HI_RES",
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"24-bit/44.1kHz",
|
||||
NativeFinalizationPolicy.displayAudioQuality(
|
||||
filePath = "/music/track.flac",
|
||||
fileName = "track.flac",
|
||||
bitDepth = 24,
|
||||
sampleRate = 44100,
|
||||
bitrateKbps = null,
|
||||
audioCodec = "flac",
|
||||
storedQuality = "LOSSLESS",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun displayQualityPreservesUsefulLossyLabels() {
|
||||
assertEquals(
|
||||
"OPUS 192kbps",
|
||||
NativeFinalizationPolicy.displayAudioQuality(
|
||||
filePath = "/music/track.ogg",
|
||||
fileName = "track.ogg",
|
||||
bitDepth = null,
|
||||
sampleRate = 48000,
|
||||
bitrateKbps = 192,
|
||||
audioCodec = "opus",
|
||||
storedQuality = "HIGH",
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"AAC",
|
||||
NativeFinalizationPolicy.displayAudioQuality(
|
||||
filePath = "/music/track.m4a",
|
||||
fileName = "track.m4a",
|
||||
bitDepth = null,
|
||||
sampleRate = 48000,
|
||||
bitrateKbps = null,
|
||||
audioCodec = "aac",
|
||||
storedQuality = "LOSSLESS",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun qualityVariantLabelRejectsUnmeasuredPlaceholders() {
|
||||
assertNull(
|
||||
NativeFinalizationPolicy.qualityVariantFilenameLabel(
|
||||
measuredQuality = "LOSSLESS",
|
||||
bitDepth = null,
|
||||
sampleRate = null,
|
||||
bitrateKbps = null,
|
||||
audioCodec = "flac",
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"24bit-96kHz",
|
||||
NativeFinalizationPolicy.qualityVariantFilenameLabel(
|
||||
measuredQuality = "24-bit/96kHz",
|
||||
bitDepth = null,
|
||||
sampleRate = null,
|
||||
bitrateKbps = null,
|
||||
audioCodec = "flac",
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"320kbps",
|
||||
NativeFinalizationPolicy.qualityVariantFilenameLabel(
|
||||
measuredQuality = "MP3 320kbps",
|
||||
bitDepth = null,
|
||||
sampleRate = null,
|
||||
bitrateKbps = null,
|
||||
audioCodec = "mp3",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun finalQualityLabelReplacesOnlyTheStagingToken() {
|
||||
assertEquals(
|
||||
"Artist - Track - 24bit-96kHz.flac",
|
||||
NativeFinalizationPolicy.applyQualityVariantFilenameLabel(
|
||||
fileName = "Artist - Track - pending.flac",
|
||||
stagingLabel = "pending",
|
||||
qualityLabel = "24bit-96kHz",
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"Artist - Track - 24bit-96kHz.flac",
|
||||
NativeFinalizationPolicy.applyQualityVariantFilenameLabel(
|
||||
fileName = "Artist - Track.flac",
|
||||
stagingLabel = "",
|
||||
qualityLabel = "24bit-96kHz",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun measuredQualityIsAddedOnlyAfterCleanNameCollision() {
|
||||
val stagedName = "Artist - Track - qv_ab12cd34.flac"
|
||||
assertEquals(
|
||||
"Artist - Track.flac",
|
||||
NativeFinalizationPolicy.removeQualityVariantStagingLabel(
|
||||
fileName = stagedName,
|
||||
stagingLabel = "qv_ab12cd34",
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"Artist - Track.flac",
|
||||
NativeFinalizationPolicy.resolveQualityVariantFilename(
|
||||
fileName = stagedName,
|
||||
stagingLabel = "qv_ab12cd34",
|
||||
qualityLabel = "24bit-96kHz",
|
||||
collisionOnly = true,
|
||||
cleanNameExists = false,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"Artist - Track - 24bit-96kHz.flac",
|
||||
NativeFinalizationPolicy.resolveQualityVariantFilename(
|
||||
fileName = stagedName,
|
||||
stagingLabel = "qv_ab12cd34",
|
||||
qualityLabel = "24bit-96kHz",
|
||||
collisionOnly = true,
|
||||
cleanNameExists = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deferredSafNamingNeverPublishesTheNativeCacheName() {
|
||||
val logicalName = NativeFinalizationPolicy.logicalOutputFileName(
|
||||
deferredSafPublish = true,
|
||||
resultSafFileName = "Sunidhi Chauhan - Aisa Jadoo - qv_ab12cd34.flac",
|
||||
requestSafFileName = "fallback.flac",
|
||||
currentFileName = "native_saf_work_603020549715656640.m4a",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"Sunidhi Chauhan - Aisa Jadoo - 16bit-44.1kHz.flac",
|
||||
NativeFinalizationPolicy.applyQualityVariantFilenameLabel(
|
||||
fileName = logicalName,
|
||||
stagingLabel = "qv_ab12cd34",
|
||||
qualityLabel = "16bit-44.1kHz",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonSafNamingStillFollowsTheCurrentConvertedFile() {
|
||||
assertEquals(
|
||||
"converted.flac",
|
||||
NativeFinalizationPolicy.logicalOutputFileName(
|
||||
deferredSafPublish = false,
|
||||
resultSafFileName = "ignored.flac",
|
||||
requestSafFileName = "ignored-too.flac",
|
||||
currentFileName = "converted.flac",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decryptionExtensionAndIndexTagsHaveStableFallbacks() {
|
||||
assertEquals(
|
||||
".m4a",
|
||||
NativeFinalizationPolicy.resolvePreferredDecryptionExtension(
|
||||
inputPath = "/music/encrypted.bin",
|
||||
requested = "M4A",
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
".flac",
|
||||
NativeFinalizationPolicy.resolvePreferredDecryptionExtension(
|
||||
inputPath = "/music/encrypted.m4a",
|
||||
requested = "",
|
||||
),
|
||||
)
|
||||
assertEquals("3/12", NativeFinalizationPolicy.formatIndexTag(3, 12))
|
||||
assertEquals("3", NativeFinalizationPolicy.formatIndexTag(3, 0))
|
||||
assertEquals("0", NativeFinalizationPolicy.formatIndexTag(0, 12))
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class NativeReplayGainPolicyTest {
|
||||
@Test
|
||||
fun completeAlbumRequiresEveryCompletedRequestEntry() {
|
||||
val eligible = NativeReplayGainPolicy.eligibleEntryIndexes(
|
||||
entryAlbumKeys = listOf("album:a", "album:a"),
|
||||
statuses = mapOf("one" to "completed", "two" to "completed"),
|
||||
requestAlbumKeys = mapOf("one" to "album:a", "two" to "album:a"),
|
||||
)
|
||||
|
||||
assertEquals(listOf(0, 1), eligible)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun incompleteOrFailedAlbumIsBlocked() {
|
||||
assertTrue(
|
||||
NativeReplayGainPolicy.eligibleEntryIndexes(
|
||||
entryAlbumKeys = listOf("album:a"),
|
||||
statuses = mapOf("one" to "completed", "two" to "downloading"),
|
||||
requestAlbumKeys = mapOf("one" to "album:a", "two" to "album:a"),
|
||||
).isEmpty(),
|
||||
)
|
||||
assertTrue(
|
||||
NativeReplayGainPolicy.eligibleEntryIndexes(
|
||||
entryAlbumKeys = listOf("album:a", "album:a"),
|
||||
statuses = mapOf("one" to "completed", "two" to "failed"),
|
||||
requestAlbumKeys = mapOf("one" to "album:a", "two" to "album:a"),
|
||||
).isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun singleTrackAndBlankAlbumKeysAreNeverWrittenAsAlbumGain() {
|
||||
val eligible = NativeReplayGainPolicy.eligibleEntryIndexes(
|
||||
entryAlbumKeys = listOf("album:single", "", ""),
|
||||
statuses = mapOf(
|
||||
"single" to "completed",
|
||||
"blank-one" to "completed",
|
||||
"blank-two" to "completed",
|
||||
),
|
||||
requestAlbumKeys = mapOf(
|
||||
"single" to "album:single",
|
||||
"blank-one" to "",
|
||||
"blank-two" to "",
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(eligible.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun albumsAreEvaluatedIndependently() {
|
||||
val eligible = NativeReplayGainPolicy.eligibleEntryIndexes(
|
||||
entryAlbumKeys = listOf("album:a", "album:a", "album:b", "album:b"),
|
||||
statuses = mapOf(
|
||||
"a1" to "completed",
|
||||
"a2" to "completed",
|
||||
"b1" to "completed",
|
||||
"b2" to "queued",
|
||||
),
|
||||
requestAlbumKeys = mapOf(
|
||||
"a1" to "album:a",
|
||||
"a2" to "album:a",
|
||||
"b1" to "album:b",
|
||||
"b2" to "album:b",
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf(0, 1), eligible)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pendingWorkOnlyIncludesRunnableStatuses() {
|
||||
assertTrue(
|
||||
NativeReplayGainPolicy.hasPendingWork(
|
||||
mapOf("one" to "completed", "two" to "finalizing"),
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeReplayGainPolicy.hasPendingWork(
|
||||
mapOf("one" to "completed", "two" to "failed"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class NativeWorkerPolicyTest {
|
||||
@Test
|
||||
fun rateLimitRetriesExactlyOnce() {
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.shouldRetryRateLimit(
|
||||
errorType = "rate_limit",
|
||||
errorMessage = "Too many requests",
|
||||
attempts = 0,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.shouldRetryRateLimit(
|
||||
errorType = "rate_limit",
|
||||
errorMessage = "Too many requests",
|
||||
attempts = 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rateLimitDetectionFallsBackToMessage() {
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.shouldRetryRateLimit(
|
||||
errorType = "network",
|
||||
errorMessage = "HTTP 429: retry later",
|
||||
attempts = 0,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.shouldRetryRateLimit(
|
||||
errorType = "network",
|
||||
errorMessage = "Connection reset",
|
||||
attempts = 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retryDelayUsesServerHintAndSafeBounds() {
|
||||
assertEquals(
|
||||
45,
|
||||
NativeWorkerPolicy.rateLimitDelaySeconds(
|
||||
retryAfterSeconds = 45,
|
||||
errorMessage = null,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
12,
|
||||
NativeWorkerPolicy.rateLimitDelaySeconds(
|
||||
retryAfterSeconds = null,
|
||||
errorMessage = "Retry-After seconds: 12",
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
5,
|
||||
NativeWorkerPolicy.rateLimitDelaySeconds(
|
||||
retryAfterSeconds = 1,
|
||||
errorMessage = null,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
300,
|
||||
NativeWorkerPolicy.rateLimitDelaySeconds(
|
||||
retryAfterSeconds = 900,
|
||||
errorMessage = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wifiOnlyPausesWithoutWifi() {
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.shouldPauseForNetwork(
|
||||
downloadNetworkMode = "wifi_only",
|
||||
hasWifi = false,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.shouldPauseForNetwork(
|
||||
downloadNetworkMode = "wifi_only",
|
||||
hasWifi = true,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.shouldPauseForNetwork(
|
||||
downloadNetworkMode = "any",
|
||||
hasWifi = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verificationDetectionUsesTypeAndMessageFallback() {
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.isVerificationRequired(
|
||||
errorType = "verification_required",
|
||||
errorMessage = null,
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.isVerificationRequired(
|
||||
errorType = "unknown",
|
||||
errorMessage = "Challenge required before download",
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.isVerificationRequired(
|
||||
errorType = "network",
|
||||
errorMessage = "Connection reset",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completionAlertSkipsCancelledOrEmptyRuns() {
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.shouldNotifyQueueComplete(
|
||||
cancelRequested = false,
|
||||
completed = 1,
|
||||
failed = 0,
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.shouldNotifyQueueComplete(
|
||||
cancelRequested = false,
|
||||
completed = 0,
|
||||
failed = 1,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.shouldNotifyQueueComplete(
|
||||
cancelRequested = true,
|
||||
completed = 1,
|
||||
failed = 0,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.shouldNotifyQueueComplete(
|
||||
cancelRequested = false,
|
||||
completed = 0,
|
||||
failed = 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
# codec bit_depth sample_rate bitrate_kbps measured_quality expected_label
|
||||
flac 24 96000 LOSSLESS 24bit-96kHz
|
||||
flac LOSSLESS <null>
|
||||
flac 24-bit/44.1kHz 24bit-44.1kHz
|
||||
mp3 320 HIGH 320kbps
|
||||
opus OPUS 192kbps 192kbps
|
||||
|
@@ -10,24 +10,15 @@ subprojects {
|
||||
if (project.hasProperty("android")) {
|
||||
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_25
|
||||
targetCompatibility = JavaVersion.VERSION_25
|
||||
}
|
||||
|
||||
// Enable multidex for all subprojects
|
||||
defaultConfig {
|
||||
multiDexEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
// Add desugaring dependency to all Android subprojects
|
||||
project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.5")
|
||||
}
|
||||
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-all.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
|
||||
@@ -19,8 +19,8 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "9.3.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.4.10" apply false
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"name": "SpotiFLAC Mobile Source",
|
||||
"identifier": "com.zarzet.spotiflac.source",
|
||||
"subtitle": "FLAC Downloader for iOS",
|
||||
"apps": [
|
||||
{
|
||||
"name": "SpotiFLAC Mobile",
|
||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||
"developerName": "zarzet",
|
||||
"version": "4.8.0",
|
||||
"versionDate": "2026-07-25",
|
||||
"downloadURL": "https://github.com/spotiflacapp/SpotiFLAC-Mobile/releases/download/v4.8.0/SpotiFLAC-v4.8.0-ios-unsigned.ipa",
|
||||
"localizedDescription": "SpotiFLAC Mobile is written in Flutter and uses installable extensions for metadata and audio downloads.",
|
||||
"iconURL": "https://raw.githubusercontent.com/spotiflacapp/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||
"size": 37939793
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 539 KiB |
|
Before Width: | Height: | Size: 811 KiB |
|
Before Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 34 KiB |