fix(mobile): logout on upgrade (#26827)

* use cookiejar

* cookie duping hook

* remove old pref

* handle network switching on logout

* remove bootstrapCookies

* dead code

* fix cast

* use constants

* use new event name

* update api
This commit is contained in:
Mert
2026-03-11 12:07:27 -05:00
committed by GitHub
parent e7db3b220d
commit c403e03a42
17 changed files with 340 additions and 89 deletions

View File

@@ -3,6 +3,7 @@ plugins {
id "kotlin-android" id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin" id "dev.flutter.flutter-gradle-plugin"
id 'com.google.devtools.ksp' id 'com.google.devtools.ksp'
id 'org.jetbrains.kotlin.plugin.serialization'
id 'org.jetbrains.kotlin.plugin.compose' version '2.0.20' // this version matches your Kotlin version id 'org.jetbrains.kotlin.plugin.compose' version '2.0.20' // this version matches your Kotlin version
} }

View File

@@ -8,11 +8,16 @@ import app.alextran.immich.BuildConfig
import app.alextran.immich.NativeBuffer import app.alextran.immich.NativeBuffer
import okhttp3.Cache import okhttp3.Cache
import okhttp3.ConnectionPool import okhttp3.ConnectionPool
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.Credentials
import okhttp3.Dispatcher import okhttp3.Dispatcher
import okhttp3.Headers import okhttp3.Headers
import okhttp3.Credentials import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.json.JSONObject import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.File import java.io.File
import java.net.Socket import java.net.Socket
@@ -32,7 +37,19 @@ private const val CERT_ALIAS = "client_cert"
private const val PREFS_NAME = "immich.ssl" private const val PREFS_NAME = "immich.ssl"
private const val PREFS_CERT_ALIAS = "immich.client_cert" private const val PREFS_CERT_ALIAS = "immich.client_cert"
private const val PREFS_HEADERS = "immich.request_headers" private const val PREFS_HEADERS = "immich.request_headers"
private const val PREFS_SERVER_URL = "immich.server_url" private const val PREFS_SERVER_URLS = "immich.server_urls"
private const val PREFS_COOKIES = "immich.cookies"
private const val COOKIE_EXPIRY_DAYS = 400L
private enum class AuthCookie(val cookieName: String, val httpOnly: Boolean) {
ACCESS_TOKEN("immich_access_token", httpOnly = true),
IS_AUTHENTICATED("immich_is_authenticated", httpOnly = false),
AUTH_TYPE("immich_auth_type", httpOnly = true);
companion object {
val names = entries.map { it.cookieName }.toSet()
}
}
/** /**
* Manages a shared OkHttpClient with SSL configuration support. * Manages a shared OkHttpClient with SSL configuration support.
@@ -58,6 +75,8 @@ object HttpClientManager {
var headers: Headers = Headers.headersOf() var headers: Headers = Headers.headersOf()
private set private set
private val cookieJar = PersistentCookieJar()
val isMtls: Boolean get() = keyChainAlias != null || keyStore.containsAlias(CERT_ALIAS) val isMtls: Boolean get() = keyChainAlias != null || keyStore.containsAlias(CERT_ALIAS)
fun initialize(context: Context) { fun initialize(context: Context) {
@@ -69,16 +88,23 @@ object HttpClientManager {
prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null) keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null)
cookieJar.init(prefs)
val savedHeaders = prefs.getString(PREFS_HEADERS, null) val savedHeaders = prefs.getString(PREFS_HEADERS, null)
if (savedHeaders != null) { if (savedHeaders != null) {
val json = JSONObject(savedHeaders) val map = Json.decodeFromString<Map<String, String>>(savedHeaders)
val builder = Headers.Builder() val builder = Headers.Builder()
for (key in json.keys()) { for ((key, value) in map) {
builder.add(key, json.getString(key)) builder.add(key, value)
} }
headers = builder.build() headers = builder.build()
} }
val serverUrlsJson = prefs.getString(PREFS_SERVER_URLS, null)
if (serverUrlsJson != null) {
cookieJar.setServerUrls(Json.decodeFromString<List<String>>(serverUrlsJson))
}
val cacheDir = File(File(context.cacheDir, "okhttp"), "api") val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
client = build(cacheDir) client = build(cacheDir)
initialized = true initialized = true
@@ -153,25 +179,50 @@ object HttpClientManager {
synchronized(this) { clientChangedListeners.add(listener) } synchronized(this) { clientChangedListeners.add(listener) }
} }
fun setRequestHeaders(headerMap: Map<String, String>, serverUrls: List<String>) { fun setRequestHeaders(headerMap: Map<String, String>, serverUrls: List<String>, token: String?) {
synchronized(this) { synchronized(this) {
val builder = Headers.Builder() val builder = Headers.Builder()
headerMap.forEach { (key, value) -> builder[key] = value } headerMap.forEach { (key, value) -> builder[key] = value }
val newHeaders = builder.build() val newHeaders = builder.build()
val headersChanged = headers != newHeaders val headersChanged = headers != newHeaders
val newUrl = serverUrls.firstOrNull() val urlsChanged = Json.encodeToString(serverUrls) != prefs.getString(PREFS_SERVER_URLS, null)
val urlChanged = newUrl != prefs.getString(PREFS_SERVER_URL, null)
if (!headersChanged && !urlChanged) return
headers = newHeaders headers = newHeaders
prefs.edit { cookieJar.setServerUrls(serverUrls)
if (headersChanged) putString(PREFS_HEADERS, JSONObject(headerMap).toString())
if (urlChanged) { if (headersChanged || urlsChanged) {
if (newUrl != null) putString(PREFS_SERVER_URL, newUrl) else remove(PREFS_SERVER_URL) prefs.edit {
putString(PREFS_HEADERS, Json.encodeToString(headerMap))
putString(PREFS_SERVER_URLS, Json.encodeToString(serverUrls))
} }
} }
if (token != null) {
val url = serverUrls.firstNotNullOfOrNull { it.toHttpUrlOrNull() } ?: return
val expiry = System.currentTimeMillis() + COOKIE_EXPIRY_DAYS * 24 * 60 * 60 * 1000
val values = mapOf(
AuthCookie.ACCESS_TOKEN to token,
AuthCookie.IS_AUTHENTICATED to "true",
AuthCookie.AUTH_TYPE to "password",
)
cookieJar.saveFromResponse(url, values.map { (cookie, value) ->
Cookie.Builder().name(cookie.cookieName).value(value).domain(url.host).path("/").expiresAt(expiry)
.apply {
if (url.isHttps) secure()
if (cookie.httpOnly) httpOnly()
}.build()
})
}
} }
} }
fun loadCookieHeader(url: String): String? {
val httpUrl = url.toHttpUrlOrNull() ?: return null
return cookieJar.loadForRequest(httpUrl).takeIf { it.isNotEmpty() }
?.joinToString("; ") { "${it.name}=${it.value}" }
}
private fun build(cacheDir: File): OkHttpClient { private fun build(cacheDir: File): OkHttpClient {
val connectionPool = ConnectionPool( val connectionPool = ConnectionPool(
maxIdleConnections = KEEP_ALIVE_CONNECTIONS, maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
@@ -188,6 +239,7 @@ object HttpClientManager {
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
return OkHttpClient.Builder() return OkHttpClient.Builder()
.cookieJar(cookieJar)
.addInterceptor { .addInterceptor {
val request = it.request() val request = it.request()
val builder = request.newBuilder() val builder = request.newBuilder()
@@ -249,4 +301,131 @@ object HttpClientManager {
socket: Socket? socket: Socket?
): String? = null ): String? = null
} }
/**
* Persistent CookieJar that duplicates auth cookies across equivalent server URLs.
* When the server sets cookies for one domain, copies are created for all other known
* server domains (for URL switching between local/remote endpoints of the same server).
*/
private class PersistentCookieJar : CookieJar {
private val store = mutableListOf<Cookie>()
private var serverUrls = listOf<HttpUrl>()
private var prefs: SharedPreferences? = null
fun init(prefs: SharedPreferences) {
this.prefs = prefs
restore()
}
@Synchronized
fun setServerUrls(urls: List<String>) {
val parsed = urls.mapNotNull { it.toHttpUrlOrNull() }
if (parsed.map { it.host } == serverUrls.map { it.host }) return
serverUrls = parsed
if (syncAuthCookies()) persist()
}
@Synchronized
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val changed = cookies.any { new ->
store.none { it.name == new.name && it.domain == new.domain && it.path == new.path && it.value == new.value }
}
store.removeAll { existing ->
cookies.any { it.name == existing.name && it.domain == existing.domain && it.path == existing.path }
}
store.addAll(cookies)
val synced = serverUrls.any { it.host == url.host } && syncAuthCookies()
if (changed || synced) persist()
}
@Synchronized
override fun loadForRequest(url: HttpUrl): List<Cookie> {
val now = System.currentTimeMillis()
if (store.removeAll { it.expiresAt < now }) {
syncAuthCookies()
persist()
}
return store.filter { it.matches(url) }
}
private fun syncAuthCookies(): Boolean {
val serverHosts = serverUrls.map { it.host }.toSet()
val now = System.currentTimeMillis()
val sourceCookies = store
.filter { it.name in AuthCookie.names && it.domain in serverHosts && it.expiresAt > now }
.associateBy { it.name }
if (sourceCookies.isEmpty()) {
return store.removeAll { it.name in AuthCookie.names && it.domain in serverHosts }
}
var changed = false
for (url in serverUrls) {
for ((_, source) in sourceCookies) {
if (store.any { it.name == source.name && it.domain == url.host && it.value == source.value }) continue
store.removeAll { it.name == source.name && it.domain == url.host }
store.add(rebuildCookie(source, url))
changed = true
}
}
return changed
}
private fun rebuildCookie(source: Cookie, url: HttpUrl): Cookie {
return Cookie.Builder()
.name(source.name).value(source.value)
.domain(url.host).path("/")
.expiresAt(source.expiresAt)
.apply {
if (url.isHttps) secure()
if (source.httpOnly) httpOnly()
}
.build()
}
private fun persist() {
val p = prefs ?: return
p.edit { putString(PREFS_COOKIES, Json.encodeToString(store.map { SerializedCookie.from(it) })) }
}
private fun restore() {
val p = prefs ?: return
val jsonStr = p.getString(PREFS_COOKIES, null) ?: return
try {
store.addAll(Json.decodeFromString<List<SerializedCookie>>(jsonStr).map { it.toCookie() })
} catch (_: Exception) {
store.clear()
}
}
}
@Serializable
private data class SerializedCookie(
val name: String,
val value: String,
val domain: String,
val path: String,
val expiresAt: Long,
val secure: Boolean,
val httpOnly: Boolean,
val hostOnly: Boolean,
) {
fun toCookie(): Cookie = Cookie.Builder()
.name(name).value(value).path(path).expiresAt(expiresAt)
.apply {
if (hostOnly) hostOnlyDomain(domain) else domain(domain)
if (secure) secure()
if (httpOnly) httpOnly()
}
.build()
companion object {
fun from(cookie: Cookie) = SerializedCookie(
name = cookie.name, value = cookie.value, domain = cookie.domain,
path = cookie.path, expiresAt = cookie.expiresAt, secure = cookie.secure,
httpOnly = cookie.httpOnly, hostOnly = cookie.hostOnly,
)
}
}
} }

