Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
492821b
docs: design SFTP SSH key authentication
anilbeesetti Jul 16, 2026
9363c8b
docs: require disposable emulator SFTP QA
anilbeesetti Jul 16, 2026
7ce9f55
docs: plan SFTP SSH key implementation
anilbeesetti Jul 16, 2026
45dd52d
feat: add SFTP connection model
anilbeesetti Jul 16, 2026
0e1a6df
build: keep network factory exhaustive
anilbeesetti Jul 16, 2026
c8eb714
feat: define SFTP connection form rules
anilbeesetti Jul 16, 2026
23ea483
feat: persist SFTP authentication settings
anilbeesetti Jul 16, 2026
e9f787c
feat: add SSH key storage and host verification
anilbeesetti Jul 16, 2026
29de22f
test: harden SFTP persistence coverage
anilbeesetti Jul 16, 2026
5ed58af
fix: harden SSH key lifecycle
anilbeesetti Jul 16, 2026
2434ea9
feat: integrate SFTP browsing and key cleanup
anilbeesetti Jul 16, 2026
51c4e81
feat: add seekable SFTP network client
anilbeesetti Jul 16, 2026
59e57c0
fix: make connection deletion transactional
anilbeesetti Jul 16, 2026
1cee341
fix: validate SFTP stream reads
anilbeesetti Jul 16, 2026
205046b
docs: describe SFTP connection credentials
anilbeesetti Jul 16, 2026
44c3d94
feat: add SFTP password and SSH key setup UI
anilbeesetti Jul 16, 2026
975e31d
fix: serialize SFTP key save lifecycle
anilbeesetti Jul 16, 2026
d232cee
fix: reject untrusted SFTP key filenames
anilbeesetti Jul 16, 2026
531588a
docs: generate disposable QA credentials
anilbeesetti Jul 16, 2026
23cf731
fix: serialize stream proxy initialization
anilbeesetti Jul 16, 2026
8ee8ee6
fix: show SSH host key mismatch fingerprints
anilbeesetti Jul 16, 2026
30cc620
fix: reconcile orphaned SSH key files
anilbeesetti Jul 16, 2026
d29b869
fix: support SSHJ crypto providers on Android
anilbeesetti Jul 16, 2026
8631d42
fix: avoid blocking SSH key operations during import
anilbeesetti Jul 16, 2026
c1d80c7
test: cover SSHJ ECDSA decoding on Android
anilbeesetti Jul 16, 2026
f8bd2d0
fix(network): show SFTP host key mismatch details
anilbeesetti Jul 16, 2026
91702e0
Merge remote-tracking branch 'origin/main' into codex/sftp-ssh-key-auth
anilbeesetti Jul 25, 2026
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
5 changes: 5 additions & 0 deletions app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@
-keep class com.hierynomus.ntlm.** { *; }
-keep class com.hierynomus.security.** { *; }

# Bouncy Castle registers JCA implementations by class name. SFTP uses an
# app-specific provider name so Android's stripped platform BC is untouched.
-keep class org.bouncycastle.jcajce.provider.** { *; }
-keep class org.bouncycastle.jce.provider.** { *; }

