Compare commits

..
1 Commits
Author SHA1 Message Date
zarzet 8f66916a58 v1.2.0: Track Metadata Screen, Hi-Res fix, Settings navigation fix 2026-01-02 00:03:05 +07:00
580 changed files with 9869 additions and 314628 deletions
-24
View File
@@ -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
-3
View File
@@ -1,3 +0,0 @@
{
"flutter": "3.44.8"
}
-24
View File
@@ -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
-3
View File
@@ -1,3 +0,0 @@
github: zarzet
ko_fi: zarzet
-130
View File
@@ -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.
-8
View File
@@ -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
-124
View File
@@ -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...
-42
View File
@@ -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. -->
+75
View File
@@ -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
+69
View File
@@ -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 }}
-77
View File
@@ -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"
-184
View File
@@ -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
+118
View File
@@ -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
-44
View File
@@ -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
+99 -423
View File
@@ -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/&/\&amp;/g' | \
sed 's/</\&lt;/g' | \
sed 's/>/\&gt;/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!"
+6 -90
View File
@@ -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/
+30 -1739
View File
File diff suppressed because it is too large Load Diff
-133
View File
@@ -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
-167
View File
@@ -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.
+23 -184
View File
@@ -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>
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
<div align="center">
[![GitHub Release](https://img.shields.io/github/v/release/spotiflacapp/SpotiFLAC-Mobile?style=for-the-badge&logo=github)](https://github.com/spotiflacapp/SpotiFLAC-Mobile/releases)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
<img src="icon.png" width="128" />
[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac)
[![Telegram Community](https://img.shields.io/badge/COMMUNITY-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac_chat)
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
![Android](https://img.shields.io/badge/Android-7.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)
![iOS](https://img.shields.io/badge/iOS-14.0%2B-000000?style=for-the-badge&logo=apple&logoColor=white)
</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:
>
> [![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](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.
-23
View File
@@ -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
+71
View File
@@ -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'
}
+13 -69
View File
@@ -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")
}
+2 -85
View File
@@ -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 { *; }
+7 -107
View File
@@ -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)
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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
}
File diff suppressed because it is too large Load Diff
@@ -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()
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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()
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

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 -5
View File
@@ -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
1 # codec bit_depth sample_rate bitrate_kbps measured_quality expected_label
2 flac 24 96000 LOSSLESS 24bit-96kHz
3 flac LOSSLESS <null>
4 flac 24-bit/44.1kHz 24bit-44.1kHz
5 mp3 320 HIGH 320kbps
6 opus OPUS 192kbps 192kbps
+3 -12
View File
@@ -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 -5
View File
@@ -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 -5
View File
@@ -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
+2 -2
View File
@@ -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")
-18
View File
@@ -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
}
]
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 811 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Some files were not shown because too many files have changed in this diff Show More