View File

@@ -184,7 +184,7 @@ interface NetworkApi {
fun removeCertificate(callback: (Result<Unit>) -> Unit) fun removeCertificate(callback: (Result<Unit>) -> Unit)
fun hasCertificate(): Boolean fun hasCertificate(): Boolean
fun getClientPointer(): Long fun getClientPointer(): Long
fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>) fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>, token: String?)
companion object { companion object {
/** The codec used by NetworkApi. */ /** The codec used by NetworkApi. */
@@ -287,8 +287,9 @@ interface NetworkApi {
val args = message as List<Any?> val args = message as List<Any?>
val headersArg = args[0] as Map<String, String> val headersArg = args[0] as Map<String, String>
val serverUrlsArg = args[1] as List<String> val serverUrlsArg = args[1] as List<String>
val tokenArg = args[2] as String?
val wrapped: List<Any?> = try { val wrapped: List<Any?> = try {
api.setRequestHeaders(headersArg, serverUrlsArg) api.setRequestHeaders(headersArg, serverUrlsArg, tokenArg)
listOf(null) listOf(null)
} catch (exception: Throwable) { } catch (exception: Throwable) {
NetworkPigeonUtils.wrapError(exception) NetworkPigeonUtils.wrapError(exception)

View File

@@ -39,7 +39,7 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware {
} }
} }
private class NetworkApiImpl() : NetworkApi { private class NetworkApiImpl : NetworkApi {
var activity: Activity? = null var activity: Activity? = null
override fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit) { override fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit) {
@@ -79,7 +79,7 @@ private class NetworkApiImpl() : NetworkApi {
return HttpClientManager.getClientPointer() return HttpClientManager.getClientPointer()
} }
override fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>) { override fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>, token: String?) {
HttpClientManager.setRequestHeaders(headers, serverUrls) HttpClientManager.setRequestHeaders(headers, serverUrls, token)
} }
} }