# mbassador creates the default handler invocation through Class.getConstructor().
-keepclassmembers,allowobfuscation class net.engio.mbassy.dispatch.ReflectiveHandlerInvocation {
public <init>(net.engio.mbassy.subscription.SubscriptionContext);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ import coil3.PlatformContext
import coil3.SingletonImageLoader
import dagger.hilt.android.HiltAndroidApp
import dev.anilbeesetti.nextplayer.core.common.di.ApplicationScope
import dev.anilbeesetti.nextplayer.core.common.Logger
import dev.anilbeesetti.nextplayer.core.data.repository.NetworkConnectionRepository
import dev.anilbeesetti.nextplayer.core.data.repository.PreferencesRepository
import dev.anilbeesetti.nextplayer.core.media.network.keys.SshKeyStore
import dev.anilbeesetti.nextplayer.crash.CrashActivity
import dev.anilbeesetti.nextplayer.crash.GlobalExceptionHandler
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch

@HiltAndroidApp
class NextPlayerApplication : Application(), SingletonImageLoader.Factory {
Expand All @@ -21,14 +25,31 @@ class NextPlayerApplication : Application(), SingletonImageLoader.Factory {
@Inject
lateinit var imageLoader: ImageLoader

@Inject
lateinit var networkConnectionRepository: NetworkConnectionRepository

@Inject
lateinit var sshKeyStore: SshKeyStore

@Inject
@ApplicationScope
lateinit var applicationScope: CoroutineScope

override fun onCreate() {
super.onCreate()
Thread.setDefaultUncaughtExceptionHandler(GlobalExceptionHandler(applicationContext, CrashActivity::class.java))
applicationScope.launch {
runCatching {
initializeSshKeyStore(networkConnectionRepository, sshKeyStore)
}.onFailure { error ->
Logger.logError(TAG, "Couldn't reconcile SSH keys: ${error.message}")
}
}
}

override fun newImageLoader(context: PlatformContext): ImageLoader = imageLoader

private companion object {
const val TAG = "NextPlayerApplication"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package dev.anilbeesetti.nextplayer

import dev.anilbeesetti.nextplayer.core.data.repository.NetworkConnectionRepository
import dev.anilbeesetti.nextplayer.core.media.network.keys.SshKeyStore
import dev.anilbeesetti.nextplayer.core.model.NetworkAuthentication
import dev.anilbeesetti.nextplayer.core.model.NetworkProtocol
import kotlinx.coroutines.flow.first

internal suspend fun initializeSshKeyStore(
repository: NetworkConnectionRepository,
sshKeyStore: SshKeyStore,
) {
val referencedFileNames = try {
repository.getConnections().first()
.asSequence()
.filter { connection ->
connection.protocol == NetworkProtocol.SFTP &&
connection.authentication == NetworkAuthentication.SSH_KEY
}
.mapNotNull { connection ->
connection.privateKeyFileName.trim()
.takeIf(SshKeyStore::isValidFileName)
}
.toSet()
} catch (_: Throwable) {
null
}
sshKeyStore.initialize(referencedFileNames)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package dev.anilbeesetti.nextplayer

import android.net.Uri
import dev.anilbeesetti.nextplayer.core.data.repository.NetworkConnectionRepository
import dev.anilbeesetti.nextplayer.core.media.network.keys.SshKeyStore
import dev.anilbeesetti.nextplayer.core.media.network.keys.StagedSshKey
import dev.anilbeesetti.nextplayer.core.model.NetworkAuthentication
import dev.anilbeesetti.nextplayer.core.model.NetworkConnection
import dev.anilbeesetti.nextplayer.core.model.NetworkProtocol
import java.io.File
import java.io.IOException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Test

class SshKeyStoreInitializerTest {

@Test
fun `startup passes only valid SFTP key-auth references to store`() = runBlocking {
val repository = FakeRepository(
flowOf(
listOf(
connection("feed.key"),
connection("feed.key"),
connection(" dead-beef.key "),
connection(""),
connection("../invalid.key"),
connection("cafe.key", protocol = NetworkProtocol.SMB),
connection("beef.key", authentication = NetworkAuthentication.PASSWORD),
),
),
)
val store = FakeSshKeyStore()

initializeSshKeyStore(repository, store)

assertEquals(setOf("feed.key", "dead-beef.key"), store.referencedFileNames)
}

@Test
fun `database enumeration failure initializes store without cleanup set`() = runBlocking {
val repository = FakeRepository(
flow { throw IOException("database unavailable") },
)
val store = FakeSshKeyStore()

initializeSshKeyStore(repository, store)

assertEquals(null, store.referencedFileNames)
}

private fun connection(
fileName: String,
protocol: NetworkProtocol = NetworkProtocol.SFTP,
authentication: NetworkAuthentication = NetworkAuthentication.SSH_KEY,
) = NetworkConnection(
name = fileName,
protocol = protocol,
host = "host",
authentication = authentication,
privateKeyFileName = fileName,
)
}

private class FakeRepository(
private val connections: Flow<List<NetworkConnection>>,
) : NetworkConnectionRepository {
override fun getConnections(): Flow<List<NetworkConnection>> = connections
override suspend fun getConnection(id: Long): NetworkConnection? = error("Not used")
override suspend fun upsert(connection: NetworkConnection): Long = error("Not used")
override suspend fun delete(id: Long) = error("Not used")
}

private class FakeSshKeyStore : SshKeyStore {
var referencedFileNames: Set<String>? = emptySet()

override suspend fun initialize(referencedFileNames: Set<String>?) {
this.referencedFileNames = referencedFileNames
}

override suspend fun stage(uri: Uri): StagedSshKey = error("Not used")
override fun resolve(fileName: String): File = error("Not used")
override suspend fun commit(fileName: String): String = error("Not used")
override suspend fun delete(fileName: String) = error("Not used")
}
1 change: 1 addition & 0 deletions core/data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ dependencies {
kspAndroidTest(libs.hilt.compiler)

testImplementation(libs.junit4)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.test.ext)
androidTestImplementation(libs.androidx.test.espresso.core)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package dev.anilbeesetti.nextplayer.core.data.repository

import dev.anilbeesetti.nextplayer.core.database.dao.NetworkConnectionDao
import dev.anilbeesetti.nextplayer.core.database.entities.NetworkConnectionEntity
import dev.anilbeesetti.nextplayer.core.model.NetworkAuthentication
import dev.anilbeesetti.nextplayer.core.model.NetworkConnection
import dev.anilbeesetti.nextplayer.core.model.NetworkProtocol
import javax.inject.Inject
Expand Down Expand Up @@ -35,6 +36,11 @@ class LocalNetworkConnectionRepository @Inject constructor(
username = username,
password = password,
useHttps = useHttps,
authentication = runCatching { NetworkAuthentication.valueOf(authentication) }
.getOrDefault(NetworkAuthentication.PASSWORD),
privateKeyFileName = privateKeyFileName,
privateKeyPassphrase = privateKeyPassphrase,
hostKeyFingerprint = hostKeyFingerprint,
)

private fun NetworkConnection.toEntity() = NetworkConnectionEntity(
Expand All @@ -47,5 +53,9 @@ class LocalNetworkConnectionRepository @Inject constructor(
username = username,
password = password,
useHttps = useHttps,
authentication = authentication.name,
privateKeyFileName = privateKeyFileName,
privateKeyPassphrase = privateKeyPassphrase,
hostKeyFingerprint = hostKeyFingerprint,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package dev.anilbeesetti.nextplayer.core.data.repository

import dev.anilbeesetti.nextplayer.core.database.dao.NetworkConnectionDao
import dev.anilbeesetti.nextplayer.core.database.entities.NetworkConnectionEntity
import dev.anilbeesetti.nextplayer.core.model.NetworkAuthentication
import dev.anilbeesetti.nextplayer.core.model.NetworkConnection
import dev.anilbeesetti.nextplayer.core.model.NetworkProtocol
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test

class LocalNetworkConnectionRepositoryTest {
private val dao = FakeNetworkConnectionDao()
private val repository = LocalNetworkConnectionRepository(dao)

@Test
fun `upsert and get connection preserves authentication settings`() = runTest {
val connection = NetworkConnection(
name = "SFTP",
protocol = NetworkProtocol.SFTP,
host = "10.0.2.2",
username = "alice",
authentication = NetworkAuthentication.SSH_KEY,
privateKeyFileName = "123.key",
privateKeyPassphrase = "passphrase",
hostKeyFingerprint = "SHA256:abc",
)

assertEquals(1L, repository.upsert(connection))
assertEquals(connection.copy(id = 1), repository.getConnection(1))
}

@Test
fun `unknown stored authentication falls back to password`() = runTest {
dao.seed(
NetworkConnectionEntity(
id = 7,
name = "Legacy",
protocol = NetworkProtocol.SFTP.name,
host = "10.0.2.2",
port = 22,
authentication = "UNKNOWN",
),
)

assertEquals(
NetworkAuthentication.PASSWORD,
repository.getConnection(7)?.authentication,
)
}

private class FakeNetworkConnectionDao : NetworkConnectionDao {
private val connections = MutableStateFlow<List<NetworkConnectionEntity>>(emptyList())

fun seed(connection: NetworkConnectionEntity) {
connections.value = connections.value + connection
}

override suspend fun upsert(connection: NetworkConnectionEntity): Long {
val id = connection.id.takeIf { it != 0L } ?: 1L
val savedConnection = connection.copy(id = id)
connections.value = connections.value.filterNot { it.id == id } + savedConnection
return id
}

override fun getAll(): Flow<List<NetworkConnectionEntity>> = connections

override suspend fun getById(id: Long): NetworkConnectionEntity? =
connections.value.firstOrNull { it.id == id }

override suspend fun deleteById(id: Long) {
connections.value = connections.value.filterNot { it.id == id }
}
}
}
5 changes: 5 additions & 0 deletions core/database/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ android {
namespace = "dev.anilbeesetti.nextplayer.core.database"
compileSdk = libs.versions.android.compileSdk.get().toInt()

sourceSets {
getByName("androidTest").assets.srcDir("$projectDir/schemas")
}

defaultConfig {
minSdk = libs.versions.android.minSdk.get().toInt()
}
Expand Down Expand Up @@ -48,5 +52,6 @@ dependencies {
testImplementation(libs.junit4)
androidTestImplementation(libs.androidx.test.ext)
androidTestImplementation(libs.androidx.test.espresso.core)
androidTestImplementation(libs.androidx.room.testing)
androidTestImplementation(libs.kotlinx.coroutines.test)
}
Loading
Loading