mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
fix(verification): recover signed-session bootstrap failures
This commit is contained in:
@@ -512,7 +512,17 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
|
||||
preflightStartedAt := time.Now()
|
||||
verificationRequired, preflightErr := preflightExtensionDownloadSession(sessionProvider)
|
||||
if preflightErr != nil {
|
||||
GoLog("[DownloadWithExtensions] Signed-session preflight for %s was inconclusive after %s: %v\n", sessionProvider, time.Since(preflightStartedAt).Round(time.Millisecond), preflightErr)
|
||||
message := fmt.Sprintf("Could not start verification for %s: %v", sessionProvider, preflightErr)
|
||||
GoLog("[DownloadWithExtensions] Signed-session preflight for %s failed after %s: %v\n", sessionProvider, time.Since(preflightStartedAt).Round(time.Millisecond), preflightErr)
|
||||
if req.ItemID != "" {
|
||||
RemoveItemProgress(req.ItemID)
|
||||
}
|
||||
return marshalJSONString(&DownloadResponse{
|
||||
Success: false,
|
||||
Error: message,
|
||||
ErrorType: classifyDownloadErrorType(message),
|
||||
Service: sessionProvider,
|
||||
})
|
||||
} else if verificationRequired {
|
||||
GoLog("[DownloadWithExtensions] Signed-session verification required for %s after %s; skipping metadata preparation\n", sessionProvider, time.Since(preflightStartedAt).Round(time.Millisecond))
|
||||
cacheUnpreparedDownloadRequest(downloadPreparationKey(req), req)
|
||||
@@ -566,7 +576,10 @@ func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
|
||||
}
|
||||
|
||||
func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
|
||||
req := ensureExtensionPendingAuthRequest(extensionID)
|
||||
req, err := ensureExtensionPendingAuthRequest(extensionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if req == nil {
|
||||
return "", nil
|
||||
}
|
||||
@@ -580,15 +593,15 @@ func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
|
||||
return marshalJSONString(result)
|
||||
}
|
||||
|
||||
func ensureExtensionPendingAuthRequest(extensionID string) *PendingAuthRequest {
|
||||
func ensureExtensionPendingAuthRequest(extensionID string) (*PendingAuthRequest, error) {
|
||||
extensionID = strings.TrimSpace(extensionID)
|
||||
if extensionID == "" {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if req := GetPendingAuthRequest(extensionID); req != nil {
|
||||
if time.Since(req.CreatedAt) < pendingAuthRequestTTL {
|
||||
return req
|
||||
return req, nil
|
||||
}
|
||||
// The cached challenge is stale (e.g. verification was requested
|
||||
// while the app was backgrounded and never completed); serving it
|
||||
@@ -599,25 +612,34 @@ func ensureExtensionPendingAuthRequest(extensionID string) *PendingAuthRequest {
|
||||
manager := getExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
if err != nil || ext == nil || !ext.Enabled || ext.Manifest == nil || ext.Manifest.SignedSession == nil {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if err := ext.ensureRuntimeReady(); err != nil || ext.runtime == nil {
|
||||
return nil
|
||||
if err := ext.ensureRuntimeReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ext.runtime == nil {
|
||||
return nil, fmt.Errorf("extension '%s' runtime is unavailable", extensionID)
|
||||
}
|
||||
|
||||
config := signedSessionConfigWithDefaults(ext.Manifest.SignedSession)
|
||||
if config.Namespace == "" || config.BaseURL == "" {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
if record, err := ext.runtime.loadSignedSession(config); err == nil {
|
||||
record.SessionID = ""
|
||||
record.SessionSecret = ""
|
||||
record.ExpiresAt = ""
|
||||
_ = ext.runtime.saveSignedSession(config, record)
|
||||
record, err := ext.runtime.loadSignedSession(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ext.runtime.startSignedSessionVerification(config, "pending-auth-request")
|
||||
return GetPendingAuthRequest(extensionID)
|
||||
record.SessionID = ""
|
||||
record.SessionSecret = ""
|
||||
record.ExpiresAt = ""
|
||||
if err := ext.runtime.saveSignedSession(config, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := ext.runtime.startSignedSessionVerification(config, "pending-auth-request"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return GetPendingAuthRequest(extensionID), nil
|
||||
}
|
||||
|
||||
func SetExtensionAuthCodeByID(extensionID, authCode string) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -214,8 +215,8 @@ func signedSessionRecordIsUsable(record *signedSessionRecord) bool {
|
||||
// preflightSignedSession prepares a signed session before download metadata
|
||||
// enrichment starts. A fresh pending challenge is reused, while bootstrap
|
||||
// responses that can issue a session silently are accepted without prompting
|
||||
// the user. Bootstrap failures remain non-fatal to the caller so the normal
|
||||
// provider path can still surface its more specific error.
|
||||
// the user. Bootstrap failures are returned so callers do not continue into
|
||||
// the provider and accidentally issue the same failing bootstrap repeatedly.
|
||||
func (r *extensionRuntime) preflightSignedSession() (bool, error) {
|
||||
if r == nil || r.manifest == nil || r.manifest.SignedSession == nil {
|
||||
return false, nil
|
||||
@@ -242,7 +243,9 @@ func (r *extensionRuntime) preflightSignedSession() (bool, error) {
|
||||
ClearPendingAuthRequest(r.extensionID)
|
||||
}
|
||||
|
||||
if authURL := r.startSignedSessionVerification(config, "download-preflight"); authURL != "" {
|
||||
if authURL, err := r.startSignedSessionVerification(config, "download-preflight"); err != nil {
|
||||
return false, err
|
||||
} else if authURL != "" {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -394,8 +397,10 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value
|
||||
|
||||
record, err := r.ensureSignedSession(config)
|
||||
if err != nil {
|
||||
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
|
||||
if authURL, verificationErr := r.startSignedSessionVerification(config, "signed-fetch"); authURL != "" {
|
||||
return r.signedSessionVerificationRequiredValue(authURL)
|
||||
} else if verificationErr != nil {
|
||||
return r.vm.ToValue(map[string]any{"ok": false, "error": verificationErr.Error()})
|
||||
}
|
||||
return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()})
|
||||
}
|
||||
@@ -409,8 +414,10 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value
|
||||
record.SessionSecret = ""
|
||||
record.ExpiresAt = ""
|
||||
_ = r.saveSignedSession(config, record)
|
||||
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
|
||||
if authURL, verificationErr := r.startSignedSessionVerification(config, "signed-fetch-reauth"); authURL != "" {
|
||||
return r.signedSessionVerificationRequiredValue(authURL)
|
||||
} else if verificationErr != nil {
|
||||
return r.vm.ToValue(map[string]any{"ok": false, "error": verificationErr.Error()})
|
||||
}
|
||||
}
|
||||
return r.vm.ToValue(map[string]any{
|
||||
@@ -490,45 +497,94 @@ func (r *extensionRuntime) refreshSignedSession(config SignedSessionConfig, reco
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionConfig, _ string) string {
|
||||
func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionConfig, reason string) (string, error) {
|
||||
record, err := r.loadSignedSession(config)
|
||||
if err != nil {
|
||||
return ""
|
||||
return "", fmt.Errorf("load signed-session bootstrap state: %w", err)
|
||||
}
|
||||
bootstrapURL, err := signedSessionURL(config, config.Endpoints.Bootstrap)
|
||||
if err != nil {
|
||||
return ""
|
||||
return "", fmt.Errorf("build signed-session bootstrap URL: %w", err)
|
||||
}
|
||||
parsed, err := url.Parse(bootstrapURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse signed-session bootstrap URL: %w", err)
|
||||
}
|
||||
parsed, _ := url.Parse(bootstrapURL)
|
||||
query := parsed.Query()
|
||||
query.Set("app_version", config.AppVersion)
|
||||
query.Set("install_id", record.InstallID)
|
||||
parsed.RawQuery = query.Encode()
|
||||
req, err := http.NewRequest(http.MethodGet, parsed.String(), nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
if r.httpClient == nil {
|
||||
return "", fmt.Errorf("signed-session bootstrap HTTP client is unavailable")
|
||||
}
|
||||
|
||||
var resp *http.Response
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
req, requestErr := http.NewRequest(http.MethodGet, parsed.String(), nil)
|
||||
if requestErr != nil {
|
||||
return "", fmt.Errorf("build signed-session bootstrap request: %w", requestErr)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
||||
resp, err = r.httpClient.Do(req)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
resp = nil
|
||||
}
|
||||
if attempt == 0 {
|
||||
// Android can retain pooled connections across a Wi-Fi/cellular
|
||||
// transition. Rebuild the GET once after dropping those sockets.
|
||||
r.httpClient.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return ""
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) && urlErr.Err != nil {
|
||||
err = urlErr.Err
|
||||
}
|
||||
bootstrapErr := fmt.Errorf(
|
||||
"signed-session bootstrap network request to %s failed: %v",
|
||||
parsed.Host,
|
||||
err,
|
||||
)
|
||||
LogWarn("SignedSession", "Bootstrap failed for extension %s (%s): %v", r.extensionID, reason, bootstrapErr)
|
||||
return "", bootstrapErr
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxExtensionHTTPResponseBytes))
|
||||
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return ""
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// Drain a bounded error response so the transport can reuse the
|
||||
// connection without exposing response bodies that may contain secrets.
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
|
||||
message := fmt.Sprintf("signed-session bootstrap returned HTTP %d", resp.StatusCode)
|
||||
if resp.StatusCode >= 500 {
|
||||
message = fmt.Sprintf("signed-session bootstrap network request returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
if retryAfter := signedSessionRetryAfterSeconds(resp); retryAfter > 0 {
|
||||
message += fmt.Sprintf("; retry-after seconds: %d", retryAfter)
|
||||
}
|
||||
bootstrapErr := errors.New(message)
|
||||
LogWarn("SignedSession", "Bootstrap failed for extension %s (%s): %v", r.extensionID, reason, bootstrapErr)
|
||||
return "", bootstrapErr
|
||||
}
|
||||
body, err := readExtensionHTTPResponseBody(resp)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read signed-session bootstrap response: %w", err)
|
||||
}
|
||||
var boot signedSessionExchangeResponse
|
||||
if err := json.Unmarshal(body, &boot); err != nil {
|
||||
return ""
|
||||
return "", fmt.Errorf("decode signed-session bootstrap response: %w", err)
|
||||
}
|
||||
if boot.SessionID != "" && boot.SessionSecret != "" && boot.ExpiresAt != "" {
|
||||
record.SessionID = boot.SessionID
|
||||
record.SessionSecret = boot.SessionSecret
|
||||
record.ExpiresAt = boot.ExpiresAt
|
||||
_ = r.saveSignedSession(config, record)
|
||||
return ""
|
||||
if err := r.saveSignedSession(config, record); err != nil {
|
||||
return "", fmt.Errorf("save bootstrapped signed session: %w", err)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
authURL := boot.AuthURL
|
||||
if authURL == "" && boot.ChallengeURL != "" {
|
||||
@@ -537,17 +593,18 @@ func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionCo
|
||||
if authURL == "" && boot.ChallengeID != "" {
|
||||
authURL = r.buildSignedSessionChallengeURL(config, boot.ChallengeID)
|
||||
}
|
||||
if authURL != "" {
|
||||
pendingAuthRequestsMu.Lock()
|
||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||
ExtensionID: r.extensionID,
|
||||
AuthURL: authURL,
|
||||
CallbackURL: config.CallbackURL,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
if authURL == "" {
|
||||
return "", fmt.Errorf("signed-session bootstrap did not return a session or verification challenge")
|
||||
}
|
||||
return authURL
|
||||
pendingAuthRequestsMu.Lock()
|
||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||
ExtensionID: r.extensionID,
|
||||
AuthURL: authURL,
|
||||
CallbackURL: config.CallbackURL,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
return authURL, nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) buildSignedSessionChallengeURL(config SignedSessionConfig, challengeID string) string {
|
||||
|
||||
@@ -245,6 +245,60 @@ func TestPreflightSignedSession(t *testing.T) {
|
||||
t.Fatalf("bootstrapped session = %#v error:%v", record, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("retries a transport failure once and surfaces the cause", func(t *testing.T) {
|
||||
calls := 0
|
||||
runtime := newSignedSessionTestRuntime(t, "preflight-network", roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
return nil, fmt.Errorf("dial tcp: Wi-Fi route unavailable")
|
||||
}))
|
||||
runtime.manifest.SignedSession = &SignedSessionConfig{
|
||||
Namespace: "preflight-network",
|
||||
BaseURL: "https://auth.example.com",
|
||||
}
|
||||
|
||||
verificationRequired, err := runtime.preflightSignedSession()
|
||||
if err == nil || verificationRequired {
|
||||
t.Fatalf("preflight = verification:%v error:%v", verificationRequired, err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("bootstrap calls = %d, want one initial attempt and one retry", calls)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "network request") || !strings.Contains(err.Error(), "Wi-Fi route unavailable") {
|
||||
t.Fatalf("bootstrap error did not preserve the transport cause: %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "install_id=") {
|
||||
t.Fatalf("bootstrap error leaked the install identifier: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("surfaces HTTP status and retry-after without inventing a challenge", func(t *testing.T) {
|
||||
calls := 0
|
||||
runtime := newSignedSessionTestRuntime(t, "preflight-rate-limit", roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Header: http.Header{"Retry-After": []string{"17"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":"limited"}`)),
|
||||
Request: req,
|
||||
}, nil
|
||||
}))
|
||||
runtime.manifest.SignedSession = &SignedSessionConfig{
|
||||
Namespace: "preflight-rate-limit",
|
||||
BaseURL: "https://auth.example.com",
|
||||
}
|
||||
|
||||
verificationRequired, err := runtime.preflightSignedSession()
|
||||
if err == nil || verificationRequired {
|
||||
t.Fatalf("preflight = verification:%v error:%v", verificationRequired, err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("rate-limited bootstrap calls = %d, want 1", calls)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "HTTP 429") || !strings.Contains(err.Error(), "retry-after seconds: 17") {
|
||||
t.Fatalf("rate-limit details missing from bootstrap error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDownloadWithExtensionsPreflightsBeforeMetadataEnrichment(t *testing.T) {
|
||||
@@ -316,6 +370,71 @@ func TestDownloadWithExtensionsPreflightsBeforeMetadataEnrichment(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadWithExtensionsStopsAfterFailedSignedSessionPreflight(t *testing.T) {
|
||||
extensionID := "preflight-network-failure"
|
||||
itemID := "preflight-network-item"
|
||||
RemoveItemProgress(itemID)
|
||||
t.Cleanup(func() { RemoveItemProgress(itemID) })
|
||||
|
||||
calls := 0
|
||||
runtime := newSignedSessionTestRuntime(t, extensionID, roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
return nil, fmt.Errorf("dial tcp: Wi-Fi route unavailable")
|
||||
}))
|
||||
manifest := &ExtensionManifest{
|
||||
Name: extensionID,
|
||||
Types: []ExtensionType{ExtensionTypeDownloadProvider},
|
||||
SignedSession: &SignedSessionConfig{
|
||||
Namespace: extensionID,
|
||||
BaseURL: "https://auth.example.com",
|
||||
},
|
||||
}
|
||||
runtime.manifest = manifest
|
||||
ext := &loadedExtension{
|
||||
ID: extensionID,
|
||||
Manifest: manifest,
|
||||
VM: runtime.vm,
|
||||
runtime: runtime,
|
||||
initialized: true,
|
||||
Enabled: true,
|
||||
DataDir: runtime.dataDir,
|
||||
}
|
||||
|
||||
manager := getExtensionManager()
|
||||
manager.mu.Lock()
|
||||
previous, hadPrevious := manager.extensions[extensionID]
|
||||
manager.extensions[extensionID] = ext
|
||||
manager.mu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
manager.mu.Lock()
|
||||
if hadPrevious {
|
||||
manager.extensions[extensionID] = previous
|
||||
} else {
|
||||
delete(manager.extensions, extensionID)
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
})
|
||||
|
||||
requestJSON := `{"service":"preflight-network-failure","item_id":"preflight-network-item","isrc":"USRC17607839"}`
|
||||
responseJSON, err := DownloadWithExtensionsJSON(requestJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadWithExtensionsJSON: %v", err)
|
||||
}
|
||||
var response DownloadResponse
|
||||
if err := json.Unmarshal([]byte(responseJSON), &response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if response.ErrorType != "network" || response.Service != extensionID || !strings.Contains(response.Error, "Could not start verification") {
|
||||
t.Fatalf("response = %#v", response)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("bootstrap calls = %d, want only the initial attempt and its transport retry", calls)
|
||||
}
|
||||
if got := GetItemProgress(itemID); got != "{}" {
|
||||
t.Fatalf("failed preflight left stale progress: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedSessionURL(t *testing.T) {
|
||||
base := SignedSessionConfig{BaseURL: "https://auth.example.com/api"}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/download_verification_retry_guard.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_state.dart';
|
||||
import 'package:spotiflac_android/services/app_state_database.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
@@ -173,7 +174,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
int _lastNotifQueueCount = -1;
|
||||
final Set<String> _locallyCancelledItemIds = {};
|
||||
final Set<String> _pausePendingItemIds = {};
|
||||
final Set<String> _verificationRetriedItemIds = {};
|
||||
final DownloadVerificationRetryGuard _verificationRetryGuard =
|
||||
DownloadVerificationRetryGuard();
|
||||
final Map<String, Future<bool>> _verificationFlowsByExtension = {};
|
||||
final Set<String> _rateLimitRetriedItemIds = {};
|
||||
String? _activeNativeWorkerRunId;
|
||||
@@ -185,9 +187,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
// then computes and writes album gain/peak to every track in the album.
|
||||
final Map<String, _AlbumRgAccumulator> _albumRgData = {};
|
||||
|
||||
String _verificationRetryKey(String itemId, String service) =>
|
||||
'$itemId::${service.trim().toLowerCase()}';
|
||||
|
||||
double _normalizeProgressForUi(double value) {
|
||||
final clamped = value.clamp(0.0, 1.0).toDouble();
|
||||
if (clamped <= 0) return 0;
|
||||
@@ -424,8 +423,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final targetService = (verificationService ?? '').trim().isNotEmpty
|
||||
? verificationService!.trim()
|
||||
: item.service;
|
||||
final verificationRetryKey = _verificationRetryKey(item.id, targetService);
|
||||
if (_verificationRetriedItemIds.contains(verificationRetryKey)) {
|
||||
if (_verificationRetryGuard.hasRetriedAfterGrant(item.id, targetService)) {
|
||||
_log.e(
|
||||
'Verification was already completed once for ${item.track.name} on $targetService; not opening another challenge',
|
||||
);
|
||||
@@ -438,8 +436,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_failedInSession++;
|
||||
return true;
|
||||
}
|
||||
_verificationRetriedItemIds.add(verificationRetryKey);
|
||||
|
||||
_log.i(
|
||||
'Download for ${item.track.name} requires verification; waiting for $targetService grant',
|
||||
);
|
||||
@@ -457,6 +453,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only a completed grant consumes the automatic retry. If bootstrap or
|
||||
// browser launch failed before a challenge opened, a later attempt must
|
||||
// still be allowed after the network changes.
|
||||
_verificationRetryGuard.recordVerificationResult(
|
||||
item.id,
|
||||
targetService,
|
||||
granted: verified,
|
||||
);
|
||||
if (verified) {
|
||||
_log.i(
|
||||
'Verification complete for $targetService; retrying ${item.track.name}',
|
||||
@@ -1586,9 +1590,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}),
|
||||
);
|
||||
_locallyCancelledItemIds.remove(id);
|
||||
_verificationRetriedItemIds.removeWhere(
|
||||
(retryKey) => retryKey == id || retryKey.startsWith('$id::'),
|
||||
);
|
||||
_verificationRetryGuard.clearItem(id);
|
||||
_rateLimitRetriedItemIds.remove(id);
|
||||
|
||||
// Purge stale ReplayGain entry for this track so a re-scan doesn't
|
||||
@@ -2295,10 +2297,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final remainingIds = state.items.map((item) => item.id).toSet();
|
||||
_locallyCancelledItemIds.removeWhere((id) => !remainingIds.contains(id));
|
||||
_pausePendingItemIds.removeWhere((id) => !remainingIds.contains(id));
|
||||
_verificationRetriedItemIds.removeWhere((retryKey) {
|
||||
final itemId = retryKey.split('::').first;
|
||||
return !remainingIds.contains(itemId);
|
||||
});
|
||||
_verificationRetryGuard.retainItems(remainingIds);
|
||||
_rateLimitRetriedItemIds.removeWhere((id) => !remainingIds.contains(id));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
class DownloadVerificationRetryGuard {
|
||||
final Set<String> _grantedRetryKeys = {};
|
||||
|
||||
bool hasRetriedAfterGrant(String itemId, String service) =>
|
||||
_grantedRetryKeys.contains(_key(itemId, service));
|
||||
|
||||
void recordVerificationResult(
|
||||
String itemId,
|
||||
String service, {
|
||||
required bool granted,
|
||||
}) {
|
||||
if (granted) {
|
||||
_grantedRetryKeys.add(_key(itemId, service));
|
||||
}
|
||||
}
|
||||
|
||||
void clearItem(String itemId) {
|
||||
_grantedRetryKeys.removeWhere(
|
||||
(retryKey) => retryKey == itemId || retryKey.startsWith('$itemId::'),
|
||||
);
|
||||
}
|
||||
|
||||
void retainItems(Set<String> itemIds) {
|
||||
_grantedRetryKeys.removeWhere((retryKey) {
|
||||
final itemId = retryKey.split('::').first;
|
||||
return !itemIds.contains(itemId);
|
||||
});
|
||||
}
|
||||
|
||||
String _key(String itemId, String service) =>
|
||||
'$itemId::${service.trim().toLowerCase()}';
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/providers/download_verification_retry_guard.dart';
|
||||
|
||||
void main() {
|
||||
group('DownloadVerificationRetryGuard', () {
|
||||
test('failed challenge attempts do not consume the granted retry', () {
|
||||
final guard = DownloadVerificationRetryGuard();
|
||||
|
||||
guard.recordVerificationResult('item-1', 'tidal-web', granted: false);
|
||||
|
||||
expect(guard.hasRetriedAfterGrant('item-1', 'tidal-web'), isFalse);
|
||||
});
|
||||
|
||||
test('completed grants allow only one automatic retry per service', () {
|
||||
final guard = DownloadVerificationRetryGuard();
|
||||
|
||||
guard.recordVerificationResult('item-1', ' TIDAL-WEB ', granted: true);
|
||||
|
||||
expect(guard.hasRetriedAfterGrant('item-1', 'tidal-web'), isTrue);
|
||||
expect(guard.hasRetriedAfterGrant('item-1', 'qobuz-web'), isFalse);
|
||||
expect(guard.hasRetriedAfterGrant('item-2', 'tidal-web'), isFalse);
|
||||
});
|
||||
|
||||
test('manual retry clears every service marker for an item', () {
|
||||
final guard = DownloadVerificationRetryGuard()
|
||||
..recordVerificationResult('item-1', 'tidal-web', granted: true)
|
||||
..recordVerificationResult('item-1', 'qobuz-web', granted: true)
|
||||
..recordVerificationResult('item-2', 'tidal-web', granted: true);
|
||||
|
||||
guard.clearItem('item-1');
|
||||
|
||||
expect(guard.hasRetriedAfterGrant('item-1', 'tidal-web'), isFalse);
|
||||
expect(guard.hasRetriedAfterGrant('item-1', 'qobuz-web'), isFalse);
|
||||
expect(guard.hasRetriedAfterGrant('item-2', 'tidal-web'), isTrue);
|
||||
});
|
||||
|
||||
test('queue cleanup retains markers only for remaining items', () {
|
||||
final guard = DownloadVerificationRetryGuard()
|
||||
..recordVerificationResult('removed', 'tidal-web', granted: true)
|
||||
..recordVerificationResult('remaining', 'tidal-web', granted: true);
|
||||
|
||||
guard.retainItems({'remaining'});
|
||||
|
||||
expect(guard.hasRetriedAfterGrant('removed', 'tidal-web'), isFalse);
|
||||
expect(guard.hasRetriedAfterGrant('remaining', 'tidal-web'), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user