View File

@@ -192,6 +192,7 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
val callback = FetchCallback(onSuccess, onFailure, ::onComplete) val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor) val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor)
HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) } HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
HttpClientManager.loadCookieHeader(url)?.let { requestBuilder.addHeader("Cookie", it) }
url.toHttpUrlOrNull()?.let { httpUrl -> url.toHttpUrlOrNull()?.let { httpUrl ->
if (httpUrl.username.isNotEmpty()) { if (httpUrl.username.isNotEmpty()) {
requestBuilder.addHeader("Authorization", Credentials.basic(httpUrl.username, httpUrl.password)) requestBuilder.addHeader("Authorization", Credentials.basic(httpUrl.username, httpUrl.password))

View File

@@ -225,7 +225,7 @@ protocol NetworkApi {
func removeCertificate(completion: @escaping (Result<Void, Error>) -> Void) func removeCertificate(completion: @escaping (Result<Void, Error>) -> Void)
func hasCertificate() throws -> Bool func hasCertificate() throws -> Bool
func getClientPointer() throws -> Int64 func getClientPointer() throws -> Int64
func setRequestHeaders(headers: [String: String], serverUrls: [String]) throws func setRequestHeaders(headers: [String: String], serverUrls: [String], token: String?) throws
} }
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -315,8 +315,9 @@ class NetworkApiSetup {
let args = message as! [Any?] let args = message as! [Any?]
let headersArg = args[0] as! [String: String] let headersArg = args[0] as! [String: String]
let serverUrlsArg = args[1] as! [String] let serverUrlsArg = args[1] as! [String]
let tokenArg: String? = nilOrValue(args[2])
do { do {
try api.setRequestHeaders(headers: headersArg, serverUrls: serverUrlsArg) try api.setRequestHeaders(headers: headersArg, serverUrls: serverUrlsArg, token: tokenArg)
reply(wrapResult(nil)) reply(wrapResult(nil))
} catch { } catch {
reply(wrapError(error)) reply(wrapError(error))

View File

@@ -58,42 +58,39 @@ class NetworkApiImpl: NetworkApi {
return Int64(Int(bitPattern: pointer)) return Int64(Int(bitPattern: pointer))
} }
func setRequestHeaders(headers: [String : String], serverUrls: [String]) throws { func setRequestHeaders(headers: [String : String], serverUrls: [String], token: String?) throws {
var headers = headers URLSessionManager.setServerUrls(serverUrls)
if let token = headers.removeValue(forKey: "x-immich-user-token") {
if let token = token {
let expiry = Date().addingTimeInterval(COOKIE_EXPIRY_DAYS * 24 * 60 * 60)
for serverUrl in serverUrls { for serverUrl in serverUrls {
guard let url = URL(string: serverUrl), let domain = url.host else { continue } guard let url = URL(string: serverUrl), let domain = url.host else { continue }
let isSecure = serverUrl.hasPrefix("https") let isSecure = serverUrl.hasPrefix("https")
let cookies: [(String, String, Bool)] = [ let values: [AuthCookie: String] = [
("immich_access_token", token, true), .accessToken: token,
("immich_is_authenticated", "true", false), .isAuthenticated: "true",
("immich_auth_type", "password", true), .authType: "password",
] ]
let expiry = Date().addingTimeInterval(400 * 24 * 60 * 60) for (cookie, value) in values {
for (name, value, httpOnly) in cookies {
var properties: [HTTPCookiePropertyKey: Any] = [ var properties: [HTTPCookiePropertyKey: Any] = [
.name: name, .name: cookie.name,
.value: value, .value: value,
.domain: domain, .domain: domain,
.path: "/", .path: "/",
.expires: expiry, .expires: expiry,
] ]
if isSecure { properties[.secure] = "TRUE" } if isSecure { properties[.secure] = "TRUE" }
if httpOnly { properties[.init("HttpOnly")] = "TRUE" } if cookie.httpOnly { properties[.init("HttpOnly")] = "TRUE" }
if let cookie = HTTPCookie(properties: properties) { if let httpCookie = HTTPCookie(properties: properties) {
URLSessionManager.cookieStorage.setCookie(cookie) URLSessionManager.cookieStorage.setCookie(httpCookie)
} }
} }
} }
} }
if serverUrls.first != UserDefaults.group.string(forKey: SERVER_URL_KEY) {
UserDefaults.group.set(serverUrls.first, forKey: SERVER_URL_KEY)
}
if headers != UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] { if headers != UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] {
UserDefaults.group.set(headers, forKey: HEADERS_KEY) UserDefaults.group.set(headers, forKey: HEADERS_KEY)
URLSessionManager.shared.recreateSession() // Recreate session to apply custom headers without app restart URLSessionManager.shared.recreateSession()
} }
} }
} }

View File

@@ -3,8 +3,30 @@ import native_video_player
let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity" let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity"
let HEADERS_KEY = "immich.request_headers" let HEADERS_KEY = "immich.request_headers"
let SERVER_URL_KEY = "immich.server_url" let SERVER_URLS_KEY = "immich.server_urls"
let APP_GROUP = "group.app.immich.share" let APP_GROUP = "group.app.immich.share"
let COOKIE_EXPIRY_DAYS: TimeInterval = 400
enum AuthCookie: CaseIterable {
case accessToken, isAuthenticated, authType
var name: String {
switch self {
case .accessToken: return "immich_access_token"
case .isAuthenticated: return "immich_is_authenticated"
case .authType: return "immich_auth_type"
}
}
var httpOnly: Bool {
switch self {
case .accessToken, .authType: return true
case .isAuthenticated: return false
}
}
static let names: Set<String> = Set(allCases.map(\.name))
}
extension UserDefaults { extension UserDefaults {
static let group = UserDefaults(suiteName: APP_GROUP)! static let group = UserDefaults(suiteName: APP_GROUP)!
@@ -34,6 +56,8 @@ class URLSessionManager: NSObject {
return "Immich_iOS_\(version)" return "Immich_iOS_\(version)"
}() }()
static let cookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: APP_GROUP) static let cookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: APP_GROUP)
private static var serverUrls: [String] = []
private static var isSyncing = false
var sessionPointer: UnsafeMutableRawPointer { var sessionPointer: UnsafeMutableRawPointer {
Unmanaged.passUnretained(session).toOpaque() Unmanaged.passUnretained(session).toOpaque()
@@ -43,12 +67,83 @@ class URLSessionManager: NSObject {
delegate = URLSessionManagerDelegate() delegate = URLSessionManagerDelegate()
session = Self.buildSession(delegate: delegate) session = Self.buildSession(delegate: delegate)
super.init() super.init()
Self.serverUrls = UserDefaults.group.stringArray(forKey: SERVER_URLS_KEY) ?? []
NotificationCenter.default.addObserver(
Self.self,
selector: #selector(Self.cookiesDidChange),
name: NSNotification.Name.NSHTTPCookieManagerCookiesChanged,
object: Self.cookieStorage
)
} }
func recreateSession() { func recreateSession() {
session = Self.buildSession(delegate: delegate) session = Self.buildSession(delegate: delegate)
} }
static func setServerUrls(_ urls: [String]) {
guard urls != serverUrls else { return }
serverUrls = urls
UserDefaults.group.set(urls, forKey: SERVER_URLS_KEY)
syncAuthCookies()
}
@objc private static func cookiesDidChange(_ notification: Notification) {
guard !isSyncing, !serverUrls.isEmpty else { return }
syncAuthCookies()
}
private static func syncAuthCookies() {
let serverHosts = Set(serverUrls.compactMap { URL(string: $0)?.host })
let allCookies = cookieStorage.cookies ?? []
let now = Date()
let serverAuthCookies = allCookies.filter {
AuthCookie.names.contains($0.name) && serverHosts.contains($0.domain)
}
var sourceCookies: [String: HTTPCookie] = [:]
for cookie in serverAuthCookies {
if cookie.expiresDate.map({ $0 > now }) ?? true {
sourceCookies[cookie.name] = cookie
}
}
isSyncing = true
defer { isSyncing = false }
if sourceCookies.isEmpty {
for cookie in serverAuthCookies {
cookieStorage.deleteCookie(cookie)
}
return
}
for serverUrl in serverUrls {
guard let url = URL(string: serverUrl), let domain = url.host else { continue }
let isSecure = serverUrl.hasPrefix("https")
for (_, source) in sourceCookies {
if allCookies.contains(where: { $0.name == source.name && $0.domain == domain && $0.value == source.value }) {
continue
}
var properties: [HTTPCookiePropertyKey: Any] = [
.name: source.name,
.value: source.value,
.domain: domain,
.path: "/",
.expires: source.expiresDate ?? Date().addingTimeInterval(COOKIE_EXPIRY_DAYS * 24 * 60 * 60),
]
if isSecure { properties[.secure] = "TRUE" }
if source.isHTTPOnly { properties[.init("HttpOnly")] = "TRUE" }
if let cookie = HTTPCookie(properties: properties) {
cookieStorage.setCookie(cookie)
}
}
}
}
private static func buildSession(delegate: URLSessionManagerDelegate) -> URLSession { private static func buildSession(delegate: URLSessionManagerDelegate) -> URLSession {
let config = URLSessionConfiguration.default let config = URLSessionConfiguration.default
config.urlCache = urlCache config.urlCache = urlCache

View File

@@ -26,8 +26,8 @@ class NetworkRepository {
} }
} }
static Future<void> setHeaders(Map<String, String> headers, List<String> serverUrls) async { static Future<void> setHeaders(Map<String, String> headers, List<String> serverUrls, {String? token}) async {
await networkApi.setRequestHeaders(headers, serverUrls); await networkApi.setRequestHeaders(headers, serverUrls, token);
if (Platform.isIOS) { if (Platform.isIOS) {
await init(); await init();
} }

View File

@@ -281,7 +281,7 @@ class NetworkApi {
} }
} }
Future<void> setRequestHeaders(Map<String, String> headers, List<String> serverUrls) async { Future<void> setRequestHeaders(Map<String, String> headers, List<String> serverUrls, String? token) async {
final String pigeonVar_channelName = final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$pigeonVar_messageChannelSuffix'; 'dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>( final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
@@ -289,7 +289,7 @@ class NetworkApi {
pigeonChannelCodec, pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger, binaryMessenger: pigeonVar_binaryMessenger,
); );
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[headers, serverUrls]); final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[headers, serverUrls, token]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?; final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) { if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName); throw _createConnectionError(pigeonVar_channelName);

