mirror of
https://github.com/immich-app/immich.git
synced 2026-03-22 14:29:26 +03:00
@@ -113,6 +113,8 @@ dependencies {
|
|||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||||
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
|
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
|
||||||
implementation 'org.chromium.net:cronet-embedded:143.7445.0'
|
implementation 'org.chromium.net:cronet-embedded:143.7445.0'
|
||||||
|
implementation("androidx.media3:media3-datasource-okhttp:1.9.2")
|
||||||
|
implementation("androidx.media3:media3-datasource-cronet:1.9.2")
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
|
||||||
implementation "androidx.work:work-runtime-ktx:$work_version"
|
implementation "androidx.work:work-runtime-ktx:$work_version"
|
||||||
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
|
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import app.alextran.immich.connectivity.ConnectivityApiImpl
|
|||||||
import app.alextran.immich.core.HttpClientManager
|
import app.alextran.immich.core.HttpClientManager
|
||||||
import app.alextran.immich.core.ImmichPlugin
|
import app.alextran.immich.core.ImmichPlugin
|
||||||
import app.alextran.immich.core.NetworkApiPlugin
|
import app.alextran.immich.core.NetworkApiPlugin
|
||||||
|
import me.albemala.native_video_player.NativeVideoPlayerPlugin
|
||||||
import app.alextran.immich.images.LocalImageApi
|
import app.alextran.immich.images.LocalImageApi
|
||||||
import app.alextran.immich.images.LocalImagesImpl
|
import app.alextran.immich.images.LocalImagesImpl
|
||||||
import app.alextran.immich.images.RemoteImageApi
|
import app.alextran.immich.images.RemoteImageApi
|
||||||
@@ -31,6 +32,7 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
companion object {
|
companion object {
|
||||||
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
||||||
HttpClientManager.initialize(ctx)
|
HttpClientManager.initialize(ctx)
|
||||||
|
NativeVideoPlayerPlugin.dataSourceFactory = HttpClientManager::createDataSourceFactory
|
||||||
flutterEngine.plugins.add(NetworkApiPlugin())
|
flutterEngine.plugins.add(NetworkApiPlugin())
|
||||||
|
|
||||||
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ package app.alextran.immich.core
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.security.KeyChain
|
import android.security.KeyChain
|
||||||
|
import androidx.annotation.OptIn
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.datasource.DataSource
|
||||||
|
import androidx.media3.datasource.ResolvingDataSource
|
||||||
|
import androidx.media3.datasource.cronet.CronetDataSource
|
||||||
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
import app.alextran.immich.BuildConfig
|
import app.alextran.immich.BuildConfig
|
||||||
import app.alextran.immich.NativeBuffer
|
import app.alextran.immich.NativeBuffer
|
||||||
import okhttp3.Cache
|
import okhttp3.Cache
|
||||||
@@ -16,6 +22,7 @@ import okhttp3.Headers
|
|||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import org.chromium.net.CronetEngine
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
@@ -25,6 +32,8 @@ import java.security.KeyStore
|
|||||||
import java.security.Principal
|
import java.security.Principal
|
||||||
import java.security.PrivateKey
|
import java.security.PrivateKey
|
||||||
import java.security.cert.X509Certificate
|
import java.security.cert.X509Certificate
|
||||||
|
import java.util.concurrent.ExecutorService
|
||||||
|
import java.util.concurrent.Executors
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.net.ssl.HttpsURLConnection
|
import javax.net.ssl.HttpsURLConnection
|
||||||
import javax.net.ssl.SSLContext
|
import javax.net.ssl.SSLContext
|
||||||
@@ -56,6 +65,7 @@ private enum class AuthCookie(val cookieName: String, val httpOnly: Boolean) {
|
|||||||
*/
|
*/
|
||||||
object HttpClientManager {
|
object HttpClientManager {
|
||||||
private const val CACHE_SIZE_BYTES = 100L * 1024 * 1024 // 100MiB
|
private const val CACHE_SIZE_BYTES = 100L * 1024 * 1024 // 100MiB
|
||||||
|
const val MEDIA_CACHE_SIZE_BYTES = 1024L * 1024 * 1024 // 1GiB
|
||||||
private const val KEEP_ALIVE_CONNECTIONS = 10
|
private const val KEEP_ALIVE_CONNECTIONS = 10
|
||||||
private const val KEEP_ALIVE_DURATION_MINUTES = 5L
|
private const val KEEP_ALIVE_DURATION_MINUTES = 5L
|
||||||
private const val MAX_REQUESTS_PER_HOST = 64
|
private const val MAX_REQUESTS_PER_HOST = 64
|
||||||
@@ -67,6 +77,11 @@ object HttpClientManager {
|
|||||||
private lateinit var appContext: Context
|
private lateinit var appContext: Context
|
||||||
private lateinit var prefs: SharedPreferences
|
private lateinit var prefs: SharedPreferences
|
||||||
|
|
||||||
|
var cronetEngine: CronetEngine? = null
|
||||||
|
private set
|
||||||
|
private lateinit var cronetStorageDir: File
|
||||||
|
val cronetExecutor: ExecutorService = Executors.newFixedThreadPool(4)
|
||||||
|
|
||||||
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||||
|
|
||||||
var keyChainAlias: String? = null
|
var keyChainAlias: String? = null
|
||||||
@@ -107,6 +122,10 @@ object HttpClientManager {
|
|||||||
|
|
||||||
val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
|
val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
|
||||||
client = build(cacheDir)
|
client = build(cacheDir)
|
||||||
|
|
||||||
|
cronetStorageDir = File(context.cacheDir, "cronet").apply { mkdirs() }
|
||||||
|
cronetEngine = buildCronetEngine()
|
||||||
|
|
||||||
initialized = true
|
initialized = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,6 +242,53 @@ object HttpClientManager {
|
|||||||
?.joinToString("; ") { "${it.name}=${it.value}" }
|
?.joinToString("; ") { "${it.name}=${it.value}" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getAuthHeaders(url: String): Map<String, String> {
|
||||||
|
val result = mutableMapOf<String, String>()
|
||||||
|
headers.forEach { (key, value) -> result[key] = value }
|
||||||
|
loadCookieHeader(url)?.let { result["Cookie"] = it }
|
||||||
|
url.toHttpUrlOrNull()?.let { httpUrl ->
|
||||||
|
if (httpUrl.username.isNotEmpty()) {
|
||||||
|
result["Authorization"] = Credentials.basic(httpUrl.username, httpUrl.password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rebuildCronetEngine(): CronetEngine {
|
||||||
|
val old = cronetEngine!!
|
||||||
|
cronetEngine = buildCronetEngine()
|
||||||
|
return old
|
||||||
|
}
|
||||||
|
|
||||||
|
val cronetStoragePath: File get() = cronetStorageDir
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
fun createDataSourceFactory(headers: Map<String, String>): DataSource.Factory {
|
||||||
|
return if (isMtls) {
|
||||||
|
OkHttpDataSource.Factory(client.newBuilder().cache(null).build())
|
||||||
|
} else {
|
||||||
|
ResolvingDataSource.Factory(
|
||||||
|
CronetDataSource.Factory(cronetEngine!!, cronetExecutor)
|
||||||
|
) { dataSpec ->
|
||||||
|
val newHeaders = dataSpec.httpRequestHeaders.toMutableMap()
|
||||||
|
newHeaders.putAll(getAuthHeaders(dataSpec.uri.toString()))
|
||||||
|
newHeaders["Cache-Control"] = "no-store"
|
||||||
|
dataSpec.buildUpon().setHttpRequestHeaders(newHeaders).build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildCronetEngine(): CronetEngine {
|
||||||
|
return CronetEngine.Builder(appContext)
|
||||||
|
.enableHttp2(true)
|
||||||
|
.enableQuic(true)
|
||||||
|
.enableBrotli(true)
|
||||||
|
.setStoragePath(cronetStorageDir.absolutePath)
|
||||||
|
.setUserAgent(USER_AGENT)
|
||||||
|
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, MEDIA_CACHE_SIZE_BYTES)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import app.alextran.immich.INITIAL_BUFFER_SIZE
|
|||||||
import app.alextran.immich.NativeBuffer
|
import app.alextran.immich.NativeBuffer
|
||||||
import app.alextran.immich.NativeByteBuffer
|
import app.alextran.immich.NativeByteBuffer
|
||||||
import app.alextran.immich.core.HttpClientManager
|
import app.alextran.immich.core.HttpClientManager
|
||||||
import app.alextran.immich.core.USER_AGENT
|
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import okhttp3.Cache
|
import okhttp3.Cache
|
||||||
import okhttp3.Call
|
import okhttp3.Call
|
||||||
@@ -15,9 +14,6 @@ import okhttp3.Callback
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.Credentials
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
|
||||||
import org.chromium.net.CronetEngine
|
|
||||||
import org.chromium.net.CronetException
|
import org.chromium.net.CronetException
|
||||||
import org.chromium.net.UrlRequest
|
import org.chromium.net.UrlRequest
|
||||||
import org.chromium.net.UrlResponseInfo
|
import org.chromium.net.UrlResponseInfo
|
||||||
@@ -31,10 +27,6 @@ import java.nio.file.Path
|
|||||||
import java.nio.file.SimpleFileVisitor
|
import java.nio.file.SimpleFileVisitor
|
||||||
import java.nio.file.attribute.BasicFileAttributes
|
import java.nio.file.attribute.BasicFileAttributes
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.Executors
|
|
||||||
|
|
||||||
|
|
||||||
private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024
|
|
||||||
|
|
||||||
private class RemoteRequest(val cancellationSignal: CancellationSignal)
|
private class RemoteRequest(val cancellationSignal: CancellationSignal)
|
||||||
|
|
||||||
@@ -101,7 +93,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private object ImageFetcherManager {
|
private object ImageFetcherManager {
|
||||||
private lateinit var appContext: Context
|
|
||||||
private lateinit var cacheDir: File
|
private lateinit var cacheDir: File
|
||||||
private lateinit var fetcher: ImageFetcher
|
private lateinit var fetcher: ImageFetcher
|
||||||
private var initialized = false
|
private var initialized = false
|
||||||
@@ -110,7 +101,6 @@ private object ImageFetcherManager {
|
|||||||
if (initialized) return
|
if (initialized) return
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
if (initialized) return
|
if (initialized) return
|
||||||
appContext = context.applicationContext
|
|
||||||
cacheDir = context.cacheDir
|
cacheDir = context.cacheDir
|
||||||
fetcher = build()
|
fetcher = build()
|
||||||
HttpClientManager.addClientChangedListener(::invalidate)
|
HttpClientManager.addClientChangedListener(::invalidate)
|
||||||
@@ -143,7 +133,7 @@ private object ImageFetcherManager {
|
|||||||
return if (HttpClientManager.isMtls) {
|
return if (HttpClientManager.isMtls) {
|
||||||
OkHttpImageFetcher.create(cacheDir)
|
OkHttpImageFetcher.create(cacheDir)
|
||||||
} else {
|
} else {
|
||||||
CronetImageFetcher(appContext, cacheDir)
|
CronetImageFetcher()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,19 +151,11 @@ private sealed interface ImageFetcher {
|
|||||||
fun clearCache(onCleared: (Result<Long>) -> Unit)
|
fun clearCache(onCleared: (Result<Long>) -> Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher {
|
private class CronetImageFetcher : ImageFetcher {
|
||||||
private val ctx = context
|
|
||||||
private var engine: CronetEngine
|
|
||||||
private val executor = Executors.newFixedThreadPool(4)
|
|
||||||
private val stateLock = Any()
|
private val stateLock = Any()
|
||||||
private var activeCount = 0
|
private var activeCount = 0
|
||||||
private var draining = false
|
private var draining = false
|
||||||
private var onCacheCleared: ((Result<Long>) -> Unit)? = null
|
private var onCacheCleared: ((Result<Long>) -> Unit)? = null
|
||||||
private val storageDir = File(cacheDir, "cronet").apply { mkdirs() }
|
|
||||||
|
|
||||||
init {
|
|
||||||
engine = build(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun fetch(
|
override fun fetch(
|
||||||
url: String,
|
url: String,
|
||||||
@@ -190,30 +172,16 @@ 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 = HttpClientManager.cronetEngine!!
|
||||||
HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
|
.newUrlRequestBuilder(url, callback, HttpClientManager.cronetExecutor)
|
||||||
HttpClientManager.loadCookieHeader(url)?.let { requestBuilder.addHeader("Cookie", it) }
|
HttpClientManager.getAuthHeaders(url).forEach { (key, value) ->
|
||||||
url.toHttpUrlOrNull()?.let { httpUrl ->
|
requestBuilder.addHeader(key, value)
|
||||||
if (httpUrl.username.isNotEmpty()) {
|
|
||||||
requestBuilder.addHeader("Authorization", Credentials.basic(httpUrl.username, httpUrl.password))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val request = requestBuilder.build()
|
val request = requestBuilder.build()
|
||||||
signal.setOnCancelListener(request::cancel)
|
signal.setOnCancelListener(request::cancel)
|
||||||
request.start()
|
request.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun build(ctx: Context): CronetEngine {
|
|
||||||
return CronetEngine.Builder(ctx)
|
|
||||||
.enableHttp2(true)
|
|
||||||
.enableQuic(true)
|
|
||||||
.enableBrotli(true)
|
|
||||||
.setStoragePath(storageDir.absolutePath)
|
|
||||||
.setUserAgent(USER_AGENT)
|
|
||||||
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE_BYTES)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onComplete() {
|
private fun onComplete() {
|
||||||
val didDrain = synchronized(stateLock) {
|
val didDrain = synchronized(stateLock) {
|
||||||
activeCount--
|
activeCount--
|
||||||
@@ -236,19 +204,16 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun onDrained() {
|
private fun onDrained() {
|
||||||
engine.shutdown()
|
|
||||||
val onCacheCleared = synchronized(stateLock) {
|
val onCacheCleared = synchronized(stateLock) {
|
||||||
val onCacheCleared = onCacheCleared
|
val onCacheCleared = onCacheCleared
|
||||||
this.onCacheCleared = null
|
this.onCacheCleared = null
|
||||||
onCacheCleared
|
onCacheCleared
|
||||||
}
|
}
|
||||||
if (onCacheCleared == null) {
|
if (onCacheCleared != null) {
|
||||||
executor.shutdown()
|
val oldEngine = HttpClientManager.rebuildCronetEngine()
|
||||||
} else {
|
oldEngine.shutdown()
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
val result = runCatching { deleteFolderAndGetSize(storageDir.toPath()) }
|
val result = runCatching { deleteFolderAndGetSize(HttpClientManager.cronetStoragePath.toPath()) }
|
||||||
// Cronet is very good at self-repair, so it shouldn't fail here regardless of clear result
|
|
||||||
engine = build(ctx)
|
|
||||||
synchronized(stateLock) { draining = false }
|
synchronized(stateLock) { draining = false }
|
||||||
onCacheCleared(result)
|
onCacheCleared(result)
|
||||||
}
|
}
|
||||||
@@ -375,7 +340,7 @@ private class OkHttpImageFetcher private constructor(
|
|||||||
val dir = File(cacheDir, "okhttp")
|
val dir = File(cacheDir, "okhttp")
|
||||||
|
|
||||||
val client = HttpClientManager.getClient().newBuilder()
|
val client = HttpClientManager.getClient().newBuilder()
|
||||||
.cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES))
|
.cache(Cache(File(dir, "thumbnails"), HttpClientManager.MEDIA_CACHE_SIZE_BYTES))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
return OkHttpImageFetcher(client)
|
return OkHttpImageFetcher(client)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import BackgroundTasks
|
import BackgroundTasks
|
||||||
import Flutter
|
import Flutter
|
||||||
|
import native_video_player
|
||||||
import network_info_plus
|
import network_info_plus
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
import permission_handler_apple
|
import permission_handler_apple
|
||||||
@@ -18,6 +19,7 @@ import UIKit
|
|||||||
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
|
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SwiftNativeVideoPlayerPlugin.cookieStorage = URLSessionManager.cookieStorage
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
|
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
|
||||||
AppDelegate.registerPlugins(with: controller.engine, controller: controller)
|
AppDelegate.registerPlugins(with: controller.engine, controller: controller)
|
||||||
|
|||||||
@@ -1194,10 +1194,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.17.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1218,8 +1218,8 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2"
|
ref: cdf621bdb7edaf996e118a58a48f6441187d79c6
|
||||||
resolved-ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2"
|
resolved-ref: cdf621bdb7edaf996e118a58a48f6441187d79c6
|
||||||
url: "https://github.com/immich-app/native_video_player"
|
url: "https://github.com/immich-app/native_video_player"
|
||||||
source: git
|
source: git
|
||||||
version: "1.3.1"
|
version: "1.3.1"
|
||||||
@@ -1897,10 +1897,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.6"
|
version: "0.7.7"
|
||||||
thumbhash:
|
thumbhash:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ dependencies:
|
|||||||
native_video_player:
|
native_video_player:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/immich-app/native_video_player
|
url: https://github.com/immich-app/native_video_player
|
||||||
ref: '0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2'
|
ref: 'cdf621bdb7edaf996e118a58a48f6441187d79c6'
|
||||||
network_info_plus: ^6.1.3
|
network_info_plus: ^6.1.3
|
||||||
octo_image: ^2.1.0
|
octo_image: ^2.1.0
|
||||||
openapi:
|
openapi:
|
||||||
|
|||||||
Reference in New Issue
Block a user