From d1e90edcfb0b32d55759f6e38d50f0d286cad34b Mon Sep 17 00:00:00 2001 From: RyanShahidi Date: Sun, 6 Oct 2024 22:45:18 -0400 Subject: [PATCH 01/12] Adds Immich API authentication method --- .../models/enums/AerialMediaSource.kt | 1 + .../models/enums/ImmichAuthType.kt | 6 + .../aerialviews/models/immich/ImmichApi.kt | 16 +- .../models/prefs/ImmichMediaPrefs.kt | 4 + .../providers/ImmichMediaProvider.kt | 207 ++++++++++++++---- .../aerialviews/services/MediaService.kt | 31 ++- .../aerialviews/ui/core/ImagePlayerView.kt | 75 ++++++- .../ui/sources/ImmichVideosFragment.kt | 186 +++++++++++----- app/src/main/res/values/strings.xml | 20 ++ app/src/main/res/xml/sources.xml | 1 - .../main/res/xml/sources_immich_videos.xml | 46 +++- 11 files changed, 447 insertions(+), 146 deletions(-) create mode 100644 app/src/main/java/com/neilturner/aerialviews/models/enums/ImmichAuthType.kt diff --git a/app/src/main/java/com/neilturner/aerialviews/models/enums/AerialMediaSource.kt b/app/src/main/java/com/neilturner/aerialviews/models/enums/AerialMediaSource.kt index ef389012..ba15c8db 100644 --- a/app/src/main/java/com/neilturner/aerialviews/models/enums/AerialMediaSource.kt +++ b/app/src/main/java/com/neilturner/aerialviews/models/enums/AerialMediaSource.kt @@ -4,4 +4,5 @@ enum class AerialMediaSource { DEFAULT, SAMBA, WEBDAV, + IMMICH, } diff --git a/app/src/main/java/com/neilturner/aerialviews/models/enums/ImmichAuthType.kt b/app/src/main/java/com/neilturner/aerialviews/models/enums/ImmichAuthType.kt new file mode 100644 index 00000000..d758f119 --- /dev/null +++ b/app/src/main/java/com/neilturner/aerialviews/models/enums/ImmichAuthType.kt @@ -0,0 +1,6 @@ +package com.neilturner.aerialviews.models.enums + +enum class ImmichAuthType { + SHARED_LINK, + API_KEY +} \ No newline at end of file diff --git a/app/src/main/java/com/neilturner/aerialviews/models/immich/ImmichApi.kt b/app/src/main/java/com/neilturner/aerialviews/models/immich/ImmichApi.kt index 46be092a..ed93fe95 100644 --- a/app/src/main/java/com/neilturner/aerialviews/models/immich/ImmichApi.kt +++ b/app/src/main/java/com/neilturner/aerialviews/models/immich/ImmichApi.kt @@ -1,4 +1,5 @@ package com.neilturner.aerialviews.models.immich +import com.google.gson.annotations.SerializedName data class ExifInfo( val description: String? = null, @@ -15,8 +16,21 @@ data class Asset( ) data class Album( + @SerializedName("id") val id: String = "", + + @SerializedName("albumName") + val name: String = "", + + @SerializedName("description") val description: String? = null, + + @SerializedName("shared") val type: String? = null, - val assets: List, + + @SerializedName("assets") + val assets: List = emptyList(), + + @SerializedName("assetCount") + val assetCount: Int = 0 ) diff --git a/app/src/main/java/com/neilturner/aerialviews/models/prefs/ImmichMediaPrefs.kt b/app/src/main/java/com/neilturner/aerialviews/models/prefs/ImmichMediaPrefs.kt index 5a28e7ca..5d9ab81e 100644 --- a/app/src/main/java/com/neilturner/aerialviews/models/prefs/ImmichMediaPrefs.kt +++ b/app/src/main/java/com/neilturner/aerialviews/models/prefs/ImmichMediaPrefs.kt @@ -2,6 +2,7 @@ package com.neilturner.aerialviews.models.prefs import com.chibatching.kotpref.KotprefModel import com.chibatching.kotpref.enumpref.nullableEnumValuePref +import com.neilturner.aerialviews.models.enums.ImmichAuthType import com.neilturner.aerialviews.models.enums.ProviderMediaType import com.neilturner.aerialviews.models.enums.SchemeType @@ -14,4 +15,7 @@ object ImmichMediaPrefs : KotprefModel() { var hostName by stringPref("", "immich_media_hostname") var pathName by stringPref("", "immich_media_pathname") var password by stringPref("", "immich_media_password") + var authType by nullableEnumValuePref(ImmichAuthType.SHARED_LINK, "immich_media_auth_type") + var apiKey by stringPref("", "immich_media_api_key") + var selectedAlbumId by stringPref("", "immich_media_selected_album_id") } diff --git a/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt b/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt index 9aa3b4c9..831a2b40 100644 --- a/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt +++ b/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt @@ -3,7 +3,9 @@ package com.neilturner.aerialviews.providers import android.content.Context import android.net.Uri import com.neilturner.aerialviews.R +import com.neilturner.aerialviews.models.enums.AerialMediaSource import com.neilturner.aerialviews.models.enums.AerialMediaType +import com.neilturner.aerialviews.models.enums.ImmichAuthType import com.neilturner.aerialviews.models.enums.ProviderMediaType import com.neilturner.aerialviews.models.enums.ProviderSourceType import com.neilturner.aerialviews.models.immich.Album @@ -12,23 +14,31 @@ import com.neilturner.aerialviews.models.videos.AerialMedia import com.neilturner.aerialviews.models.videos.VideoMetadata import com.neilturner.aerialviews.utils.FileHelper import com.neilturner.aerialviews.utils.toStringOrEmpty +import okhttp3.OkHttpClient import retrofit2.Response import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Path import retrofit2.http.Query import timber.log.Timber +import java.security.SecureRandom +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager class ImmichMediaProvider( context: Context, private val prefs: ImmichMediaPrefs, ) : MediaProvider(context) { + override val type = ProviderSourceType.REMOTE override val enabled: Boolean get() = prefs.enabled private lateinit var server: String - private lateinit var key: String private lateinit var apiInterface: ImmichService init { @@ -38,74 +48,92 @@ class ImmichMediaProvider( } } - private object RetrofitInstance { - fun getInstance(host: String): Retrofit = - Retrofit - .Builder() - .baseUrl(host) - .addConverterFactory(GsonConverterFactory.create()) - .build() - } - private interface ImmichService { @GET("/api/shared-links/me") - suspend fun getAlbum( + suspend fun getSharedAlbum( @Query("key") key: String, @Query("password") password: String?, ): Response + + @GET("/api/albums") + suspend fun getAlbums(@Header("x-api-key") apiKey: String): Response> + + @GET("/api/albums/{id}") + suspend fun getAlbum( + @Header("x-api-key") apiKey: String, + @Path("id") albumId: String + ): Response } private fun parsePrefs() { server = prefs.scheme?.toStringOrEmpty()?.lowercase() + "://" + prefs.hostName - - val pattern = Regex("(^/)?(share/)?(.*)") - val replacement = "$3" - key = pattern.replace(prefs.pathName, replacement) } private fun getApiInterface() { + val trustAllCerts = arrayOf(object : X509TrustManager { + override fun checkClientTrusted( + chain: Array?, + authType: String? + ) { + } + + override fun checkServerTrusted( + chain: Array?, + authType: String? + ) { + } + + override fun getAcceptedIssuers(): Array = arrayOf() + }) + + val sslContext = SSLContext.getInstance("SSL").apply { + init(null, trustAllCerts, SecureRandom()) + } + + val okHttpClient = OkHttpClient.Builder() + .sslSocketFactory(sslContext.socketFactory, trustAllCerts[0] as X509TrustManager) + .hostnameVerifier { _, _ -> true } + .build() + Timber.i("Connecting to $server") try { - apiInterface = - RetrofitInstance.getInstance(server).create(ImmichService::class.java) + apiInterface = Retrofit.Builder() + .baseUrl(server) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(ImmichService::class.java) } catch (e: Exception) { Timber.e(e, e.message.toString()) } } override suspend fun fetchMedia(): List = fetchImmichMedia().first - override suspend fun fetchTest(): String = fetchImmichMedia().second - override suspend fun fetchMetadata(): List = emptyList() private suspend fun fetchImmichMedia(): Pair, String> { val media = mutableListOf() - // Check hostname - // Validate IP address or hostname? if (prefs.hostName.isEmpty()) { return Pair(media, "Hostname and port not specified") } - // Check path name - if (prefs.pathName.isEmpty()) { - return Pair(media, "Path name not specified") - } - - val immichMedia = - try { - getAlbumFromAPI() - } catch (e: Exception) { - Timber.e(e, e.message.toString()) - return Pair(emptyList(), e.message.toString()) + val immichMedia = try { + when (prefs.authType) { + ImmichAuthType.SHARED_LINK -> getSharedAlbumFromAPI() + ImmichAuthType.API_KEY -> getSelectedAlbumFromAPI() + null -> return Pair(emptyList(), "Invalid authentication type") } + } catch (e: Exception) { + Timber.e(e, e.message.toString()) + return Pair(emptyList(), e.message.toString()) + } var excluded = 0 var videos = 0 var images = 0 - // Create Immich URL, add to media list, adding media type immichMedia.assets.forEach lit@{ asset -> val uri = getAssetUri(asset.id) val filename = Uri.parse(asset.originalPath) @@ -127,6 +155,7 @@ class ImmichMediaProvider( } val item = AerialMedia(uri, description, poi) + item.source = AerialMediaSource.IMMICH if (FileHelper.isSupportedVideoType(asset.originalPath.toString())) { item.type = AerialMediaType.VIDEO videos++ @@ -140,32 +169,114 @@ class ImmichMediaProvider( media.add(item) } - var message = String.format(context.getString(R.string.immich_media_test_summary1), media.size.toString()) + "\n" - message += String.format(context.getString(R.string.immich_media_test_summary2), excluded.toString()) + "\n" + var message = String.format( + context.getString(R.string.immich_media_test_summary1), + media.size.toString() + ) + "\n" + message += String.format( + context.getString(R.string.immich_media_test_summary2), + excluded.toString() + ) + "\n" if (prefs.mediaType != ProviderMediaType.PHOTOS) { - message += String.format(context.getString(R.string.immich_media_test_summary3), videos.toString()) + "\n" + message += String.format( + context.getString(R.string.immich_media_test_summary3), + videos.toString() + ) + "\n" } if (prefs.mediaType != ProviderMediaType.VIDEOS) { - message += String.format(context.getString(R.string.immich_media_test_summary4), images.toString()) + "\n" + message += String.format( + context.getString(R.string.immich_media_test_summary4), + images.toString() + ) + "\n" + } Timber.i("Media found: ${media.size}") return Pair(media, message) } - private suspend fun getAlbumFromAPI(): Album { - lateinit var album: Album + private suspend fun getSharedAlbumFromAPI(): Album { try { - val response = apiInterface.getAlbum(key = key, password = prefs.password) - val body = response.body() - if (body != null) { - album = body + Timber.d("Fetching shared album with key: ${prefs.pathName}") + val response = + apiInterface.getSharedAlbum(key = prefs.pathName, password = prefs.password) + Timber.d("Shared album API response: ${response.raw().toString()}") + if (response.isSuccessful) { + val album = response.body() + Timber.d("Shared album fetched successfully: ${album?.toString()}") + return album ?: throw Exception("Empty response body") + } else { + val errorBody = response.errorBody()?.string() + Timber.e("API error: ${response.code()} - ${response.message()}") + Timber.e("Error body: $errorBody") + throw Exception("API error: ${response.code()} - ${response.message()}") } - } catch (exception: Exception) { - Timber.i(exception.message.toString()) + } catch (e: Exception) { + Timber.e(e, "Error fetching shared album: ${e.message}") + throw e + } + } + + private suspend fun getSelectedAlbumFromAPI(): Album { + try { + val selectedAlbumId = prefs.selectedAlbumId + Timber.d("Attempting to fetch selected album") + Timber.d("Selected Album ID: $selectedAlbumId") + Timber.d("API Key (first 5 chars): ${prefs.apiKey.take(5)}...") + val response = apiInterface.getAlbum(apiKey = prefs.apiKey, albumId = selectedAlbumId) + Timber.d("API Request URL: ${response.raw().request.url}") + Timber.d("API Request Method: ${response.raw().request.method}") + Timber.d("API Request Headers: ${response.raw().request.headers}") + if (response.isSuccessful) { + val album = response.body() + if (album != null) { + Timber.d("Successfully fetched album: ${album.name}, assets: ${album.assets.size}") + return album + } else { + Timber.e("Received null album from successful response") + throw Exception("Received null album from API") + } + } else { + val errorBody = response.errorBody()?.string() + Timber.e("Failed to fetch album. Code: ${response.code()}, Error: $errorBody") + throw Exception("Failed to fetch selected album: ${response.code()} - ${response.message()}") + } + } catch (e: Exception) { + Timber.e(e, "Exception while fetching selected album") + throw Exception("Failed to fetch selected album", e) + } + } + + suspend fun fetchAlbums(): List { + try { + val response = apiInterface.getAlbums(apiKey = prefs.apiKey) + if (response.isSuccessful) { + val albums = response.body() + Timber.d("API Response: ${albums?.toString()}") + albums?.forEach { album -> + Timber.d("Album: id=${album.id}, name=${album.name}, assetCount=${album.assetCount}, type=${album.type}") + } + return albums ?: emptyList() + } else { + Timber.e("Error fetching albums: ${response.code()} - ${response.message()}") + Timber.e("Error body: ${response.errorBody()?.string()}") + throw Exception("Failed to fetch albums: ${response.code()}") + } + } catch (e: Exception) { + Timber.e(e, "Exception while fetching albums") + throw e + } + } + + private fun getAssetUri(id: String): Uri { + return when (prefs.authType) { + ImmichAuthType.SHARED_LINK -> Uri.parse("$server/api/assets/$id/original?key=${prefs.pathName}&password=${prefs.password}") + ImmichAuthType.API_KEY -> { + Uri.parse("$server/api/assets/$id/original") + } + + null -> throw IllegalStateException("Invalid authentication type") } - return album } - private fun getAssetUri(id: String): Uri = Uri.parse(server + "/api/assets/$id/original?key=$key&password=${prefs.password}") -} +} \ No newline at end of file diff --git a/app/src/main/java/com/neilturner/aerialviews/services/MediaService.kt b/app/src/main/java/com/neilturner/aerialviews/services/MediaService.kt index 289b7944..8437afa0 100644 --- a/app/src/main/java/com/neilturner/aerialviews/services/MediaService.kt +++ b/app/src/main/java/com/neilturner/aerialviews/services/MediaService.kt @@ -2,6 +2,7 @@ package com.neilturner.aerialviews.services import android.content.Context import com.neilturner.aerialviews.models.MediaPlaylist +import com.neilturner.aerialviews.models.enums.AerialMediaSource import com.neilturner.aerialviews.models.enums.AerialMediaType import com.neilturner.aerialviews.models.enums.DescriptionFilenameType import com.neilturner.aerialviews.models.enums.DescriptionManifestType @@ -28,9 +29,8 @@ import com.neilturner.aerialviews.utils.FileHelper import com.neilturner.aerialviews.utils.filenameWithoutExtension import timber.log.Timber -class MediaService( - val context: Context, -) { +class MediaService(val context: Context) { + private val providers = mutableListOf() init { @@ -41,12 +41,10 @@ class MediaService( providers.add(WebDavMediaProvider(context, WebDavMediaPrefs)) providers.add(ImmichMediaProvider(context, ImmichMediaPrefs)) providers.add(AppleMediaProvider(context, AppleVideoPrefs)) - // Sort by local first so duplicates removed are remote providers.sortBy { it.type == ProviderSourceType.REMOTE } } suspend fun fetchMedia(): MediaPlaylist { - // Find all videos from all providers/sources var media = mutableListOf() providers.forEach { try { @@ -58,14 +56,17 @@ class MediaService( } } - // Remove duplicates based on filename only if (GeneralPrefs.removeDuplicates) { val numVideos = media.size - media = media.distinctBy { Pair(it.uri.filenameWithoutExtension.lowercase(), it.type) }.toMutableList() - Timber.i("Duplicate videos removed based on filename: ${numVideos - media.size}") + media = media.distinctBy { + when (it.source) { + AerialMediaSource.IMMICH -> it.uri.toString() + else -> Pair(it.uri.filenameWithoutExtension.lowercase(), it.type) + } + }.toMutableList() + Timber.i("Duplicate videos removed: ${numVideos - media.size}") } - // Add metadata to (Manifest) Apple, Community videos only val manifestDescriptionStyle = GeneralPrefs.descriptionVideoManifestStyle ?: DescriptionManifestType.DISABLED val (matched, unmatched) = addMetadataToManifestVideos(media, providers, manifestDescriptionStyle) Timber.i("Manifest: matched ${matched.size}, unmatched ${unmatched.size}") @@ -73,24 +74,19 @@ class MediaService( var (videos, photos) = unmatched.partition { it.type == AerialMediaType.VIDEO } Timber.i("Unmatched: videos ${videos.size}, photos ${photos.size}") - // Remove if not Apple or Community videos if (GeneralPrefs.ignoreNonManifestVideos) { videos = listOf() Timber.i("Removing non-manifest videos") } - // Add description to user videos val videoDescriptionStyle = GeneralPrefs.descriptionVideoFilenameStyle ?: DescriptionFilenameType.DISABLED videos = addFilenameAsDescriptionToMedia(videos, videoDescriptionStyle) - // Add description to user images val photoDescriptionStyle = GeneralPrefs.descriptionPhotoFilenameStyle ?: DescriptionFilenameType.DISABLED photos = addFilenameAsDescriptionToMedia(photos, photoDescriptionStyle) - // Combine all videos and photos var filteredMedia = matched + videos + photos - // Randomise video order if (GeneralPrefs.shuffleVideos) { filteredMedia = filteredMedia.shuffled() Timber.i("Shuffling media items") @@ -103,7 +99,7 @@ class MediaService( private suspend fun addMetadataToManifestVideos( media: List, providers: List, - description: DescriptionManifestType, + description: DescriptionManifestType ): Pair, List> { val metadata = mutableListOf() val matched = mutableListOf() @@ -111,13 +107,12 @@ class MediaService( providers.forEach { try { - metadata.addAll((it.fetchMetadata())) + metadata.addAll(it.fetchMetadata()) } catch (ex: Exception) { Timber.e(ex, "Exception while fetching metadata") } } - // Find video id in metadata list media.forEach video@{ video -> metadata.forEach { metadata -> if (video.type == AerialMediaType.VIDEO && @@ -138,7 +133,7 @@ class MediaService( private fun addFilenameAsDescriptionToMedia( media: List, - description: DescriptionFilenameType, + description: DescriptionFilenameType ): List { when (description) { DescriptionFilenameType.FILENAME -> { diff --git a/app/src/main/java/com/neilturner/aerialviews/ui/core/ImagePlayerView.kt b/app/src/main/java/com/neilturner/aerialviews/ui/core/ImagePlayerView.kt index bcf82787..10ac4f0c 100644 --- a/app/src/main/java/com/neilturner/aerialviews/ui/core/ImagePlayerView.kt +++ b/app/src/main/java/com/neilturner/aerialviews/ui/core/ImagePlayerView.kt @@ -2,7 +2,6 @@ package com.neilturner.aerialviews.ui.core import android.content.Context import android.net.Uri -import android.os.Build import android.util.AttributeSet import androidx.appcompat.widget.AppCompatImageView import coil.EventListener @@ -18,8 +17,10 @@ import com.hierynomus.mssmb2.SMB2ShareAccess import com.hierynomus.smbj.SMBClient import com.hierynomus.smbj.share.DiskShare import com.neilturner.aerialviews.models.enums.AerialMediaSource +import com.neilturner.aerialviews.models.enums.ImmichAuthType import com.neilturner.aerialviews.models.enums.PhotoScale import com.neilturner.aerialviews.models.prefs.GeneralPrefs +import com.neilturner.aerialviews.models.prefs.ImmichMediaPrefs import com.neilturner.aerialviews.models.prefs.SambaMediaPrefs import com.neilturner.aerialviews.models.prefs.WebDavMediaPrefs import com.neilturner.aerialviews.models.videos.AerialMedia @@ -29,8 +30,15 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import okhttp3.Interceptor +import okhttp3.OkHttpClient import timber.log.Timber +import java.security.SecureRandom +import java.security.cert.X509Certificate import java.util.EnumSet +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager class ImagePlayerView : AppCompatImageView, @@ -44,17 +52,54 @@ class ImagePlayerView : constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) - private var imageLoader: ImageLoader = - ImageLoader - .Builder(context) + private val imageLoader: ImageLoader by lazy { + ImageLoader.Builder(context) .eventListener(this) .components { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) { add(ImageDecoderDecoder.Factory()) } else { add(GifDecoder.Factory()) } - }.build() + } + .okHttpClient { + // Create a trust manager that does not validate certificate chains + val trustAllCerts = arrayOf(object : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + override fun getAcceptedIssuers(): Array = arrayOf() + }) + + // Install the all-trusting trust manager + val sslContext = SSLContext.getInstance("SSL") + sslContext.init(null, trustAllCerts, SecureRandom()) + + // Create an ssl socket factory with our all-trusting manager + val sslSocketFactory = sslContext.socketFactory + + OkHttpClient.Builder() + .sslSocketFactory(sslSocketFactory, trustAllCerts[0] as X509TrustManager) + .hostnameVerifier { _, _ -> true } + .addInterceptor(ApiKeyInterceptor()) + .build() + } + .build() + } + + private class ApiKeyInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): okhttp3.Response { + val originalRequest = chain.request() + val newRequest = when (ImmichMediaPrefs.authType) { + ImmichAuthType.API_KEY -> { + originalRequest.newBuilder() + .addHeader("X-API-Key", ImmichMediaPrefs.apiKey) + .build() + } + else -> originalRequest + } + return chain.proceed(newRequest) + } + } init { val scaleType = @@ -100,15 +145,21 @@ class ImagePlayerView : coroutineScope.launch { loadImage(media.uri) } } } + + val request = ImageRequest.Builder(context) + .data(media.uri) + .target(this) + .build() + + imageLoader.enqueue(request) } private suspend fun loadImage(uri: Uri) { - val request = - ImageRequest - .Builder(context) - .target(this) - request.data(uri) - imageLoader.execute(request.build()) + val request = ImageRequest.Builder(context) + .data(uri) + .target(this) + .build() + imageLoader.execute(request) } private suspend fun loadSambaImage(uri: Uri) { diff --git a/app/src/main/java/com/neilturner/aerialviews/ui/sources/ImmichVideosFragment.kt b/app/src/main/java/com/neilturner/aerialviews/ui/sources/ImmichVideosFragment.kt index 3236d3c2..81253edb 100644 --- a/app/src/main/java/com/neilturner/aerialviews/ui/sources/ImmichVideosFragment.kt +++ b/app/src/main/java/com/neilturner/aerialviews/ui/sources/ImmichVideosFragment.kt @@ -5,30 +5,48 @@ import android.os.Bundle import androidx.appcompat.app.AlertDialog import androidx.lifecycle.lifecycleScope import androidx.preference.EditTextPreference +import androidx.preference.ListPreference import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat -import androidx.preference.PreferenceManager import com.neilturner.aerialviews.R +import com.neilturner.aerialviews.models.enums.ImmichAuthType +import com.neilturner.aerialviews.models.immich.Album import com.neilturner.aerialviews.models.prefs.ImmichMediaPrefs import com.neilturner.aerialviews.providers.ImmichMediaProvider -import com.neilturner.aerialviews.utils.toStringOrEmpty import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import timber.log.Timber class ImmichVideosFragment : PreferenceFragmentCompat(), - SharedPreferences.OnSharedPreferenceChangeListener, - PreferenceManager.OnPreferenceTreeClickListener { - override fun onCreatePreferences( - savedInstanceState: Bundle?, - rootKey: String?, - ) { + SharedPreferences.OnSharedPreferenceChangeListener { + + private lateinit var authTypePreference: ListPreference + private lateinit var hostnamePreference: EditTextPreference + private lateinit var schemePreference: ListPreference + private lateinit var pathnamePreference: EditTextPreference + private lateinit var passwordPreference: EditTextPreference + private lateinit var apiKeyPreference: EditTextPreference + private lateinit var selectAlbumPreference: Preference + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.sources_immich_videos, rootKey) preferenceManager.sharedPreferences?.registerOnSharedPreferenceChangeListener(this) + authTypePreference = findPreference("immich_media_auth_type")!! + hostnamePreference = findPreference("immich_media_hostname")!! + schemePreference = findPreference("immich_media_scheme")!! + pathnamePreference = findPreference("immich_media_pathname")!! + passwordPreference = findPreference("immich_media_password")!! + apiKeyPreference = findPreference("immich_media_api_key")!! + selectAlbumPreference = findPreference("immich_media_select_album")!! + limitTextInput() + updateAuthTypeVisibility() updateSummary() + + setupPreferenceClickListeners() } override fun onDestroy() { @@ -36,76 +54,128 @@ class ImmichVideosFragment : super.onDestroy() } - override fun onPreferenceTreeClick(preference: Preference): Boolean { - if (preference.key.isNullOrEmpty()) { - return super.onPreferenceTreeClick(preference) + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) { + updateSummary() + if (key == "immich_media_auth_type") { + updateAuthTypeVisibility() } + } - if (preference.key.contains("immich_media_test_connection")) { + private fun setupPreferenceClickListeners() { + findPreference("immich_media_test_connection")?.setOnPreferenceClickListener { lifecycleScope.launch { testImmichConnection() } - return true + true } - return super.onPreferenceTreeClick(preference) + selectAlbumPreference.setOnPreferenceClickListener { + lifecycleScope.launch { selectAlbum() } + true + } } - override fun onSharedPreferenceChanged( - sharedPreferences: SharedPreferences, - key: String?, - ) { - updateSummary() + private fun updateSummary() { + updateHostnameSummary() + updatePathnameSummary() + updatePasswordSummary() + updateApiKeySummary() + updateSelectedAlbumSummary() } - private fun updateSummary() { - // Host name - val hostname = findPreference("immich_media_hostname") - if (hostname?.text.toStringOrEmpty().isNotEmpty()) { - hostname?.summary = hostname?.text + private fun updateHostnameSummary() { + hostnamePreference.summary = if (hostnamePreference.text.isNullOrEmpty()) { + getString(R.string.immich_media_hostname_summary) + } else { + hostnamePreference.text + } + } + + private fun updatePathnameSummary() { + pathnamePreference.summary = if (pathnamePreference.text.isNullOrEmpty()) { + getString(R.string.immich_media_pathname_summary) } else { - hostname?.summary = getString(R.string.immich_media_hostname_summary) + pathnamePreference.text } + } - // Path name - val pathname = findPreference("immich_media_pathname") - if (pathname?.text.toStringOrEmpty().isNotEmpty()) { - val fixedShareName = ImmichMediaPrefs.pathName - ImmichMediaPrefs.pathName = fixedShareName - pathname?.summary = fixedShareName - pathname?.text = fixedShareName + private fun updatePasswordSummary() { + passwordPreference.summary = if (passwordPreference.text.isNullOrEmpty()) { + getString(R.string.immich_media_password_summary) } else { - pathname?.summary = getString(R.string.immich_media_pathname_summary) + "*".repeat(passwordPreference.text!!.length) } + } - // Password - val password = findPreference("immich_media_password") - if (password?.text.toStringOrEmpty().isNotEmpty()) { - password?.summary = "*".repeat(ImmichMediaPrefs.password.length) + private fun updateApiKeySummary() { + apiKeyPreference.summary = if (apiKeyPreference.text.isNullOrEmpty()) { + getString(R.string.immich_media_api_key_summary) } else { - password?.summary = getString(R.string.immich_media_password_summary) + "*".repeat(apiKeyPreference.text!!.length) + } + } + + private fun updateSelectedAlbumSummary() { + selectAlbumPreference.summary = if (ImmichMediaPrefs.selectedAlbumId.isEmpty()) { + getString(R.string.immich_media_select_album_summary) + } else { + getString(R.string.immich_media_selected_album, ImmichMediaPrefs.selectedAlbumId) + } + } + + private fun updateAuthTypeVisibility() { + val authType = ImmichAuthType.valueOf(authTypePreference.value) + when (authType) { + ImmichAuthType.SHARED_LINK -> { + pathnamePreference.isVisible = true + passwordPreference.isVisible = true + apiKeyPreference.isVisible = false + selectAlbumPreference.isVisible = false + } + ImmichAuthType.API_KEY -> { + pathnamePreference.isVisible = false + passwordPreference.isVisible = false + apiKeyPreference.isVisible = true + selectAlbumPreference.isVisible = true + } } } private fun limitTextInput() { - preferenceScreen - .findPreference("immich_media_hostname") - ?.setOnBindEditTextListener { it.setSingleLine() } - preferenceScreen - .findPreference("immich_media_path") - ?.setOnBindEditTextListener { it.setSingleLine() } - preferenceScreen - .findPreference("immich_media_username") - ?.setOnBindEditTextListener { it.setSingleLine() } - preferenceScreen - .findPreference("immich_media_password") - ?.setOnBindEditTextListener { it.setSingleLine() } - } - - private suspend fun testImmichConnection() = - withContext(Dispatchers.IO) { - val provider = ImmichMediaProvider(requireContext(), ImmichMediaPrefs) - val result = provider.fetchTest() - showDialog(resources.getString(R.string.immich_media_test_results), result) + listOf( + "immich_media_hostname", + "immich_media_pathname", + "immich_media_password", + "immich_media_api_key" + ).forEach { key -> + findPreference(key)?.setOnBindEditTextListener { it.setSingleLine() } + } + } + + private suspend fun testImmichConnection() = withContext(Dispatchers.IO) { + val provider = ImmichMediaProvider(requireContext(), ImmichMediaPrefs) + val result = provider.fetchTest() + showDialog(resources.getString(R.string.immich_media_test_results), result) + } + + private suspend fun selectAlbum() = withContext(Dispatchers.IO) { + val provider = ImmichMediaProvider(requireContext(), ImmichMediaPrefs) + val albums = provider.fetchAlbums() + showAlbumSelectionDialog(albums) + } + + private suspend fun showAlbumSelectionDialog(albums: List) = withContext(Dispatchers.Main) { + Timber.d("Showing album selection dialog with ${albums.size} albums") + val albumNames = albums.map { "${it.name} (${it.assetCount} assets)" }.toTypedArray() + AlertDialog.Builder(requireContext()).apply { + setTitle(R.string.immich_media_select_album) + setSingleChoiceItems(albumNames, -1) { dialog, which -> + ImmichMediaPrefs.selectedAlbumId = albums[which].id + dialog.dismiss() + updateSummary() + } + setNegativeButton(R.string.button_cancel, null) + create().show() } + } private suspend fun showDialog( title: String = "", @@ -118,4 +188,4 @@ class ImmichVideosFragment : create().show() } } -} +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e757801c..06ade6c3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -242,6 +242,10 @@ Use Immich share media Choose media type Test connection settings + Authentication Method + API Key + Enter your Immich API key + Select Album Scheme Hostname & port @@ -262,6 +266,22 @@ Test results + Server Authentication Method + Authentication Information + Authentication Test + No album selected + Selected album ID: %1$s + + + + Shared Link + API Key + + + SHARED_LINK + API_KEY + + Overlays Choose what overlays are displayed in different corners of the screen diff --git a/app/src/main/res/xml/sources.xml b/app/src/main/res/xml/sources.xml index 5da8ade1..687c949e 100644 --- a/app/src/main/res/xml/sources.xml +++ b/app/src/main/res/xml/sources.xml @@ -38,7 +38,6 @@ app:fragment="com.neilturner.aerialviews.ui.sources.WebDavVideosFragment"/> diff --git a/app/src/main/res/xml/sources_immich_videos.xml b/app/src/main/res/xml/sources_immich_videos.xml index 645a42b8..e1ad8989 100644 --- a/app/src/main/res/xml/sources_immich_videos.xml +++ b/app/src/main/res/xml/sources_immich_videos.xml @@ -17,15 +17,9 @@ app:key="immich_media_type" app:title="@string/immich_media_type_title" app:useSimpleSummaryProvider="true" /> - - - + + + + + + + + + + + + + + + - + \ No newline at end of file From b9005ffdc5cea27cc553ab8770d27652dad89331 Mon Sep 17 00:00:00 2001 From: RyanShahidi Date: Mon, 7 Oct 2024 17:11:39 -0400 Subject: [PATCH 02/12] Changes selected album from UUID to the album name --- .../neilturner/aerialviews/models/prefs/ImmichMediaPrefs.kt | 1 + .../neilturner/aerialviews/ui/sources/ImmichVideosFragment.kt | 3 ++- app/src/main/res/values/strings.xml | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/neilturner/aerialviews/models/prefs/ImmichMediaPrefs.kt b/app/src/main/java/com/neilturner/aerialviews/models/prefs/ImmichMediaPrefs.kt index 5d9ab81e..78f4a8bd 100644 --- a/app/src/main/java/com/neilturner/aerialviews/models/prefs/ImmichMediaPrefs.kt +++ b/app/src/main/java/com/neilturner/aerialviews/models/prefs/ImmichMediaPrefs.kt @@ -18,4 +18,5 @@ object ImmichMediaPrefs : KotprefModel() { var authType by nullableEnumValuePref(ImmichAuthType.SHARED_LINK, "immich_media_auth_type") var apiKey by stringPref("", "immich_media_api_key") var selectedAlbumId by stringPref("", "immich_media_selected_album_id") + var selectedAlbumName by stringPref("", "immich_media_selected_album_name") } diff --git a/app/src/main/java/com/neilturner/aerialviews/ui/sources/ImmichVideosFragment.kt b/app/src/main/java/com/neilturner/aerialviews/ui/sources/ImmichVideosFragment.kt index 81253edb..a6e0fd21 100644 --- a/app/src/main/java/com/neilturner/aerialviews/ui/sources/ImmichVideosFragment.kt +++ b/app/src/main/java/com/neilturner/aerialviews/ui/sources/ImmichVideosFragment.kt @@ -117,7 +117,7 @@ class ImmichVideosFragment : selectAlbumPreference.summary = if (ImmichMediaPrefs.selectedAlbumId.isEmpty()) { getString(R.string.immich_media_select_album_summary) } else { - getString(R.string.immich_media_selected_album, ImmichMediaPrefs.selectedAlbumId) + getString(R.string.immich_media_selected_album, ImmichMediaPrefs.selectedAlbumName) } } @@ -169,6 +169,7 @@ class ImmichVideosFragment : setTitle(R.string.immich_media_select_album) setSingleChoiceItems(albumNames, -1) { dialog, which -> ImmichMediaPrefs.selectedAlbumId = albums[which].id + ImmichMediaPrefs.selectedAlbumName = albums[which].name dialog.dismiss() updateSummary() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 06ade6c3..aa3eade5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -270,7 +270,7 @@ Authentication Information Authentication Test No album selected - Selected album ID: %1$s + Selected album: %1$s From 927cfd6fbf00c765312a90a0656eba9d4ac32997 Mon Sep 17 00:00:00 2001 From: RyanShahidi Date: Mon, 7 Oct 2024 17:21:07 -0400 Subject: [PATCH 03/12] Fixes Choose Media Type not having effect for Immich --- .../providers/ImmichMediaProvider.kt | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt b/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt index 831a2b40..30aa7b1f 100644 --- a/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt +++ b/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt @@ -156,17 +156,27 @@ class ImmichMediaProvider( val item = AerialMedia(uri, description, poi) item.source = AerialMediaSource.IMMICH - if (FileHelper.isSupportedVideoType(asset.originalPath.toString())) { - item.type = AerialMediaType.VIDEO - videos++ - } else if (FileHelper.isSupportedImageType(asset.originalPath.toString())) { - item.type = AerialMediaType.IMAGE - images++ - } else { - excluded++ - return@lit + + when { + FileHelper.isSupportedVideoType(asset.originalPath.toString()) -> { + item.type = AerialMediaType.VIDEO + videos++ + if (prefs.mediaType != ProviderMediaType.PHOTOS) { + media.add(item) + } + } + FileHelper.isSupportedImageType(asset.originalPath.toString()) -> { + item.type = AerialMediaType.IMAGE + images++ + if (prefs.mediaType != ProviderMediaType.VIDEOS) { + media.add(item) + } + } + else -> { + excluded++ + return@lit + } } - media.add(item) } var message = String.format( @@ -188,7 +198,6 @@ class ImmichMediaProvider( context.getString(R.string.immich_media_test_summary4), images.toString() ) + "\n" - } Timber.i("Media found: ${media.size}") From d1515494712643a1785496c2fbaaeae7a4f97247 Mon Sep 17 00:00:00 2001 From: RyanShahidi Date: Mon, 7 Oct 2024 18:10:17 -0400 Subject: [PATCH 04/12] Refine Immich shared link key cleaning process to handle various user input formats --- .../providers/ImmichMediaProvider.kt | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt b/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt index 30aa7b1f..dbc8deb5 100644 --- a/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt +++ b/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt @@ -206,9 +206,9 @@ class ImmichMediaProvider( private suspend fun getSharedAlbumFromAPI(): Album { try { - Timber.d("Fetching shared album with key: ${prefs.pathName}") - val response = - apiInterface.getSharedAlbum(key = prefs.pathName, password = prefs.password) + val cleanedKey = cleanSharedLinkKey(prefs.pathName) + Timber.d("Fetching shared album with key: $cleanedKey") + val response = apiInterface.getSharedAlbum(key = cleanedKey, password = prefs.password) Timber.d("Shared album API response: ${response.raw().toString()}") if (response.isSuccessful) { val album = response.body() @@ -277,13 +277,17 @@ class ImmichMediaProvider( } } + private fun cleanSharedLinkKey(input: String): String { + return input.trim() + .replace(Regex("^/+|/+$"), "") // Remove leading and trailing slashes + .replace(Regex("^share/|^/share/"), "") // Remove "share/" or "/share/" from the beginning + } + private fun getAssetUri(id: String): Uri { + val cleanedKey = cleanSharedLinkKey(prefs.pathName) return when (prefs.authType) { - ImmichAuthType.SHARED_LINK -> Uri.parse("$server/api/assets/$id/original?key=${prefs.pathName}&password=${prefs.password}") - ImmichAuthType.API_KEY -> { - Uri.parse("$server/api/assets/$id/original") - } - + ImmichAuthType.SHARED_LINK -> Uri.parse("$server/api/assets/$id/original?key=$cleanedKey&password=${prefs.password}") + ImmichAuthType.API_KEY -> Uri.parse("$server/api/assets/$id/original") null -> throw IllegalStateException("Invalid authentication type") } } From eb561acd59482324c34db681b4a22cb2f10a33e1 Mon Sep 17 00:00:00 2001 From: RyanShahidi Date: Mon, 7 Oct 2024 18:57:03 -0400 Subject: [PATCH 05/12] Improve error handling in Immich integration by displaying detailed API error messages when selecting album --- .../aerialviews/models/immich/ImmichApi.kt | 7 +++++ .../providers/ImmichMediaProvider.kt | 27 +++++++++-------- .../ui/sources/ImmichVideosFragment.kt | 29 +++++++++++++++++-- app/src/main/res/values/strings.xml | 4 +++ 4 files changed, 50 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/neilturner/aerialviews/models/immich/ImmichApi.kt b/app/src/main/java/com/neilturner/aerialviews/models/immich/ImmichApi.kt index ed93fe95..2dd80d91 100644 --- a/app/src/main/java/com/neilturner/aerialviews/models/immich/ImmichApi.kt +++ b/app/src/main/java/com/neilturner/aerialviews/models/immich/ImmichApi.kt @@ -34,3 +34,10 @@ data class Album( @SerializedName("assetCount") val assetCount: Int = 0 ) + +data class ErrorResponse( + val message: String = "", + val error: String = "", + val statusCode: Int = 0, + @SerializedName("correlationId") val correlationId: String = "" +) diff --git a/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt b/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt index dbc8deb5..3162cfff 100644 --- a/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt +++ b/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt @@ -1,7 +1,7 @@ package com.neilturner.aerialviews.providers - import android.content.Context import android.net.Uri +import com.google.gson.Gson import com.neilturner.aerialviews.R import com.neilturner.aerialviews.models.enums.AerialMediaSource import com.neilturner.aerialviews.models.enums.AerialMediaType @@ -9,6 +9,7 @@ import com.neilturner.aerialviews.models.enums.ImmichAuthType import com.neilturner.aerialviews.models.enums.ProviderMediaType import com.neilturner.aerialviews.models.enums.ProviderSourceType import com.neilturner.aerialviews.models.immich.Album +import com.neilturner.aerialviews.models.immich.ErrorResponse import com.neilturner.aerialviews.models.prefs.ImmichMediaPrefs import com.neilturner.aerialviews.models.videos.AerialMedia import com.neilturner.aerialviews.models.videos.VideoMetadata @@ -256,24 +257,22 @@ class ImmichMediaProvider( } } - suspend fun fetchAlbums(): List { - try { + suspend fun fetchAlbums(): Result> { + return try { val response = apiInterface.getAlbums(apiKey = prefs.apiKey) if (response.isSuccessful) { - val albums = response.body() - Timber.d("API Response: ${albums?.toString()}") - albums?.forEach { album -> - Timber.d("Album: id=${album.id}, name=${album.name}, assetCount=${album.assetCount}, type=${album.type}") - } - return albums ?: emptyList() + Result.success(response.body() ?: emptyList()) } else { - Timber.e("Error fetching albums: ${response.code()} - ${response.message()}") - Timber.e("Error body: ${response.errorBody()?.string()}") - throw Exception("Failed to fetch albums: ${response.code()}") + val errorBody = response.errorBody()?.string() + val errorMessage = try { + Gson().fromJson(errorBody, ErrorResponse::class.java).message + } catch (e: Exception) { + response.message() + } + Result.failure(Exception("${response.code()} - $errorMessage")) } } catch (e: Exception) { - Timber.e(e, "Exception while fetching albums") - throw e + Result.failure(e) } } diff --git a/app/src/main/java/com/neilturner/aerialviews/ui/sources/ImmichVideosFragment.kt b/app/src/main/java/com/neilturner/aerialviews/ui/sources/ImmichVideosFragment.kt index a6e0fd21..82f50f14 100644 --- a/app/src/main/java/com/neilturner/aerialviews/ui/sources/ImmichVideosFragment.kt +++ b/app/src/main/java/com/neilturner/aerialviews/ui/sources/ImmichVideosFragment.kt @@ -156,13 +156,28 @@ class ImmichVideosFragment : showDialog(resources.getString(R.string.immich_media_test_results), result) } - private suspend fun selectAlbum() = withContext(Dispatchers.IO) { + private suspend fun selectAlbum() { val provider = ImmichMediaProvider(requireContext(), ImmichMediaPrefs) - val albums = provider.fetchAlbums() - showAlbumSelectionDialog(albums) + provider.fetchAlbums().fold( + onSuccess = { albums -> + if (albums.isEmpty()) { + showErrorDialog(getString(R.string.immich_media_no_albums), getString(R.string.immich_media_no_albums_message)) + } else { + showAlbumSelectionDialog(albums) + } + }, + onFailure = { error -> + showErrorDialog(getString(R.string.immich_media_fetch_albums_error), error.message ?: getString(R.string.immich_media_unknown_error)) + } + ) } private suspend fun showAlbumSelectionDialog(albums: List) = withContext(Dispatchers.Main) { + if (albums.isEmpty()) { + showErrorDialog(getString(R.string.immich_media_no_albums), getString(R.string.immich_media_no_albums_message)) + return@withContext + } + Timber.d("Showing album selection dialog with ${albums.size} albums") val albumNames = albums.map { "${it.name} (${it.assetCount} assets)" }.toTypedArray() AlertDialog.Builder(requireContext()).apply { @@ -178,6 +193,14 @@ class ImmichVideosFragment : } } + private suspend fun showErrorDialog(title: String, message: String) = withContext(Dispatchers.Main) { + AlertDialog.Builder(requireContext()) + .setTitle(title) + .setMessage(message) + .setPositiveButton(R.string.button_ok, null) + .show() + } + private suspend fun showDialog( title: String = "", message: String, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index aa3eade5..bc043a24 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -271,6 +271,10 @@ Authentication Test No album selected Selected album: %1$s + Error Fetching Albums + No Albums Found + No albums were found in your Immich account. Please make sure you have at least one album created. + An unknown error occurred From 2324f40c6506668186c5ebf8f5e7fa709e47cc35 Mon Sep 17 00:00:00 2001 From: RyanShahidi Date: Mon, 7 Oct 2024 19:07:40 -0400 Subject: [PATCH 06/12] Improve error handling in Immich integration for both shared link and API with test connection settings button --- .../providers/ImmichMediaProvider.kt | 38 ++++++++++++++++++- .../ui/sources/ImmichVideosFragment.kt | 4 +- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt b/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt index 3162cfff..731cb1c0 100644 --- a/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt +++ b/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt @@ -110,7 +110,43 @@ class ImmichMediaProvider( } override suspend fun fetchMedia(): List = fetchImmichMedia().first - override suspend fun fetchTest(): String = fetchImmichMedia().second + override suspend fun fetchTest(): String { + if (prefs.hostName.isEmpty()) { + return "Hostname and port not specified" + } + + return try { + when (prefs.authType) { + ImmichAuthType.SHARED_LINK -> { + val cleanedKey = cleanSharedLinkKey(prefs.pathName) + val response = apiInterface.getSharedAlbum(key = cleanedKey, password = prefs.password) + handleResponse(response, "Shared link test successful") + } + ImmichAuthType.API_KEY -> { + val response = apiInterface.getAlbums(apiKey = prefs.apiKey) + handleResponse(response, "API key test successful") + } + null -> "Invalid authentication type" + } + } catch (e: Exception) { + "Error: ${e.message}" + } + } + + private fun handleResponse(response: Response<*>, successMessage: String): String { + return if (response.isSuccessful) { + successMessage + } else { + val errorBody = response.errorBody()?.string() + val errorMessage = try { + Gson().fromJson(errorBody, ErrorResponse::class.java).message + } catch (e: Exception) { + response.message() + } + "Error ${response.code()} - $errorMessage" + } + } + override suspend fun fetchMetadata(): List = emptyList() private suspend fun fetchImmichMedia(): Pair, String> { diff --git a/app/src/main/java/com/neilturner/aerialviews/ui/sources/ImmichVideosFragment.kt b/app/src/main/java/com/neilturner/aerialviews/ui/sources/ImmichVideosFragment.kt index 82f50f14..68c98d5d 100644 --- a/app/src/main/java/com/neilturner/aerialviews/ui/sources/ImmichVideosFragment.kt +++ b/app/src/main/java/com/neilturner/aerialviews/ui/sources/ImmichVideosFragment.kt @@ -153,7 +153,7 @@ class ImmichVideosFragment : private suspend fun testImmichConnection() = withContext(Dispatchers.IO) { val provider = ImmichMediaProvider(requireContext(), ImmichMediaPrefs) val result = provider.fetchTest() - showDialog(resources.getString(R.string.immich_media_test_results), result) + showDialog(getString(R.string.immich_media_test_results), result) } private suspend fun selectAlbum() { @@ -202,7 +202,7 @@ class ImmichVideosFragment : } private suspend fun showDialog( - title: String = "", + title: String, message: String, ) = withContext(Dispatchers.Main) { AlertDialog.Builder(requireContext()).apply { From 3ec25bd81a0640c934e7dc527a0dcc9430aa2f60 Mon Sep 17 00:00:00 2001 From: RyanShahidi Date: Fri, 18 Oct 2024 14:27:09 -0400 Subject: [PATCH 07/12] Automatic SSL certificate handling for Immich API with fallback for self-signed certificates --- .../providers/ImmichMediaProvider.kt | 85 +++++++++++++------ 1 file changed, 60 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt b/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt index 731cb1c0..83a3b088 100644 --- a/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt +++ b/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt @@ -24,12 +24,15 @@ import retrofit2.http.Header import retrofit2.http.Path import retrofit2.http.Query import timber.log.Timber -import java.security.SecureRandom +import java.security.KeyStore +import java.security.cert.CertificateException import java.security.cert.X509Certificate import javax.net.ssl.SSLContext -import javax.net.ssl.TrustManager +import javax.net.ssl.SSLHandshakeException +import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager + class ImmichMediaProvider( context: Context, private val prefs: ImmichMediaPrefs, @@ -71,30 +74,35 @@ class ImmichMediaProvider( } private fun getApiInterface() { - val trustAllCerts = arrayOf(object : X509TrustManager { - override fun checkClientTrusted( - chain: Array?, - authType: String? - ) { - } - - override fun checkServerTrusted( - chain: Array?, - authType: String? - ) { + val standardTrustManager = getTrustManager(false) + val permissiveTrustManager = getTrustManager(true) + + val standardSslSocketFactory = SSLContext.getInstance("TLS").apply { + init(null, arrayOf(standardTrustManager), null) + }.socketFactory + + val permissiveSslSocketFactory = SSLContext.getInstance("TLS").apply { + init(null, arrayOf(permissiveTrustManager), null) + }.socketFactory + + val okHttpClientBuilder = OkHttpClient.Builder() + .sslSocketFactory(standardSslSocketFactory, standardTrustManager) + .addInterceptor { chain -> + try { + chain.proceed(chain.request()) + } catch (e: SSLHandshakeException) { + Timber.w("SSL Handshake failed with standard trust manager, attempting with permissive trust manager") + + val permissiveClientBuilder = OkHttpClient.Builder() + .sslSocketFactory(permissiveSslSocketFactory, permissiveTrustManager) + .hostnameVerifier { _, _ -> true } + + val permissiveClient = permissiveClientBuilder.build() + permissiveClient.newCall(chain.request()).execute() + } } - override fun getAcceptedIssuers(): Array = arrayOf() - }) - - val sslContext = SSLContext.getInstance("SSL").apply { - init(null, trustAllCerts, SecureRandom()) - } - - val okHttpClient = OkHttpClient.Builder() - .sslSocketFactory(sslContext.socketFactory, trustAllCerts[0] as X509TrustManager) - .hostnameVerifier { _, _ -> true } - .build() + val okHttpClient = okHttpClientBuilder.build() Timber.i("Connecting to $server") try { @@ -105,10 +113,37 @@ class ImmichMediaProvider( .build() .create(ImmichService::class.java) } catch (e: Exception) { - Timber.e(e, e.message.toString()) + Timber.e(e, "Error creating Immich API interface: ${e.message}") + throw e } } + private fun getTrustManager(permissive: Boolean): X509TrustManager { + return if (permissive) { + object : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) { + try { + chain?.get(0)?.checkValidity() + } catch (e: Exception) { + throw CertificateException("Certificate not valid.") + } + } + override fun getAcceptedIssuers(): Array = arrayOf() + } + } else { + try { + val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + trustManagerFactory.init(null as KeyStore?) + trustManagerFactory.trustManagers.first { it is X509TrustManager } as X509TrustManager + } catch (e: Exception) { + Timber.e(e, "Error getting default trust manager") + throw e + } + } + } + + override suspend fun fetchMedia(): List = fetchImmichMedia().first override suspend fun fetchTest(): String { if (prefs.hostName.isEmpty()) { From 33bbfc690a5b6ed10531d952a6a67a743ddc9d39 Mon Sep 17 00:00:00 2001 From: RyanShahidi Date: Fri, 18 Oct 2024 14:37:42 -0400 Subject: [PATCH 08/12] Automatic SSL certificate handling for Immich API with fallback for self-signed certificates --- .../aerialviews/ui/core/ImagePlayerView.kt | 83 ++++++++++++++----- 1 file changed, 62 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/neilturner/aerialviews/ui/core/ImagePlayerView.kt b/app/src/main/java/com/neilturner/aerialviews/ui/core/ImagePlayerView.kt index 10ac4f0c..afa58b32 100644 --- a/app/src/main/java/com/neilturner/aerialviews/ui/core/ImagePlayerView.kt +++ b/app/src/main/java/com/neilturner/aerialviews/ui/core/ImagePlayerView.kt @@ -33,12 +33,14 @@ import kotlinx.coroutines.withContext import okhttp3.Interceptor import okhttp3.OkHttpClient import timber.log.Timber -import java.security.SecureRandom import java.security.cert.X509Certificate import java.util.EnumSet import javax.net.ssl.SSLContext -import javax.net.ssl.TrustManager import javax.net.ssl.X509TrustManager +import java.security.KeyStore +import java.security.cert.CertificateException +import javax.net.ssl.SSLHandshakeException +import javax.net.ssl.TrustManagerFactory class ImagePlayerView : AppCompatImageView, @@ -63,29 +65,68 @@ class ImagePlayerView : } } .okHttpClient { - // Create a trust manager that does not validate certificate chains - val trustAllCerts = arrayOf(object : X509TrustManager { - override fun checkClientTrusted(chain: Array?, authType: String?) {} - override fun checkServerTrusted(chain: Array?, authType: String?) {} - override fun getAcceptedIssuers(): Array = arrayOf() - }) - - // Install the all-trusting trust manager - val sslContext = SSLContext.getInstance("SSL") - sslContext.init(null, trustAllCerts, SecureRandom()) - - // Create an ssl socket factory with our all-trusting manager - val sslSocketFactory = sslContext.socketFactory - - OkHttpClient.Builder() - .sslSocketFactory(sslSocketFactory, trustAllCerts[0] as X509TrustManager) - .hostnameVerifier { _, _ -> true } - .addInterceptor(ApiKeyInterceptor()) - .build() + buildOkHttpClient() } .build() } + private fun buildOkHttpClient(): OkHttpClient { + val standardTrustManager = getTrustManager(false) + val permissiveTrustManager = getTrustManager(true) + + val standardSslSocketFactory = SSLContext.getInstance("TLS").apply { + init(null, arrayOf(standardTrustManager), null) + }.socketFactory + + val permissiveSslSocketFactory = SSLContext.getInstance("TLS").apply { + init(null, arrayOf(permissiveTrustManager), null) + }.socketFactory + + return OkHttpClient.Builder() + .sslSocketFactory(standardSslSocketFactory, standardTrustManager) + .addInterceptor { chain -> + try { + chain.proceed(chain.request()) + } catch (e: SSLHandshakeException) { + Timber.w("SSL Handshake failed with standard trust manager, attempting with permissive trust manager") + + val permissiveClientBuilder = OkHttpClient.Builder() + .sslSocketFactory(permissiveSslSocketFactory, permissiveTrustManager) + .hostnameVerifier { _, _ -> true } + + val permissiveClient = permissiveClientBuilder.build() + permissiveClient.newCall(chain.request()).execute() + } + } + .addInterceptor(ApiKeyInterceptor()) + .build() + } + + private fun getTrustManager(permissive: Boolean): X509TrustManager { + return if (permissive) { + object : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) { + try { + chain?.get(0)?.checkValidity() + } catch (e: Exception) { + throw CertificateException("Certificate not valid.") + } + } + override fun getAcceptedIssuers(): Array = arrayOf() + } + } else { + try { + val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + trustManagerFactory.init(null as KeyStore?) + trustManagerFactory.trustManagers.first { it is X509TrustManager } as X509TrustManager + } catch (e: Exception) { + Timber.e(e, "Error getting default trust manager") + throw e + } + } + } + private class ApiKeyInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): okhttp3.Response { val originalRequest = chain.request() From 86de7c8c812ed37512c41db2e5dd4e9fba87a36a Mon Sep 17 00:00:00 2001 From: RyanShahidi Date: Fri, 18 Oct 2024 14:38:56 -0400 Subject: [PATCH 09/12] Organized imports --- .../com/neilturner/aerialviews/ui/core/ImagePlayerView.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/neilturner/aerialviews/ui/core/ImagePlayerView.kt b/app/src/main/java/com/neilturner/aerialviews/ui/core/ImagePlayerView.kt index afa58b32..4b83d516 100644 --- a/app/src/main/java/com/neilturner/aerialviews/ui/core/ImagePlayerView.kt +++ b/app/src/main/java/com/neilturner/aerialviews/ui/core/ImagePlayerView.kt @@ -33,14 +33,14 @@ import kotlinx.coroutines.withContext import okhttp3.Interceptor import okhttp3.OkHttpClient import timber.log.Timber +import java.security.KeyStore +import java.security.cert.CertificateException import java.security.cert.X509Certificate import java.util.EnumSet import javax.net.ssl.SSLContext -import javax.net.ssl.X509TrustManager -import java.security.KeyStore -import java.security.cert.CertificateException import javax.net.ssl.SSLHandshakeException import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager class ImagePlayerView : AppCompatImageView, From 6596df68c30b938db7064d0f0039e990c5e261fd Mon Sep 17 00:00:00 2001 From: RyanShahidi Date: Fri, 18 Oct 2024 15:35:09 -0400 Subject: [PATCH 10/12] Adds support for video via API --- .../aerialviews/services/ImmichDataSource.kt | 114 ++++++++++++++++++ .../aerialviews/ui/core/VideoPlayerView.kt | 30 +++-- 2 files changed, 132 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/neilturner/aerialviews/services/ImmichDataSource.kt diff --git a/app/src/main/java/com/neilturner/aerialviews/services/ImmichDataSource.kt b/app/src/main/java/com/neilturner/aerialviews/services/ImmichDataSource.kt new file mode 100644 index 00000000..e4e73c0d --- /dev/null +++ b/app/src/main/java/com/neilturner/aerialviews/services/ImmichDataSource.kt @@ -0,0 +1,114 @@ +package com.neilturner.aerialviews.services + +import android.annotation.SuppressLint +import android.net.Uri +import androidx.media3.common.C +import androidx.media3.datasource.BaseDataSource +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import com.neilturner.aerialviews.models.enums.ImmichAuthType +import com.neilturner.aerialviews.models.prefs.ImmichMediaPrefs +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.EOFException +import java.io.IOException +import java.io.InputStream +import kotlin.math.min + +@SuppressLint("UnsafeOptInUsageError") +class ImmichDataSource : BaseDataSource(true) { + private lateinit var dataSpec: DataSpec + private var okHttpClient: OkHttpClient = OkHttpClient() + private var inputStream: InputStream? = null + private var bytesRemaining: Long = 0 + private var opened = false + + override fun open(dataSpec: DataSpec): Long { + this.dataSpec = dataSpec + val uri = dataSpec.uri + bytesRemaining = dataSpec.length + if (bytesRemaining == C.LENGTH_UNSET.toLong()) { + bytesRemaining = Long.MAX_VALUE + } + + transferInitializing(dataSpec) + + val request = Request.Builder() + .url(uri.toString()) + .addHeader("Range", "bytes=${dataSpec.position}-") + .also { builder -> + when (ImmichMediaPrefs.authType) { + ImmichAuthType.API_KEY -> builder.addHeader("X-API-Key", ImmichMediaPrefs.apiKey) + ImmichAuthType.SHARED_LINK -> { + // Add any necessary headers for shared link authentication + } + null -> { + // No authentication + } + } + } + .build() + + try { + val response = okHttpClient.newCall(request).execute() + if (!response.isSuccessful) { + throw IOException("Unexpected code ${response.code}") + } + + inputStream = response.body?.byteStream() + opened = true + transferStarted(dataSpec) + } catch (e: IOException) { + throw IOException(e) + } + + return bytesRemaining + } + + override fun read(buffer: ByteArray, offset: Int, readLength: Int): Int { + if (readLength == 0) { + return 0 + } + if (bytesRemaining == 0L) { + return C.RESULT_END_OF_INPUT + } + + val bytesRead = try { + inputStream?.read(buffer, offset, min(readLength.toLong(), bytesRemaining).toInt()) ?: -1 + } catch (e: IOException) { + throw IOException(e) + } + + if (bytesRead == -1) { + if (bytesRemaining != Long.MAX_VALUE) { + throw EOFException() + } + return C.RESULT_END_OF_INPUT + } + + bytesRemaining -= bytesRead.toLong() + bytesTransferred(bytesRead) + return bytesRead + } + + override fun getUri(): Uri? = if (opened) dataSpec.uri else null + + override fun close() { + try { + inputStream?.close() + } catch (e: IOException) { + throw IOException(e) + } finally { + inputStream = null + if (opened) { + opened = false + transferEnded() + } + } + } +} + +class ImmichDataSourceFactory : DataSource.Factory { + @SuppressLint("UnsafeOptInUsageError") + override fun createDataSource(): DataSource = ImmichDataSource() +} \ No newline at end of file diff --git a/app/src/main/java/com/neilturner/aerialviews/ui/core/VideoPlayerView.kt b/app/src/main/java/com/neilturner/aerialviews/ui/core/VideoPlayerView.kt index 89090868..ee399a22 100644 --- a/app/src/main/java/com/neilturner/aerialviews/ui/core/VideoPlayerView.kt +++ b/app/src/main/java/com/neilturner/aerialviews/ui/core/VideoPlayerView.kt @@ -11,6 +11,7 @@ import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.VideoSize +import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.ProgressiveMediaSource @@ -23,6 +24,7 @@ import com.neilturner.aerialviews.models.enums.VideoScale import com.neilturner.aerialviews.models.prefs.GeneralPrefs import com.neilturner.aerialviews.models.videos.AerialMedia import com.neilturner.aerialviews.services.CustomRendererFactory +import com.neilturner.aerialviews.services.ImmichDataSourceFactory import com.neilturner.aerialviews.services.PhilipsMediaCodecAdapterFactory import com.neilturner.aerialviews.services.SambaDataSourceFactory import com.neilturner.aerialviews.services.WebDavDataSourceFactory @@ -105,26 +107,30 @@ class VideoPlayerView( PhilipsMediaCodecAdapterFactory.mediaUrl = uri.toString() } - when (media.source) { + val mediaSource = when (media.source) { AerialMediaSource.SAMBA -> { - val mediaSource = - ProgressiveMediaSource - .Factory(SambaDataSourceFactory()) - .createMediaSource(mediaItem) - player.setMediaSource(mediaSource) + ProgressiveMediaSource + .Factory(SambaDataSourceFactory()) + .createMediaSource(mediaItem) } AerialMediaSource.WEBDAV -> { - val mediaSource = - ProgressiveMediaSource - .Factory(WebDavDataSourceFactory()) - .createMediaSource(mediaItem) - player.setMediaSource(mediaSource) + ProgressiveMediaSource + .Factory(WebDavDataSourceFactory()) + .createMediaSource(mediaItem) + } + AerialMediaSource.IMMICH -> { + ProgressiveMediaSource + .Factory(ImmichDataSourceFactory()) + .createMediaSource(mediaItem) } else -> { - player.setMediaItem(mediaItem) + ProgressiveMediaSource + .Factory(DefaultHttpDataSource.Factory()) + .createMediaSource(mediaItem) } } + player.setMediaSource(mediaSource) player.prepare() if (muteVideo) { From c5476576e90543aa5f4095083f53b0d1181955fe Mon Sep 17 00:00:00 2001 From: Neil Turner Date: Fri, 8 Nov 2024 16:56:12 +0000 Subject: [PATCH 11/12] Small refactor Just moved a few things around after the merge --- .../aerialviews/ui/core/VideoPlayerHelper.kt | 6 ++++++ app/src/main/res/values/arrays.xml | 10 ++++++++++ app/src/main/res/values/strings.xml | 9 --------- app/src/main/res/xml/sources_immich_videos.xml | 1 - 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/neilturner/aerialviews/ui/core/VideoPlayerHelper.kt b/app/src/main/java/com/neilturner/aerialviews/ui/core/VideoPlayerHelper.kt index 629a9d0a..a6e9a2a8 100644 --- a/app/src/main/java/com/neilturner/aerialviews/ui/core/VideoPlayerHelper.kt +++ b/app/src/main/java/com/neilturner/aerialviews/ui/core/VideoPlayerHelper.kt @@ -17,6 +17,7 @@ import com.neilturner.aerialviews.models.enums.LimitLongerVideos import com.neilturner.aerialviews.models.prefs.GeneralPrefs import com.neilturner.aerialviews.models.videos.AerialMedia import com.neilturner.aerialviews.services.CustomRendererFactory +import com.neilturner.aerialviews.services.ImmichDataSourceFactory import com.neilturner.aerialviews.services.SambaDataSourceFactory import com.neilturner.aerialviews.services.WebDavDataSourceFactory import com.neilturner.aerialviews.utils.WindowHelper @@ -105,6 +106,11 @@ object VideoPlayerHelper { .createMediaSource(mediaItem) player.setMediaSource(mediaSource) } + AerialMediaSource.IMMICH -> { + ProgressiveMediaSource + .Factory(ImmichDataSourceFactory()) + .createMediaSource(mediaItem) + } AerialMediaSource.WEBDAV -> { val mediaSource = ProgressiveMediaSource diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 16178004..8626bdd7 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -598,4 +598,14 @@ 100 100 + + + + Shared Link + API Key + + + SHARED_LINK + API_KEY + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5185e5b3..406fdee8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -274,15 +274,6 @@ Photos found: %1$s Selected for playback: %1$s - - Shared Link - API Key - - - SHARED_LINK - API_KEY - - Search subfolders Search all subfolders for videos & photos diff --git a/app/src/main/res/xml/sources_immich_videos.xml b/app/src/main/res/xml/sources_immich_videos.xml index e1ad8989..ca9f5bbb 100644 --- a/app/src/main/res/xml/sources_immich_videos.xml +++ b/app/src/main/res/xml/sources_immich_videos.xml @@ -89,5 +89,4 @@ android:title="@string/immich_media_test_connection" android:key="immich_media_test_connection" /> - \ No newline at end of file From 7700be0eb7e6bce05dd40a0eb05c2d26528dc09f Mon Sep 17 00:00:00 2001 From: RyanShahidi Date: Mon, 11 Nov 2024 23:02:26 -0500 Subject: [PATCH 12/12] Implement SSL certificate validation toggle for Immich source --- .../models/prefs/ImmichMediaPrefs.kt | 5 +- .../providers/ImmichMediaProvider.kt | 91 +++---------------- .../aerialviews/ui/core/ImagePlayerView.kt | 64 +------------ .../aerialviews/ui/core/VideoPlayerHelper.kt | 28 +++++- .../ui/sources/ImmichVideosFragment.kt | 89 +++++++++--------- .../aerialviews/utils/ServerConnection.kt | 70 ++++++++++++++ app/src/main/res/values/strings.xml | 5 + .../main/res/xml/sources_immich_videos.xml | 25 +++-- 8 files changed, 180 insertions(+), 197 deletions(-) create mode 100644 app/src/main/java/com/neilturner/aerialviews/utils/ServerConnection.kt diff --git a/app/src/main/java/com/neilturner/aerialviews/models/prefs/ImmichMediaPrefs.kt b/app/src/main/java/com/neilturner/aerialviews/models/prefs/ImmichMediaPrefs.kt index 78f4a8bd..01c30170 100644 --- a/app/src/main/java/com/neilturner/aerialviews/models/prefs/ImmichMediaPrefs.kt +++ b/app/src/main/java/com/neilturner/aerialviews/models/prefs/ImmichMediaPrefs.kt @@ -4,17 +4,16 @@ import com.chibatching.kotpref.KotprefModel import com.chibatching.kotpref.enumpref.nullableEnumValuePref import com.neilturner.aerialviews.models.enums.ImmichAuthType import com.neilturner.aerialviews.models.enums.ProviderMediaType -import com.neilturner.aerialviews.models.enums.SchemeType object ImmichMediaPrefs : KotprefModel() { override val kotprefName = "${context.packageName}_preferences" var enabled by booleanPref(false, "immich_media_enabled") var mediaType by nullableEnumValuePref(ProviderMediaType.VIDEOS, "immich_media_type") - var scheme by nullableEnumValuePref(SchemeType.HTTP, "immich_media_scheme") - var hostName by stringPref("", "immich_media_hostname") var pathName by stringPref("", "immich_media_pathname") var password by stringPref("", "immich_media_password") + var url by stringPref("", "immich_media_url") + var validateSsl by booleanPref(true, "immich_media_validate_ssl") var authType by nullableEnumValuePref(ImmichAuthType.SHARED_LINK, "immich_media_auth_type") var apiKey by stringPref("", "immich_media_api_key") var selectedAlbumId by stringPref("", "immich_media_selected_album_id") diff --git a/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt b/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt index 83a3b088..3d314ade 100644 --- a/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt +++ b/app/src/main/java/com/neilturner/aerialviews/providers/ImmichMediaProvider.kt @@ -14,8 +14,9 @@ import com.neilturner.aerialviews.models.prefs.ImmichMediaPrefs import com.neilturner.aerialviews.models.videos.AerialMedia import com.neilturner.aerialviews.models.videos.VideoMetadata import com.neilturner.aerialviews.utils.FileHelper -import com.neilturner.aerialviews.utils.toStringOrEmpty -import okhttp3.OkHttpClient +import com.neilturner.aerialviews.utils.ServerConfig +import com.neilturner.aerialviews.utils.SslHelper +import com.neilturner.aerialviews.utils.UrlParser import retrofit2.Response import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory @@ -24,13 +25,6 @@ import retrofit2.http.Header import retrofit2.http.Path import retrofit2.http.Query import timber.log.Timber -import java.security.KeyStore -import java.security.cert.CertificateException -import java.security.cert.X509Certificate -import javax.net.ssl.SSLContext -import javax.net.ssl.SSLHandshakeException -import javax.net.ssl.TrustManagerFactory -import javax.net.ssl.X509TrustManager class ImmichMediaProvider( @@ -46,9 +40,8 @@ class ImmichMediaProvider( private lateinit var apiInterface: ImmichService init { - parsePrefs() if (enabled) { - getApiInterface() + setupApiInterface() } } @@ -69,43 +62,13 @@ class ImmichMediaProvider( ): Response } - private fun parsePrefs() { - server = prefs.scheme?.toStringOrEmpty()?.lowercase() + "://" + prefs.hostName - } - - private fun getApiInterface() { - val standardTrustManager = getTrustManager(false) - val permissiveTrustManager = getTrustManager(true) - - val standardSslSocketFactory = SSLContext.getInstance("TLS").apply { - init(null, arrayOf(standardTrustManager), null) - }.socketFactory - - val permissiveSslSocketFactory = SSLContext.getInstance("TLS").apply { - init(null, arrayOf(permissiveTrustManager), null) - }.socketFactory - - val okHttpClientBuilder = OkHttpClient.Builder() - .sslSocketFactory(standardSslSocketFactory, standardTrustManager) - .addInterceptor { chain -> - try { - chain.proceed(chain.request()) - } catch (e: SSLHandshakeException) { - Timber.w("SSL Handshake failed with standard trust manager, attempting with permissive trust manager") - - val permissiveClientBuilder = OkHttpClient.Builder() - .sslSocketFactory(permissiveSslSocketFactory, permissiveTrustManager) - .hostnameVerifier { _, _ -> true } - - val permissiveClient = permissiveClientBuilder.build() - permissiveClient.newCall(chain.request()).execute() - } - } - - val okHttpClient = okHttpClientBuilder.build() - - Timber.i("Connecting to $server") + private fun setupApiInterface() { try { + server = UrlParser.parseServerUrl(prefs.url) + val serverConfig = ServerConfig(server, prefs.validateSsl) + val okHttpClient = SslHelper().createOkHttpClient(serverConfig) + + Timber.i("Connecting to $server") apiInterface = Retrofit.Builder() .baseUrl(server) .client(okHttpClient) @@ -118,36 +81,11 @@ class ImmichMediaProvider( } } - private fun getTrustManager(permissive: Boolean): X509TrustManager { - return if (permissive) { - object : X509TrustManager { - override fun checkClientTrusted(chain: Array?, authType: String?) {} - override fun checkServerTrusted(chain: Array?, authType: String?) { - try { - chain?.get(0)?.checkValidity() - } catch (e: Exception) { - throw CertificateException("Certificate not valid.") - } - } - override fun getAcceptedIssuers(): Array = arrayOf() - } - } else { - try { - val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) - trustManagerFactory.init(null as KeyStore?) - trustManagerFactory.trustManagers.first { it is X509TrustManager } as X509TrustManager - } catch (e: Exception) { - Timber.e(e, "Error getting default trust manager") - throw e - } - } - } - - override suspend fun fetchMedia(): List = fetchImmichMedia().first + override suspend fun fetchTest(): String { - if (prefs.hostName.isEmpty()) { - return "Hostname and port not specified" + if (prefs.url.isEmpty()) { + return "Server URL not specified" } return try { @@ -187,7 +125,7 @@ class ImmichMediaProvider( private suspend fun fetchImmichMedia(): Pair, String> { val media = mutableListOf() - if (prefs.hostName.isEmpty()) { + if (prefs.url.isEmpty()) { return Pair(media, "Hostname and port not specified") } @@ -208,7 +146,6 @@ class ImmichMediaProvider( immichMedia.assets.forEach lit@{ asset -> val uri = getAssetUri(asset.id) - val filename = Uri.parse(asset.originalPath) val poi = mutableMapOf() val description = asset.exifInfo?.description.toString() diff --git a/app/src/main/java/com/neilturner/aerialviews/ui/core/ImagePlayerView.kt b/app/src/main/java/com/neilturner/aerialviews/ui/core/ImagePlayerView.kt index 4b83d516..2901a035 100644 --- a/app/src/main/java/com/neilturner/aerialviews/ui/core/ImagePlayerView.kt +++ b/app/src/main/java/com/neilturner/aerialviews/ui/core/ImagePlayerView.kt @@ -25,6 +25,8 @@ import com.neilturner.aerialviews.models.prefs.SambaMediaPrefs import com.neilturner.aerialviews.models.prefs.WebDavMediaPrefs import com.neilturner.aerialviews.models.videos.AerialMedia import com.neilturner.aerialviews.utils.SambaHelper +import com.neilturner.aerialviews.utils.ServerConfig +import com.neilturner.aerialviews.utils.SslHelper import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -33,14 +35,7 @@ import kotlinx.coroutines.withContext import okhttp3.Interceptor import okhttp3.OkHttpClient import timber.log.Timber -import java.security.KeyStore -import java.security.cert.CertificateException -import java.security.cert.X509Certificate import java.util.EnumSet -import javax.net.ssl.SSLContext -import javax.net.ssl.SSLHandshakeException -import javax.net.ssl.TrustManagerFactory -import javax.net.ssl.X509TrustManager class ImagePlayerView : AppCompatImageView, @@ -71,62 +66,13 @@ class ImagePlayerView : } private fun buildOkHttpClient(): OkHttpClient { - val standardTrustManager = getTrustManager(false) - val permissiveTrustManager = getTrustManager(true) - - val standardSslSocketFactory = SSLContext.getInstance("TLS").apply { - init(null, arrayOf(standardTrustManager), null) - }.socketFactory - - val permissiveSslSocketFactory = SSLContext.getInstance("TLS").apply { - init(null, arrayOf(permissiveTrustManager), null) - }.socketFactory - - return OkHttpClient.Builder() - .sslSocketFactory(standardSslSocketFactory, standardTrustManager) - .addInterceptor { chain -> - try { - chain.proceed(chain.request()) - } catch (e: SSLHandshakeException) { - Timber.w("SSL Handshake failed with standard trust manager, attempting with permissive trust manager") - - val permissiveClientBuilder = OkHttpClient.Builder() - .sslSocketFactory(permissiveSslSocketFactory, permissiveTrustManager) - .hostnameVerifier { _, _ -> true } - - val permissiveClient = permissiveClientBuilder.build() - permissiveClient.newCall(chain.request()).execute() - } - } + val serverConfig = ServerConfig("", ImmichMediaPrefs.validateSsl) + val okHttpClient = SslHelper().createOkHttpClient(serverConfig) + return okHttpClient.newBuilder() .addInterceptor(ApiKeyInterceptor()) .build() } - private fun getTrustManager(permissive: Boolean): X509TrustManager { - return if (permissive) { - object : X509TrustManager { - override fun checkClientTrusted(chain: Array?, authType: String?) {} - override fun checkServerTrusted(chain: Array?, authType: String?) { - try { - chain?.get(0)?.checkValidity() - } catch (e: Exception) { - throw CertificateException("Certificate not valid.") - } - } - override fun getAcceptedIssuers(): Array = arrayOf() - } - } else { - try { - val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) - trustManagerFactory.init(null as KeyStore?) - trustManagerFactory.trustManagers.first { it is X509TrustManager } as X509TrustManager - } catch (e: Exception) { - Timber.e(e, "Error getting default trust manager") - throw e - } - } - } - private class ApiKeyInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): okhttp3.Response { val originalRequest = chain.request() diff --git a/app/src/main/java/com/neilturner/aerialviews/ui/core/VideoPlayerHelper.kt b/app/src/main/java/com/neilturner/aerialviews/ui/core/VideoPlayerHelper.kt index a6e9a2a8..fa79b579 100644 --- a/app/src/main/java/com/neilturner/aerialviews/ui/core/VideoPlayerHelper.kt +++ b/app/src/main/java/com/neilturner/aerialviews/ui/core/VideoPlayerHelper.kt @@ -7,21 +7,24 @@ import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.util.EventLogger import com.neilturner.aerialviews.models.enums.AerialMediaSource +import com.neilturner.aerialviews.models.enums.ImmichAuthType import com.neilturner.aerialviews.models.enums.LimitLongerVideos import com.neilturner.aerialviews.models.prefs.GeneralPrefs +import com.neilturner.aerialviews.models.prefs.ImmichMediaPrefs import com.neilturner.aerialviews.models.videos.AerialMedia import com.neilturner.aerialviews.services.CustomRendererFactory -import com.neilturner.aerialviews.services.ImmichDataSourceFactory import com.neilturner.aerialviews.services.SambaDataSourceFactory import com.neilturner.aerialviews.services.WebDavDataSourceFactory import com.neilturner.aerialviews.utils.WindowHelper import timber.log.Timber +import java.util.concurrent.TimeUnit import kotlin.math.ceil import kotlin.time.Duration.Companion.milliseconds @@ -107,9 +110,28 @@ object VideoPlayerHelper { player.setMediaSource(mediaSource) } AerialMediaSource.IMMICH -> { - ProgressiveMediaSource - .Factory(ImmichDataSourceFactory()) + val dataSourceFactory = DefaultHttpDataSource.Factory() + .setAllowCrossProtocolRedirects(true) + .setConnectTimeoutMs(TimeUnit.SECONDS.toMillis(30).toInt()) + .setReadTimeoutMs(TimeUnit.SECONDS.toMillis(30).toInt()) + + // Add necessary headers for Immich + if (ImmichMediaPrefs.authType == ImmichAuthType.API_KEY) { + dataSourceFactory.setDefaultRequestProperties( + mapOf("X-API-Key" to ImmichMediaPrefs.apiKey) + ) + } + + // If SSL validation is disabled, we need to set the appropriate flags + if (!ImmichMediaPrefs.validateSsl) { + System.setProperty("javax.net.ssl.trustAll", "true") + } + + val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory) .createMediaSource(mediaItem) + + player.setMediaSource(mediaSource) + Timber.d("Setting up Immich media source with URI: ${media.uri}") } AerialMediaSource.WEBDAV -> { val mediaSource = diff --git a/app/src/main/java/com/neilturner/aerialviews/ui/sources/ImmichVideosFragment.kt b/app/src/main/java/com/neilturner/aerialviews/ui/sources/ImmichVideosFragment.kt index cae275f9..2b45f44a 100644 --- a/app/src/main/java/com/neilturner/aerialviews/ui/sources/ImmichVideosFragment.kt +++ b/app/src/main/java/com/neilturner/aerialviews/ui/sources/ImmichVideosFragment.kt @@ -13,6 +13,7 @@ import com.neilturner.aerialviews.models.immich.Album import com.neilturner.aerialviews.models.prefs.ImmichMediaPrefs import com.neilturner.aerialviews.providers.ImmichMediaProvider import com.neilturner.aerialviews.utils.MenuStateFragment +import com.neilturner.aerialviews.utils.UrlParser import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -22,25 +23,25 @@ class ImmichVideosFragment : MenuStateFragment(), SharedPreferences.OnSharedPreferenceChangeListener { + private lateinit var urlPreference: EditTextPreference private lateinit var authTypePreference: ListPreference - private lateinit var hostnamePreference: EditTextPreference - private lateinit var schemePreference: ListPreference - private lateinit var pathnamePreference: EditTextPreference + private lateinit var validateSslPreference: Preference private lateinit var passwordPreference: EditTextPreference private lateinit var apiKeyPreference: EditTextPreference private lateinit var selectAlbumPreference: Preference + private lateinit var pathnamePreference: EditTextPreference override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.sources_immich_videos, rootKey) preferenceManager.sharedPreferences?.registerOnSharedPreferenceChangeListener(this) + urlPreference = findPreference("immich_media_url")!! authTypePreference = findPreference("immich_media_auth_type")!! - hostnamePreference = findPreference("immich_media_hostname")!! - schemePreference = findPreference("immich_media_scheme")!! - pathnamePreference = findPreference("immich_media_pathname")!! + validateSslPreference = findPreference("immich_media_validate_ssl")!! passwordPreference = findPreference("immich_media_password")!! apiKeyPreference = findPreference("immich_media_api_key")!! selectAlbumPreference = findPreference("immich_media_select_album")!! + pathnamePreference = findPreference("immich_media_pathname")!! limitTextInput() updateAuthTypeVisibility() @@ -62,6 +63,19 @@ class ImmichVideosFragment : } private fun setupPreferenceClickListeners() { + urlPreference.setOnPreferenceChangeListener { _, newValue -> + try { + UrlParser.parseServerUrl(newValue.toString()) + true + } catch (e: IllegalArgumentException) { + AlertDialog.Builder(requireContext()) + .setMessage(getString(R.string.immich_media_url_invalid)) + .setPositiveButton(R.string.button_ok, null) + .show() + false + } + } + findPreference("immich_media_test_connection")?.setOnPreferenceClickListener { lifecycleScope.launch { testImmichConnection() } true @@ -73,28 +87,17 @@ class ImmichVideosFragment : } } - private fun updateSummary() { - updateHostnameSummary() - updatePathnameSummary() - updatePasswordSummary() - updateApiKeySummary() - updateSelectedAlbumSummary() - } - private fun updateHostnameSummary() { - hostnamePreference.summary = if (hostnamePreference.text.isNullOrEmpty()) { - getString(R.string.immich_media_hostname_summary) + private fun updateSummary() { + urlPreference.summary = if (urlPreference.text.isNullOrEmpty()) { + getString(R.string.immich_media_url_summary) } else { - hostnamePreference.text + urlPreference.text } - } - private fun updatePathnameSummary() { - pathnamePreference.summary = if (pathnamePreference.text.isNullOrEmpty()) { - getString(R.string.immich_media_pathname_summary) - } else { - pathnamePreference.text - } + updatePasswordSummary() + updateApiKeySummary() + updateSelectedAlbumSummary() } private fun updatePasswordSummary() { @@ -138,11 +141,9 @@ class ImmichVideosFragment : } } } - private fun limitTextInput() { listOf( - "immich_media_hostname", - "immich_media_pathname", + "immich_media_url", "immich_media_password", "immich_media_api_key" ).forEach { key -> @@ -161,20 +162,29 @@ class ImmichVideosFragment : provider.fetchAlbums().fold( onSuccess = { albums -> if (albums.isEmpty()) { - showErrorDialog(getString(R.string.immich_media_no_albums), getString(R.string.immich_media_no_albums_message)) + showErrorDialog( + getString(R.string.immich_media_no_albums), + getString(R.string.immich_media_no_albums_message) + ) } else { showAlbumSelectionDialog(albums) } }, - onFailure = { error -> - showErrorDialog(getString(R.string.immich_media_fetch_albums_error), error.message ?: getString(R.string.immich_media_unknown_error)) + onFailure = { exception -> + showErrorDialog( + getString(R.string.immich_media_fetch_albums_error), + exception.message ?: getString(R.string.immich_media_unknown_error) + ) } ) } private suspend fun showAlbumSelectionDialog(albums: List) = withContext(Dispatchers.Main) { if (albums.isEmpty()) { - showErrorDialog(getString(R.string.immich_media_no_albums), getString(R.string.immich_media_no_albums_message)) + showErrorDialog( + getString(R.string.immich_media_no_albums), + getString(R.string.immich_media_no_albums_message) + ) return@withContext } @@ -201,15 +211,12 @@ class ImmichVideosFragment : .show() } - private suspend fun showDialog( - title: String, - message: String, - ) = withContext(Dispatchers.Main) { - AlertDialog.Builder(requireContext()).apply { - setTitle(title) - setMessage(message) - setPositiveButton(R.string.button_ok, null) - create().show() - } + private suspend fun showDialog(title: String, message: String) = withContext(Dispatchers.Main) { + AlertDialog.Builder(requireContext()) + .setTitle(title) + .setMessage(message) + .setPositiveButton(R.string.button_ok, null) + .create() + .show() } } \ No newline at end of file diff --git a/app/src/main/java/com/neilturner/aerialviews/utils/ServerConnection.kt b/app/src/main/java/com/neilturner/aerialviews/utils/ServerConnection.kt new file mode 100644 index 00000000..2fd41c75 --- /dev/null +++ b/app/src/main/java/com/neilturner/aerialviews/utils/ServerConnection.kt @@ -0,0 +1,70 @@ +package com.neilturner.aerialviews.utils + +import android.net.Uri +import okhttp3.OkHttpClient +import timber.log.Timber +import java.security.SecureRandom +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + +data class ServerConfig( + val url: String, + val validateCertificates: Boolean = true +) + +object UrlParser { + fun parseServerUrl(input: String): String { + if (input.isBlank()) return "" + + // Remove any leading/trailing whitespace + var processedUrl = input.trim() + + // Check if the URL starts with a protocol + if (!processedUrl.startsWith("http://", ignoreCase = true) && + !processedUrl.startsWith("https://", ignoreCase = true)) { + // If no protocol is specified, prepend http:// + processedUrl = "http://$processedUrl" + } + + try { + val uri = Uri.parse(processedUrl) + // Validate basic URL components + if (uri.host == null) { + throw IllegalArgumentException("Invalid URL: Missing host") + } + return processedUrl + } catch (e: Exception) { + Timber.e(e, "URL parsing failed: ${e.message}") + throw IllegalArgumentException("Invalid URL format: ${e.message}") + } + } +} + +class SslHelper { + fun createOkHttpClient(config: ServerConfig): OkHttpClient { + val builder = OkHttpClient.Builder() + + if (!config.validateCertificates) { + val trustAllCerts = arrayOf(object : X509TrustManager { + override fun checkClientTrusted(chain: Array, authType: String) {} + override fun checkServerTrusted(chain: Array, authType: String) {} + override fun getAcceptedIssuers(): Array = arrayOf() + }) + + try { + val sslContext = SSLContext.getInstance("TLS").apply { + init(null, trustAllCerts, SecureRandom()) + } + + builder.sslSocketFactory(sslContext.socketFactory, trustAllCerts[0] as X509TrustManager) + builder.hostnameVerifier { _, _ -> true } + } catch (e: Exception) { + Timber.e(e, "Error setting up SSL: ${e.message}") + } + } + + return builder.build() + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 406fdee8..1100749c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -279,6 +279,11 @@ Test results + Server URL + Enter server URL (e.g., 192.168.1.100:8080) + Validate SSL Certificates + Disable only if using self-signed certificates + Invalid server URL Overlays Choose what overlays are displayed in different corners of the screen diff --git a/app/src/main/res/xml/sources_immich_videos.xml b/app/src/main/res/xml/sources_immich_videos.xml index ca9f5bbb..42364d56 100644 --- a/app/src/main/res/xml/sources_immich_videos.xml +++ b/app/src/main/res/xml/sources_immich_videos.xml @@ -20,23 +20,20 @@ - + app:key="immich_media_url" + app:title="@string/immich_media_url_title" + app:summary="@string/immich_media_url_summary" + android:selectAllOnFocus="true" /> + +