View File

@@ -123,7 +123,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
} }
Future<bool> saveAuthInfo({required String accessToken}) async { Future<bool> saveAuthInfo({required String accessToken}) async {
await _apiService.setAccessToken(accessToken); await Store.put(StoreKey.accessToken, accessToken);
await _apiService.updateHeaders(); await _apiService.updateHeaders();
final serverEndpoint = Store.get(StoreKey.serverEndpoint); final serverEndpoint = Store.get(StoreKey.serverEndpoint);
@@ -145,7 +145,6 @@ class AuthNotifier extends StateNotifier<AuthState> {
user = serverUser; user = serverUser;
await Store.put(StoreKey.deviceId, deviceId); await Store.put(StoreKey.deviceId, deviceId);
await Store.put(StoreKey.deviceIdHash, fastHash(deviceId)); await Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
await Store.put(StoreKey.accessToken, accessToken);
} }
} on ApiException catch (error, stackTrace) { } on ApiException catch (error, stackTrace) {
if (error.code == 401) { if (error.code == 401) {

View File

@@ -38,10 +38,6 @@ class AuthRepository extends DatabaseRepository {
}); });
} }
String getAccessToken() {
return Store.get(StoreKey.accessToken);
}
bool getEndpointSwitchingFeature() { bool getEndpointSwitchingFeature() {
return Store.tryGet(StoreKey.autoEndpointSwitching) ?? false; return Store.tryGet(StoreKey.autoEndpointSwitching) ?? false;
} }

