Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds Immich API authentication method #176

Open
wants to merge 18 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ enum class AerialMediaSource {
DEFAULT,
SAMBA,
WEBDAV,
IMMICH,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.neilturner.aerialviews.models.enums

enum class ImmichAuthType {
SHARED_LINK,
API_KEY
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
package com.neilturner.aerialviews.models.immich
import com.google.gson.annotations.SerializedName

data class ExifInfo(
val description: String? = null,
Expand All @@ -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<Asset>,

@SerializedName("assets")
val assets: List<Asset> = emptyList(),

@SerializedName("assetCount")
val assetCount: Int = 0
)
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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<Album>

@GET("/api/albums")
suspend fun getAlbums(@Header("x-api-key") apiKey: String): Response<List<Album>>

@GET("/api/albums/{id}")
suspend fun getAlbum(
@Header("x-api-key") apiKey: String,
@Path("id") albumId: String
): Response<Album>
}

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<TrustManager>(object : X509TrustManager {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is those code needed to ignore invalid or local SSL certs?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, if there is a better method I think it should be utilized instead. Without this code I was getting the following error:

javax.net.ssl.SSLHandshakeException: Chain validation failed Caused by: java.security.cert.CertificateException: Chain validation failed

override fun checkClientTrusted(
chain: Array<out X509Certificate>?,
authType: String?
) {
}

override fun checkServerTrusted(
chain: Array<out X509Certificate>?,
authType: String?
) {
}

override fun getAcceptedIssuers(): Array<X509Certificate> = 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<AerialMedia> = fetchImmichMedia().first

override suspend fun fetchTest(): String = fetchImmichMedia().second

override suspend fun fetchMetadata(): List<VideoMetadata> = emptyList()

private suspend fun fetchImmichMedia(): Pair<List<AerialMedia>, String> {
val media = mutableListOf<AerialMedia>()

// 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)
Expand All @@ -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++
Expand All @@ -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<Album> {
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}")
}
}
Loading