Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -26,6 +26,7 @@ import io.putdotio.android.files.FilesItemResolver
import io.putdotio.android.files.FilesPage
import io.putdotio.android.files.FilesPlaybackProgress
import io.putdotio.android.files.FilesRepositoryResult
import io.putdotio.android.files.FilesStreamUrlResult
import io.putdotio.android.files.FilesStreamUrls
import io.putdotio.android.files.FilesWatchedRepository
import io.putdotio.android.history.HistoryEventId
Expand Down Expand Up @@ -251,7 +252,7 @@ class TvAutoplayProofTest {

override suspend fun clearPosition(itemId: FilesItemId) = FilesRepositoryResult.Success(Unit)
},
streamUrls = FilesStreamUrls { null },
streamUrls = FilesStreamUrls { FilesStreamUrlResult.DownloadTokenUnavailable },
filesItemResolver = object : FilesItemResolver {
override suspend fun resolveItem(itemId: FilesItemId) = error("No history rows")
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import io.putdotio.android.files.FilesPage
import io.putdotio.android.files.FilesRepository
import io.putdotio.android.files.FilesRepositoryResult
import io.putdotio.android.files.FilesSort
import io.putdotio.android.files.FilesStreamUrlResult
import io.putdotio.android.files.FilesStreamUrls
import io.putdotio.android.files.FilesWatchedRepository
import io.putdotio.android.history.HistoryEventId
Expand Down Expand Up @@ -116,7 +117,7 @@ internal fun tvProofDependencies(

override suspend fun clearPosition(itemId: FilesItemId) = FilesRepositoryResult.Success(Unit)
},
streamUrls = FilesStreamUrls { null },
streamUrls = FilesStreamUrls { FilesStreamUrlResult.DownloadTokenUnavailable },
filesItemResolver = object : FilesItemResolver {
override suspend fun resolveItem(itemId: FilesItemId) = error("No history rows")
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package io.putdotio.android.files

import io.putdotio.android.playback.loadMediaAccount
import io.putdotio.sdk.PutioClient
import io.putdotio.sdk.account.AccountInfo
import io.putdotio.sdk.errors.PutioException
import java.util.concurrent.CancellationException

Expand Down Expand Up @@ -37,15 +39,54 @@ class SdkFilesWatchedRepository(
}
}

/** URLs another player can open; they carry the session's token and must never be logged or stored. */
/**
* URLs another player can open. They carry the account's download token, never the session's
* access token, and must never be logged or stored.
*/
fun interface FilesStreamUrls {
/** The original file's stream URL, or null while no session token is set. */
fun originalStreamUrl(itemId: FilesItemId): String?
suspend fun originalStreamUrl(itemId: FilesItemId): FilesStreamUrlResult
}

class SdkFilesStreamUrls(
private val client: PutioClient,
sealed interface FilesStreamUrlResult {
class Ready(
val url: String,
) : FilesStreamUrlResult {
override fun toString(): String = "Ready(<redacted stream url>)"
}

/** put.io returned no download token; the session's access token is never a substitute. */
data object DownloadTokenUnavailable : FilesStreamUrlResult

data class Failure(
val failure: FilesFailure,
) : FilesStreamUrlResult
}

class SdkFilesStreamUrls internal constructor(
private val loadAccount: suspend () -> AccountInfo,
private val buildOriginalStreamUrl: (fileId: Long, downloadToken: String) -> String,
) : FilesStreamUrls {
override fun originalStreamUrl(itemId: FilesItemId): String? =
client.config.accessToken?.let { client.files.buildOriginalStreamUrl(itemId.value, it) }
constructor(client: PutioClient) : this(
loadAccount = client::loadMediaAccount,
// The SDK names this parameter accessToken; put.io accepts the download token on /stream.
buildOriginalStreamUrl = { fileId, token -> client.files.buildOriginalStreamUrl(fileId, token) },
)

// This SDK boundary converts unexpected implementation failures into the app's stable failure taxonomy.
@Suppress("TooGenericExceptionCaught")
override suspend fun originalStreamUrl(itemId: FilesItemId): FilesStreamUrlResult =
try {
val token = loadAccount().downloadToken
if (token == null) {
FilesStreamUrlResult.DownloadTokenUnavailable
} else {
FilesStreamUrlResult.Ready(buildOriginalStreamUrl(itemId.value, token.value))
}
} catch (error: CancellationException) {
throw error
} catch (error: PutioException) {
FilesStreamUrlResult.Failure(error.toFilesFailure())
} catch (unexpected: Exception) {
FilesStreamUrlResult.Failure(FilesFailure.Unexpected(unexpected))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ class SdkPlaybackRepository internal constructor(
playbackPreference: () -> PlaybackPreference,
) : this(
playbackPreference = playbackPreference,
loadAccount = { client.account.getInfo(AccountInfoQuery(downloadToken = true)) },
loadAccount = client::loadMediaAccount,
resolvePlayback = client.files::resolvePlayback,
loadFile = { fileId ->
client.files.get(
Expand Down Expand Up @@ -339,6 +339,13 @@ class ConvertingPlaybackRepository internal constructor(

private const val AUTOPLAY_PAGE_SIZE = 200

/**
* The account with its download token, the narrow credential put.io accepts on media endpoints.
* Every media URL the app builds carries this token, never the session's access token.
*/
internal suspend fun PutioClient.loadMediaAccount(): AccountInfo =
account.getInfo(AccountInfoQuery(downloadToken = true))

internal class MissingPlaybackCredentialException : IllegalStateException("Playback credential is unavailable")

private fun io.putdotio.sdk.files.PlaybackResolution.toAppResolution(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package io.putdotio.android.files

import io.putdotio.sdk.PutioClient
import io.putdotio.sdk.PutioConfig
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

class SdkFilesStreamUrlsTest {
@Test
fun externalPlayerUrlsCarryTheDownloadTokenNeverTheAccessToken() = runBlocking {
val requests = mutableListOf<String>()
val client = client(requests, downloadToken = "\"$DOWNLOAD_TOKEN\"")

val result = SdkFilesStreamUrls(client).originalStreamUrl(FilesItemId(42L))

val url = (result as FilesStreamUrlResult.Ready).url
assertEquals("https://api.put.io/v2/files/42/stream?oauth_token=$DOWNLOAD_TOKEN", url)
assertFalse(url.contains(ACCESS_TOKEN))
assertFalse(result.toString().contains(DOWNLOAD_TOKEN))
assertTrue(requests.single().endsWith("/account/info?download_token=1"))
}

@Test
fun aMissingDownloadTokenFailsInsteadOfFallingBackToTheAccessToken() = runBlocking {
val result = SdkFilesStreamUrls(client(mutableListOf(), downloadToken = null))
.originalStreamUrl(FilesItemId(42L))

assertEquals(FilesStreamUrlResult.DownloadTokenUnavailable, result)
}

@Test
fun aRejectedSessionIsTheSessionVerdict() = runBlocking {
val result = SdkFilesStreamUrls(client(mutableListOf(), downloadToken = null, status = 401))
.originalStreamUrl(FilesItemId(42L))

assertTrue((result as FilesStreamUrlResult.Failure).failure is FilesFailure.AuthenticationRequired)
}

private fun client(requests: MutableList<String>, downloadToken: String?, status: Int = 200): PutioClient {
val body = if (status == 200) accountInfo(downloadToken) else UNAUTHORIZED
val http = OkHttpClient.Builder()
.addInterceptor(
Interceptor { chain ->
requests += chain.request().url.toString()
Response.Builder()
.request(chain.request())
.protocol(Protocol.HTTP_1_1)
.code(status)
.message("")
.body(body.toResponseBody("application/json".toMediaType()))
.build()
},
)
.build()
return PutioClient(PutioConfig(accessToken = ACCESS_TOKEN), http)
}

private fun accountInfo(downloadToken: String?): String =
"""
{"status":"OK","info":{"user_id":1,"username":"u","mail":"u@example.com","avatar_url":"",
"disk":{"avail":1,"size":1,"used":0},"settings":{"sort_by":"NAME_ASC"},
"account_status":"active","download_token":${downloadToken ?: "null"}}}
""".trimIndent()

private companion object {
const val ACCESS_TOKEN = "full-oauth-access-token"
const val DOWNLOAD_TOKEN = "narrow-download-token"
const val UNAUTHORIZED =
"""{"status":"ERROR","error_type":"invalid_grant","error_message":"Unauthorized","status_code":401}"""
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import io.putdotio.android.files.FilesItemResolver
import io.putdotio.android.files.FilesOpenOrigin
import io.putdotio.android.files.FilesPage
import io.putdotio.android.files.FilesRepositoryResult
import io.putdotio.android.files.FilesStreamUrlResult
import io.putdotio.android.files.FilesStreamUrls
import io.putdotio.android.files.FilesWatchedRepository
import io.putdotio.android.files.StubFilesRepository
Expand Down Expand Up @@ -95,6 +96,9 @@ class TvSessionViewModelTest {
private var historyItems = emptyList<HistoryItem>()
/** The root listing's pages, continued by `page-N` cursors; empty lists an empty root. */
private var rootPages = emptyList<FilesPage>()
private var streamResult: (FilesItemId) -> FilesStreamUrlResult = {
FilesStreamUrlResult.Ready("https://api.put.io/v2/files/${it.value}/stream?oauth_token=t")
}
private val dependencies = TvSessionDependencies(
filesRepository = object : StubFilesRepository() {
override suspend fun loadFolder(folderId: FilesItemId): FilesRepositoryResult<FilesPage> {
Expand Down Expand Up @@ -128,7 +132,7 @@ class TvSessionViewModelTest {
override suspend fun save(change: AndroidAppConfigChange) = AndroidAppConfigRepositoryResult.Success(Unit)
},
watchedRepository = watched,
streamUrls = FilesStreamUrls { "https://api.put.io/v2/files/${it.value}/stream?oauth_token=t" },
streamUrls = FilesStreamUrls { streamResult(it) },
filesItemResolver = object : FilesItemResolver {
override suspend fun resolveItem(itemId: FilesItemId) = FilesRepositoryResult.Success(
FilesItem(
Expand Down Expand Up @@ -287,7 +291,38 @@ class TvSessionViewModelTest {
assertEquals(rejected, session.fileActionFailure.value)
session.dismissFileActionFailure()
assertEquals(rejected, session.fileActionFailure.value)
assertEquals("https://api.put.io/v2/files/9/stream?oauth_token=t", session.originalStreamUrl(video))
assertEquals(
"https://api.put.io/v2/files/9/stream?oauth_token=t",
(session.originalStreamUrl(video) as FilesStreamUrlResult.Ready).url,
)
}

@Test
fun `a rejected stream lookup is the session verdict and survives dismissal`() = runTest {
val rejected = FilesFailure.AuthenticationRequired(PutioConfigurationException("401"))
streamResult = { FilesStreamUrlResult.Failure(rejected) }
val session = checkNotNull(TvSessionViewModel(auth).sessionFor(account(), TvAuthSessionId(1), dependencies))

val result = session.originalStreamUrl(streamItem)

assertEquals(FilesStreamUrlResult.Failure(rejected), result)
assertEquals(rejected, session.fileActionFailure.value)
session.dismissFileActionFailure()
assertEquals(rejected, session.fileActionFailure.value)
}

@Test
fun `a missing download token or other stream failure is no session verdict`() = runTest {
val session = checkNotNull(TvSessionViewModel(auth).sessionFor(account(), TvAuthSessionId(1), dependencies))

streamResult = { FilesStreamUrlResult.DownloadTokenUnavailable }
assertEquals(FilesStreamUrlResult.DownloadTokenUnavailable, session.originalStreamUrl(streamItem))
assertNull(session.fileActionFailure.value)

val offline = FilesStreamUrlResult.Failure(FilesFailure.Unexpected(IllegalStateException("offline")))
streamResult = { offline }
assertEquals(offline, session.originalStreamUrl(streamItem))
assertNull(session.fileActionFailure.value)
}

@Test
Expand Down Expand Up @@ -595,4 +630,13 @@ class TvSessionViewModelTest {
closed = true
}
}

private val streamItem = FilesItem(
id = FilesItemId(9),
parentId = FilesItemId(0L),
name = "clip.mp4",
type = PutioFileType.VIDEO,
sizeBytes = 1L,
createdAt = "2026-04-20T10:00:00Z",
)
}
28 changes: 22 additions & 6 deletions app/src/tv/kotlin/io/putdotio/android/PutioApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import io.putdotio.android.files.FilesContent
import io.putdotio.android.files.FilesFailure
import io.putdotio.android.files.FilesItem
import io.putdotio.android.files.FilesOpenOrigin
import io.putdotio.android.files.FilesStreamUrlResult
import io.putdotio.android.files.SdkFilesRepository
import io.putdotio.android.files.SdkFilesStreamUrls
import io.putdotio.android.files.SdkFilesWatchedRepository
Expand Down Expand Up @@ -292,7 +293,9 @@ internal fun TvSessionShell(
// A row's actions: VLC gets the original file; a watched toggle writes the
// account's position; deletion runs on the shared browser operation.
val context = LocalContext.current
val streamScope = rememberCoroutineScope()
var filesNotice by remember(session) { mutableStateOf<Int?>(null) }
var streamFailure by remember(session) { mutableStateOf<FilesFailure?>(null) }
// A requester on the pane lands on its first focusable descendant (Refresh); the
// pane's own entry effects then move focus to the row it remembers.
TvFilesScreen(
Expand All @@ -305,21 +308,34 @@ internal fun TvSessionShell(
confirmedTrashEnabled = settingsState.confirmedTrashEnabled(),
watchedToggleEnabled = settingsState.confirmedResumePlayback() == true,
onOpenInVlc = { item ->
val url = session.originalStreamUrl(item)
filesNotice = when {
url == null -> R.string.tv_files_stream_unavailable
launchVlc(context, url, item) -> null
else -> R.string.tv_files_vlc_missing
streamScope.launch {
val stream = session.originalStreamUrl(item)
filesNotice = null
streamFailure = null
when (stream) {
is FilesStreamUrlResult.Ready ->
if (!launchVlc(context, stream.url, item)) filesNotice = R.string.tv_files_vlc_missing
FilesStreamUrlResult.DownloadTokenUnavailable ->
filesNotice = R.string.tv_files_stream_unavailable
// A 401 is the session's verdict, which the session already holds.
is FilesStreamUrlResult.Failure -> streamFailure =
stream.failure.takeUnless { it is FilesFailure.AuthenticationRequired }
}
}
},
onSetWatched = session::setWatched,
notice = filesNotice?.let { stringResource(it) }
?: streamFailure?.let { stringResource(R.string.tv_files_stream_error, it.tvMessageText()) }
?: fileActionFailure?.takeUnless { it is FilesFailure.AuthenticationRequired }
?.let { stringResource(R.string.tv_files_watched_error, it.tvMessageText()) },
// OK clears only what it was shown; a failure that arrived behind a VLC
// notice is shown next.
onDismissNotice = {
if (filesNotice != null) filesNotice = null else session.dismissFileActionFailure()
when {
filesNotice != null -> filesNotice = null
streamFailure != null -> streamFailure = null
else -> session.dismissFileActionFailure()
}
},
)
},
Expand Down
12 changes: 10 additions & 2 deletions app/src/tv/kotlin/io/putdotio/android/tv/TvSessionViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import io.putdotio.android.files.FilesOpenOrigin
import io.putdotio.android.files.FilesPlaybackProgress
import io.putdotio.android.files.FilesRepository
import io.putdotio.android.files.FilesRepositoryResult
import io.putdotio.android.files.FilesStreamUrlResult
import io.putdotio.android.files.FilesStreamUrls
import io.putdotio.android.files.FilesWatchedRepository
import io.putdotio.android.history.HistoryController
Expand Down Expand Up @@ -273,8 +274,15 @@ internal class TvSession internal constructor(
job.start()
}

/** The original file's URL for an external player, or null without a session token. */
fun originalStreamUrl(item: FilesItem): String? = streamUrls.originalStreamUrl(item.id)
/**
* The original file's URL for an external player, carrying the account's download token.
* A 401 is recorded as the session verdict; the caller explains every other failure.
*/
suspend fun originalStreamUrl(item: FilesItem): FilesStreamUrlResult =
streamUrls.originalStreamUrl(item.id).also { result ->
val failure = (result as? FilesStreamUrlResult.Failure)?.failure
if (failure is FilesFailure.AuthenticationRequired) mutableFileActionFailure.value = failure
}
Comment on lines +281 to +285

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added in 65c8a8d: TvSessionViewModelTest now stubs a 401 stream lookup and asserts it becomes the session verdict that survives dismissal, which fails with the wiring removed. It also checks that a missing download token or another failure records no verdict.


/** Drops the explanation the pane showed; a 401 stays, since it is a session verdict. */
fun dismissFileActionFailure() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ internal fun TvFilesDeleteDialog(

/**
* Hands the original file to VLC. False when VLC is not installed; the URL carries the
* session token and goes only into the intent, never into a log or a message.
* account's download token and goes only into the intent, never into a log or a message.
*/
internal fun launchVlc(context: Context, streamUrl: String, item: FilesItem): Boolean {
val intent = Intent(Intent.ACTION_VIEW)
Expand Down
1 change: 1 addition & 0 deletions app/src/tv/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
<string name="tv_files_delete_message">%1$s will be removed right away. This cannot be undone.</string>
<string name="tv_files_vlc_missing">VLC isn’t installed on this device.</string>
<string name="tv_files_stream_unavailable">Couldn’t prepare the stream. Sign in again.</string>
<string name="tv_files_stream_error">Couldn’t prepare the stream. %1$s</string>
<string name="tv_files_watched_error">Couldn’t update the watched state. %1$s</string>
<string name="tv_files_deleting">Moving to trash</string>
<string name="tv_files_deleting_permanently">Deleting</string>
Expand Down
Loading