View File

@@ -11,7 +11,7 @@ import 'package:immich_mobile/utils/url_helper.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
class ApiService implements Authentication { class ApiService {
late ApiClient _apiClient; late ApiClient _apiClient;
late UsersApi usersApi; late UsersApi usersApi;
@@ -45,7 +45,6 @@ class ApiService implements Authentication {
setEndpoint(endpoint); setEndpoint(endpoint);
} }
} }
String? _accessToken;
final _log = Logger("ApiService"); final _log = Logger("ApiService");
Future<void> updateHeaders() async { Future<void> updateHeaders() async {
@@ -54,11 +53,8 @@ class ApiService implements Authentication {
} }
setEndpoint(String endpoint) { setEndpoint(String endpoint) {
_apiClient = ApiClient(basePath: endpoint, authentication: this); _apiClient = ApiClient(basePath: endpoint);
_apiClient.client = NetworkRepository.client; _apiClient.client = NetworkRepository.client;
if (_accessToken != null) {
setAccessToken(_accessToken!);
}
usersApi = UsersApi(_apiClient); usersApi = UsersApi(_apiClient);
authenticationApi = AuthenticationApi(_apiClient); authenticationApi = AuthenticationApi(_apiClient);
oAuthApi = AuthenticationApi(_apiClient); oAuthApi = AuthenticationApi(_apiClient);
@@ -157,11 +153,6 @@ class ApiService implements Authentication {
return ""; return "";
} }
Future<void> setAccessToken(String accessToken) async {
_accessToken = accessToken;
await Store.put(StoreKey.accessToken, accessToken);
}
Future<void> setDeviceInfoHeader() async { Future<void> setDeviceInfoHeader() async {
DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin(); DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
@@ -205,28 +196,12 @@ class ApiService implements Authentication {
} }
static Map<String, String> getRequestHeaders() { static Map<String, String> getRequestHeaders() {
var accessToken = Store.get(StoreKey.accessToken, "");
var customHeadersStr = Store.get(StoreKey.customHeaders, ""); var customHeadersStr = Store.get(StoreKey.customHeaders, "");
var header = <String, String>{};
if (accessToken.isNotEmpty) {
header['x-immich-user-token'] = accessToken;
}
if (customHeadersStr.isEmpty) { if (customHeadersStr.isEmpty) {
return header; return const {};
} }
var customHeaders = jsonDecode(customHeadersStr) as Map; return (jsonDecode(customHeadersStr) as Map).cast<String, String>();
customHeaders.forEach((key, value) {
header[key] = value;
});
return header;
}
@override
Future<void> applyToParams(List<QueryParam> queryParams, Map<String, String> headerParams) {
return Future.value();
} }
ApiClient get apiClient => _apiClient; ApiClient get apiClient => _apiClient;

View File

@@ -340,7 +340,6 @@ class BackgroundService {
], ],
); );
await ref.read(apiServiceProvider).setAccessToken(Store.get(StoreKey.accessToken));
await ref.read(authServiceProvider).setOpenApiServiceEndpoint(); await ref.read(authServiceProvider).setOpenApiServiceEndpoint();
dPrint(() => "[BG UPLOAD] Using endpoint: ${ref.read(apiServiceProvider).apiClient.basePath}"); dPrint(() => "[BG UPLOAD] Using endpoint: ${ref.read(apiServiceProvider).apiClient.basePath}");

