From aa5d0f92db7f142d029f700d56ce1874c2c24036 Mon Sep 17 00:00:00 2001 From: Angelo Suzuki <1063155+tinsukE@users.noreply.github.com> Date: Fri, 27 Feb 2026 06:20:01 +0100 Subject: [PATCH] Support specifying a client certificate for mTLS auth (#458) * feat: collect and save client certificate * feat: use client certificate for Retrofit, Glide and ExoPlayer --------- Co-authored-by: eddyizm --- .../14.json | 1164 +++++++++++++++++ .../java/com/cappielloantonio/tempo/App.java | 3 + .../tempo/database/AppDatabase.java | 8 +- .../cappielloantonio/tempo/model/Server.kt | 6 +- .../tempo/subsonic/RetrofitClient.kt | 11 +- .../tempo/ui/activity/MainActivity.java | 5 +- .../tempo/ui/dialog/ServerSignupDialog.java | 16 +- .../tempo/ui/fragment/LoginFragment.java | 6 +- .../tempo/util/ClientCertManager.kt | 95 ++ .../tempo/util/Preferences.kt | 11 + .../main/res/layout/dialog_server_signup.xml | 19 + app/src/main/res/values/strings.xml | 1 + 12 files changed, 1335 insertions(+), 10 deletions(-) create mode 100644 app/schemas/com.cappielloantonio.tempo.database.AppDatabase/14.json create mode 100644 app/src/main/java/com/cappielloantonio/tempo/util/ClientCertManager.kt diff --git a/app/schemas/com.cappielloantonio.tempo.database.AppDatabase/14.json b/app/schemas/com.cappielloantonio.tempo.database.AppDatabase/14.json new file mode 100644 index 00000000..0e76b479 --- /dev/null +++ b/app/schemas/com.cappielloantonio.tempo.database.AppDatabase/14.json @@ -0,0 +1,1164 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "42299b5bbc21c4c7c83eea7dc5ca5f66", + "entities": [ + { + "tableName": "queue", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `track_order` INTEGER NOT NULL, `last_play` INTEGER NOT NULL, `playing_changed` INTEGER NOT NULL, `stream_id` TEXT, `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `sampling_rate` INTEGER, `bit_depth` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, PRIMARY KEY(`track_order`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackOrder", + "columnName": "track_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlay", + "columnName": "last_play", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playingChanged", + "columnName": "playing_changed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "streamId", + "columnName": "stream_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDir", + "columnName": "is_dir", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverArtId", + "columnName": "cover_art_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "suffix", + "columnName": "suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedContentType", + "columnName": "transcoding_content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedSuffix", + "columnName": "transcoded_suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "samplingRate", + "columnName": "sampling_rate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitDepth", + "columnName": "bit_depth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isVideo", + "columnName": "is_video", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userRating", + "columnName": "user_rating", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "averageRating", + "columnName": "average_rating", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookmarkPosition", + "columnName": "bookmark_position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalWidth", + "columnName": "original_width", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalHeight", + "columnName": "original_height", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "track_order" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "server", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `server_name` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `address` TEXT NOT NULL, `local_address` TEXT, `timestamp` INTEGER NOT NULL, `low_security` INTEGER NOT NULL DEFAULT false, `client_cert` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localAddress", + "columnName": "local_address", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLowSecurity", + "columnName": "low_security", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "clientCert", + "columnName": "client_cert", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "recent_search", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`search` TEXT NOT NULL, `timestamp` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`search`))", + "fields": [ + { + "fieldPath": "search", + "columnName": "search", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "search" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `playlist_id` TEXT, `playlist_name` TEXT, `download_state` INTEGER NOT NULL DEFAULT 1, `download_uri` TEXT DEFAULT '', `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `sampling_rate` INTEGER, `bit_depth` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "playlistName", + "columnName": "playlist_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "downloadState", + "columnName": "download_state", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "downloadUri", + "columnName": "download_uri", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDir", + "columnName": "is_dir", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverArtId", + "columnName": "cover_art_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "suffix", + "columnName": "suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedContentType", + "columnName": "transcoding_content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedSuffix", + "columnName": "transcoded_suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "samplingRate", + "columnName": "sampling_rate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitDepth", + "columnName": "bit_depth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isVideo", + "columnName": "is_video", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userRating", + "columnName": "user_rating", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "averageRating", + "columnName": "average_rating", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookmarkPosition", + "columnName": "bookmark_position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalWidth", + "columnName": "original_width", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalHeight", + "columnName": "original_height", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "chronology", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `server` TEXT, `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `sampling_rate` INTEGER, `bit_depth` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "server", + "columnName": "server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDir", + "columnName": "is_dir", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverArtId", + "columnName": "cover_art_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "suffix", + "columnName": "suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedContentType", + "columnName": "transcoding_content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedSuffix", + "columnName": "transcoded_suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "samplingRate", + "columnName": "sampling_rate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitDepth", + "columnName": "bit_depth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isVideo", + "columnName": "is_video", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userRating", + "columnName": "user_rating", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "averageRating", + "columnName": "average_rating", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookmarkPosition", + "columnName": "bookmark_position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalWidth", + "columnName": "original_width", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalHeight", + "columnName": "original_height", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "favorite", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `songId` TEXT, `albumId` TEXT, `artistId` TEXT, `toStar` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "toStar", + "columnName": "toStar", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "session_media_item", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`index` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT, `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, `stream_id` TEXT, `stream_url` TEXT, `timestamp` INTEGER)", + "fields": [ + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDir", + "columnName": "is_dir", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverArtId", + "columnName": "cover_art_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "suffix", + "columnName": "suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedContentType", + "columnName": "transcoding_content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedSuffix", + "columnName": "transcoded_suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isVideo", + "columnName": "is_video", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userRating", + "columnName": "user_rating", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "averageRating", + "columnName": "average_rating", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookmarkPosition", + "columnName": "bookmark_position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalWidth", + "columnName": "original_width", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalHeight", + "columnName": "original_height", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "streamId", + "columnName": "stream_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "streamUrl", + "columnName": "stream_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "index" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `duration` INTEGER NOT NULL, `coverArt` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverArtId", + "columnName": "coverArt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` TEXT NOT NULL, `artist` TEXT, `title` TEXT, `lyrics` TEXT, `structured_lyrics` TEXT, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`song_id`))", + "fields": [ + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "structuredLyrics", + "columnName": "structured_lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "song_id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '42299b5bbc21c4c7c83eea7dc5ca5f66')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/App.java b/app/src/main/java/com/cappielloantonio/tempo/App.java index b6774670..236d145e 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/App.java +++ b/app/src/main/java/com/cappielloantonio/tempo/App.java @@ -11,6 +11,7 @@ import com.cappielloantonio.tempo.github.Github; import com.cappielloantonio.tempo.helper.ThemeHelper; import com.cappielloantonio.tempo.subsonic.Subsonic; import com.cappielloantonio.tempo.subsonic.SubsonicPreferences; +import com.cappielloantonio.tempo.util.ClientCertManager; import com.cappielloantonio.tempo.util.Preferences; public class App extends Application { @@ -31,6 +32,8 @@ public class App extends Application { instance = new App(); context = getApplicationContext(); preferences = PreferenceManager.getDefaultSharedPreferences(context); + + ClientCertManager.setupSslSocketFactory(context); } public static App getInstance() { diff --git a/app/src/main/java/com/cappielloantonio/tempo/database/AppDatabase.java b/app/src/main/java/com/cappielloantonio/tempo/database/AppDatabase.java index ecf1f177..6102df4f 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/database/AppDatabase.java +++ b/app/src/main/java/com/cappielloantonio/tempo/database/AppDatabase.java @@ -30,9 +30,13 @@ import com.cappielloantonio.tempo.subsonic.models.Playlist; @UnstableApi @Database( - version = 13, + version = 14, entities = {Queue.class, Server.class, RecentSearch.class, Download.class, Chronology.class, Favorite.class, SessionMediaItem.class, Playlist.class, LyricsCache.class}, - autoMigrations = {@AutoMigration(from = 10, to = 11), @AutoMigration(from = 11, to = 12)} + autoMigrations = { + @AutoMigration(from = 10, to = 11), + @AutoMigration(from = 11, to = 12), + @AutoMigration(from = 13, to = 14), + } ) @TypeConverters({DateConverters.class}) public abstract class AppDatabase extends RoomDatabase { diff --git a/app/src/main/java/com/cappielloantonio/tempo/model/Server.kt b/app/src/main/java/com/cappielloantonio/tempo/model/Server.kt index 78bfa6ee..c37b75e6 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/model/Server.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/model/Server.kt @@ -2,7 +2,6 @@ package com.cappielloantonio.tempo.model import android.os.Parcelable import androidx.annotation.Keep -import androidx.annotation.Nullable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @@ -35,5 +34,8 @@ data class Server( val timestamp: Long, @ColumnInfo(name = "low_security", defaultValue = "false") - val isLowSecurity: Boolean + val isLowSecurity: Boolean, + + @ColumnInfo(name = "client_cert") + val clientCert: String?, ) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/RetrofitClient.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/RetrofitClient.kt index 3f9868bd..28d4135d 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/RetrofitClient.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/RetrofitClient.kt @@ -3,6 +3,7 @@ package com.cappielloantonio.tempo.subsonic import com.cappielloantonio.tempo.App import com.cappielloantonio.tempo.subsonic.utils.CacheUtil import com.cappielloantonio.tempo.subsonic.utils.EmptyDateTypeAdapter +import com.cappielloantonio.tempo.util.ClientCertManager import com.google.gson.GsonBuilder import okhttp3.Cache import okhttp3.OkHttpClient @@ -13,7 +14,7 @@ import java.util.Date import java.util.concurrent.TimeUnit class RetrofitClient(subsonic: Subsonic) { - var retrofit: Retrofit + val retrofit: Retrofit init { val gson = GsonBuilder() @@ -50,6 +51,7 @@ class RetrofitClient(subsonic: Subsonic) { .addInterceptor(cacheUtil.offlineInterceptor) // .addNetworkInterceptor(cacheUtil.onlineInterceptor) .cache(getCache()) + .setupSsl() .build() } @@ -63,4 +65,11 @@ class RetrofitClient(subsonic: Subsonic) { val cacheSize = 10 * 1024 * 1024 return Cache(App.getContext().cacheDir, cacheSize.toLong()) } + + private fun OkHttpClient.Builder.setupSsl(): OkHttpClient.Builder { + ClientCertManager.sslSocketFactory?.let { sslSocketFactory -> + sslSocketFactory(sslSocketFactory, ClientCertManager.trustManager) + } + return this + } } \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java b/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java index f872b403..0cd4396f 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java @@ -2,7 +2,9 @@ package com.cappielloantonio.tempo.ui.activity; import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; import android.content.res.Configuration; +import android.graphics.Rect; import android.content.IntentFilter; import android.net.ConnectivityManager; import android.net.NetworkInfo; @@ -24,8 +26,8 @@ import androidx.fragment.app.FragmentManager; import androidx.lifecycle.ViewModelProvider; import androidx.media3.common.MediaItem; import androidx.media3.common.MediaMetadata; -import androidx.media3.common.Player; import androidx.media3.common.MimeTypes; +import androidx.media3.common.Player; import androidx.media3.common.util.UnstableApi; import androidx.navigation.NavController; import androidx.navigation.fragment.NavHostFragment; @@ -448,6 +450,7 @@ public class MainActivity extends BaseActivity { Preferences.setServer(null); Preferences.setLocalAddress(null); Preferences.setUser(null); + Preferences.setClientCert(null); // TODO Enter all settings to be reset Preferences.setOpenSubsonic(false); diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/ServerSignupDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/ServerSignupDialog.java index 93381d33..3946a370 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/ServerSignupDialog.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/ServerSignupDialog.java @@ -2,8 +2,8 @@ package com.cappielloantonio.tempo.ui.dialog; import android.app.Dialog; import android.os.Bundle; +import android.security.KeyChain; import android.text.TextUtils; -import android.view.View; import android.widget.Toast; import androidx.annotation.NonNull; @@ -32,11 +32,21 @@ public class ServerSignupDialog extends DialogFragment { private String server; private String localAddress; private boolean lowSecurity = false; + private String clientCertAlias; @NonNull @Override public Dialog onCreateDialog(Bundle savedInstanceState) { bind = DialogServerSignupBinding.inflate(getLayoutInflater()); + bind.clientCertTextView.setOnClickListener(v -> { + if (TextUtils.isEmpty(bind.clientCertTextView.getText())) { + KeyChain.choosePrivateKeyAlias(requireActivity(), alias -> { + bind.clientCertTextView.setText(alias); + }, null, null, null, null); + } else { + bind.clientCertTextView.setText(null); + } + }); loginViewModel = new ViewModelProvider(requireActivity()).get(LoginViewModel.class); @@ -74,6 +84,7 @@ public class ServerSignupDialog extends DialogFragment { bind.serverTextView.setText(loginViewModel.getServerToEdit().getAddress()); bind.localAddressTextView.setText(loginViewModel.getServerToEdit().getLocalAddress()); bind.lowSecurityCheckbox.setChecked(loginViewModel.getServerToEdit().isLowSecurity()); + bind.clientCertTextView.setText(loginViewModel.getServerToEdit().getClientCert()); } } else { loginViewModel.setServerToEdit(null); @@ -106,6 +117,7 @@ public class ServerSignupDialog extends DialogFragment { server = bind.serverTextView.getText() != null && !bind.serverTextView.getText().toString().trim().isBlank() ? bind.serverTextView.getText().toString().trim() : null; localAddress = bind.localAddressTextView.getText() != null && !bind.localAddressTextView.getText().toString().trim().isBlank() ? bind.localAddressTextView.getText().toString().trim() : null; lowSecurity = bind.lowSecurityCheckbox.isChecked(); + clientCertAlias = bind.clientCertTextView.getText() != null && !bind.clientCertTextView.getText().toString().trim().isBlank() ? bind.clientCertTextView.getText().toString().trim() : null; if (TextUtils.isEmpty(serverName)) { bind.serverNameTextView.setError(getString(R.string.error_required)); @@ -137,6 +149,6 @@ public class ServerSignupDialog extends DialogFragment { private void saveServerPreference() { String serverID = loginViewModel.getServerToEdit() != null ? loginViewModel.getServerToEdit().getServerId() : UUID.randomUUID().toString(); - loginViewModel.addServer(new Server(serverID, this.serverName, this.username, this.password, this.server, this.localAddress, System.currentTimeMillis(), this.lowSecurity)); + loginViewModel.addServer(new Server(serverID, this.serverName, this.username, this.password, this.server, this.localAddress, System.currentTimeMillis(), this.lowSecurity, this.clientCertAlias)); } } diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/LoginFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/LoginFragment.java index d5bb0fae..c2f80f5e 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/LoginFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/LoginFragment.java @@ -117,7 +117,7 @@ public class LoginFragment extends Fragment implements ClickCallback { @Override public void onServerClick(Bundle bundle) { Server server = bundle.getParcelable("server_object"); - saveServerPreference(server.getServerId(), server.getAddress(), server.getLocalAddress(), server.getUsername(), server.getPassword(), server.isLowSecurity()); + saveServerPreference(server.getServerId(), server.getAddress(), server.getLocalAddress(), server.getUsername(), server.getPassword(), server.isLowSecurity(), server.getClientCert()); SystemRepository systemRepository = new SystemRepository(); systemRepository.checkUserCredential(new SystemCallback() { @@ -142,13 +142,14 @@ public class LoginFragment extends Fragment implements ClickCallback { dialog.show(activity.getSupportFragmentManager(), null); } - private void saveServerPreference(String serverId, String server, String localAddress, String user, String password, boolean isLowSecurity) { + private void saveServerPreference(String serverId, String server, String localAddress, String user, String password, boolean isLowSecurity, String clientCert) { Preferences.setServerId(serverId); Preferences.setServer(server); Preferences.setLocalAddress(localAddress); Preferences.setUser(user); Preferences.setPassword(password); Preferences.setLowSecurity(isLowSecurity); + Preferences.setClientCert(clientCert); App.getSubsonicClientInstance(true); } @@ -161,6 +162,7 @@ public class LoginFragment extends Fragment implements ClickCallback { Preferences.setToken(null); Preferences.setSalt(null); Preferences.setLowSecurity(false); + Preferences.setClientCert(null); App.getSubsonicClientInstance(true); } diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/ClientCertManager.kt b/app/src/main/java/com/cappielloantonio/tempo/util/ClientCertManager.kt new file mode 100644 index 00000000..60505c43 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/util/ClientCertManager.kt @@ -0,0 +1,95 @@ +package com.cappielloantonio.tempo.util + +import android.content.Context +import android.security.KeyChain +import android.util.Log +import androidx.core.net.toUri +import okhttp3.internal.platform.Platform +import java.net.Socket +import java.security.KeyManagementException +import java.security.NoSuchAlgorithmException +import java.security.Principal +import java.security.PrivateKey +import java.security.cert.X509Certificate +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.X509KeyManager + +object ClientCertManager { + + private const val TAG = "ClientCertManager" + + val trustManager = Platform.get().platformTrustManager() + var sslSocketFactory: SSLSocketFactory? = null + private set + + @JvmStatic + fun setupSslSocketFactory(context: Context) { + sslSocketFactory = createSslSocketFactory(context) + sslSocketFactory?.let { + // HttpsURLConnection is used both by: + // - Glide: in IPv6StringLoader + // - ExoPlayer: in DefaultHttpDataSource + HttpsURLConnection.setDefaultSSLSocketFactory(it) + } + } + + private fun createSslSocketFactory(context: Context): SSLSocketFactory? { + return try { + val clientKeyManager = object : X509KeyManager { + override fun getClientAliases(keyType: String?, issuers: Array?) = null + + override fun chooseClientAlias( + keyType: Array?, + issuers: Array?, + socket: Socket? + ): String? { + val clientCert = Preferences.getClientCert() ?: return null + val server = Preferences.getServer() ?: return null + return if (server.toUri().host == socket?.inetAddress?.hostName) { + clientCert + } else null + } + + override fun getServerAliases(keyType: String?, issuers: Array?) = null + + override fun chooseServerAlias( + keyType: String?, + issuers: Array?, + socket: Socket? + ) = null + + override fun getCertificateChain(alias: String?): Array? { + val clientCert = Preferences.getClientCert() + return if (alias == clientCert && clientCert != null) { + KeyChain.getCertificateChain( + context, + clientCert + ) + } else null + } + + override fun getPrivateKey(alias: String?): PrivateKey? { + val clientCert = Preferences.getClientCert() + return if (alias == clientCert && clientCert != null) { + KeyChain.getPrivateKey( + context, + clientCert + ) + } else null + } + } + + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(arrayOf(clientKeyManager), arrayOf(trustManager), null) + sslContext.socketFactory + } catch (e: NoSuchAlgorithmException) { + Log.e(TAG, "Failed setting mTLS", e) + null + } catch (e: KeyManagementException) { + Log.e(TAG, "Failed setting mTLS", e) + null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt index c72f767f..1edc914e 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt @@ -16,6 +16,7 @@ object Preferences { private const val TOKEN = "token" private const val SALT = "salt" private const val LOW_SECURITY = "low_security" + private const val CLIENT_CERT = "client_cert" private const val BATTERY_OPTIMIZATION = "battery_optimization" private const val SERVER_ID = "server_id" private const val OPEN_SUBSONIC = "open_subsonic" @@ -173,6 +174,16 @@ object Preferences { App.getInstance().preferences.edit().putBoolean(LOW_SECURITY, isLowSecurity).apply() } + @JvmStatic + fun getClientCert(): String? { + return App.getInstance().preferences.getString(CLIENT_CERT, null) + } + + @JvmStatic + fun setClientCert(clientCert: String?) { + App.getInstance().preferences.edit().putString(CLIENT_CERT, clientCert).apply() + } + @JvmStatic fun getServerId(): String? { return App.getInstance().preferences.getString(SERVER_ID, null) diff --git a/app/src/main/res/layout/dialog_server_signup.xml b/app/src/main/res/layout/dialog_server_signup.xml index 69597d3e..c37cf646 100644 --- a/app/src/main/res/layout/dialog_server_signup.xml +++ b/app/src/main/res/layout/dialog_server_signup.xml @@ -129,6 +129,25 @@ android:layout_marginStart="24dp" android:layout_marginEnd="24dp" android:text="@string/server_signup_dialog_action_low_security" /> + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 37558c33..2cb61a4c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -322,6 +322,7 @@ Password Server URL Username + Client certificate (optional) Cancel Delete Save