View File

@@ -74,7 +74,6 @@ class BackupVerificationService {
final lower = compute(_computeSaveToDelete, ( final lower = compute(_computeSaveToDelete, (
deleteCandidates: deleteCandidates.slice(0, half), deleteCandidates: deleteCandidates.slice(0, half),
originals: originals.slice(0, half), originals: originals.slice(0, half),
auth: Store.get(StoreKey.accessToken),
endpoint: Store.get(StoreKey.serverEndpoint), endpoint: Store.get(StoreKey.serverEndpoint),
rootIsolateToken: isolateToken, rootIsolateToken: isolateToken,
fileMediaRepository: _fileMediaRepository, fileMediaRepository: _fileMediaRepository,
@@ -82,7 +81,6 @@ class BackupVerificationService {
final upper = compute(_computeSaveToDelete, ( final upper = compute(_computeSaveToDelete, (
deleteCandidates: deleteCandidates.slice(half), deleteCandidates: deleteCandidates.slice(half),
originals: originals.slice(half), originals: originals.slice(half),
auth: Store.get(StoreKey.accessToken),
endpoint: Store.get(StoreKey.serverEndpoint), endpoint: Store.get(StoreKey.serverEndpoint),
rootIsolateToken: isolateToken, rootIsolateToken: isolateToken,
fileMediaRepository: _fileMediaRepository, fileMediaRepository: _fileMediaRepository,
@@ -92,7 +90,6 @@ class BackupVerificationService {
toDelete = await compute(_computeSaveToDelete, ( toDelete = await compute(_computeSaveToDelete, (
deleteCandidates: deleteCandidates, deleteCandidates: deleteCandidates,
originals: originals, originals: originals,
auth: Store.get(StoreKey.accessToken),
endpoint: Store.get(StoreKey.serverEndpoint), endpoint: Store.get(StoreKey.serverEndpoint),
rootIsolateToken: isolateToken, rootIsolateToken: isolateToken,
fileMediaRepository: _fileMediaRepository, fileMediaRepository: _fileMediaRepository,
@@ -105,7 +102,6 @@ class BackupVerificationService {
({ ({
List<Asset> deleteCandidates, List<Asset> deleteCandidates,
List<Asset> originals, List<Asset> originals,
String auth,
String endpoint, String endpoint,
RootIsolateToken rootIsolateToken, RootIsolateToken rootIsolateToken,
FileMediaRepository fileMediaRepository, FileMediaRepository fileMediaRepository,
@@ -120,7 +116,6 @@ class BackupVerificationService {
await tuple.fileMediaRepository.enableBackgroundAccess(); await tuple.fileMediaRepository.enableBackgroundAccess();
final ApiService apiService = ApiService(); final ApiService apiService = ApiService();
apiService.setEndpoint(tuple.endpoint); apiService.setEndpoint(tuple.endpoint);
await apiService.setAccessToken(tuple.auth);
for (int i = 0; i < tuple.deleteCandidates.length; i++) { for (int i = 0; i < tuple.deleteCandidates.length; i++) {
if (await _compareAssets(tuple.deleteCandidates[i], tuple.originals[i], apiService)) { if (await _compareAssets(tuple.deleteCandidates[i], tuple.originals[i], apiService)) {
result.add(tuple.deleteCandidates[i]); result.add(tuple.deleteCandidates[i]);

View File

@@ -25,8 +25,10 @@ import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/platform/network_api.g.dart'; import 'package:immich_mobile/platform/network_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart'; import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/debug_print.dart';
@@ -35,7 +37,7 @@ import 'package:isar/isar.dart';
// ignore: import_rule_photo_manager // ignore: import_rule_photo_manager
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
const int targetVersion = 24; const int targetVersion = 25;
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async { Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
final hasVersion = Store.tryGet(StoreKey.version) != null; final hasVersion = Store.tryGet(StoreKey.version) != null;
@@ -109,6 +111,16 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
await _applyLocalAssetOrientation(drift); await _applyLocalAssetOrientation(drift);
} }
if (version < 25) {
final accessToken = Store.tryGet(StoreKey.accessToken);
if (accessToken != null && accessToken.isNotEmpty) {
final serverUrls = ApiService.getServerUrls();
if (serverUrls.isNotEmpty) {
await NetworkRepository.setHeaders(ApiService.getRequestHeaders(), serverUrls, token: accessToken);
}
}
}
if (version < 22 && !Store.isBetaTimelineEnabled) { if (version < 22 && !Store.isBetaTimelineEnabled) {
await Store.put(StoreKey.needBetaMigration, true); await Store.put(StoreKey.needBetaMigration, true);
} }

View File

@@ -43,5 +43,5 @@ abstract class NetworkApi {
int getClientPointer(); int getClientPointer();
void setRequestHeaders(Map<String, String> headers, List<String> serverUrls); void setRequestHeaders(Map<String, String> headers, List<String> serverUrls, String? token);
} }