feat(app-phone): полноценный UI телефона — каталог Jellyfin, детали, пульт очков, реальное скачивание с S3

- 4 экрана по макету sketches/phone-ui: Каталог (табы Фильмы/Сериалы/Папки, поиск, бейджи зеркала), Детали (озвучки, статус конвертации, чипы скачивания), Очки (пульт, статус), Чат-заглушка
- S3 SigV4 presign (S3Presigner): mirror-client read-only креды; watch() и DownloadManager подписывают URL (бакет приватный)
- DownloadManager: реальное скачивание video+audio с прогрессом, readTimeout=0 (длинные файлы)
- PhoneActions.watch: параметр presign (app-host работает без изменений)
- TESTING.md: блок телефонных кейсов TP-1..6
This commit is contained in:
Hermes Agent
2026-08-19 09:13:56 +03:00
parent 4458678698
commit 6069400b88
19 changed files with 2145 additions and 68 deletions
@@ -0,0 +1,85 @@
package pw.binom.viewmate.phone
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class S3PresignerTest {
private val url = "https://s3.binom.pw/media/mirror/m1/video.mkv"
private val accessKey = "MirrorClient01QwertyKey"
private val secretKey = "MirrorClient01SecretKeyValueForGlassesOnly"
private val region = "us-east-1"
private val expires = 3600
private val amzDate = "20260819T000000Z"
@Test
fun presignContainsAllXAmzParamsInAlphabeticalOrder() {
val signed = S3Presigner.presignGetAt(url, accessKey, secretKey, region, expires, amzDate)
assertTrue(signed.startsWith("$url?"), "presigned url должен начинаться с исходного url: $signed")
val query = signed.substringAfter('?')
assertTrue(query.contains("X-Amz-Algorithm=AWS4-HMAC-SHA256"))
assertTrue(query.contains("X-Amz-Credential=$accessKey%2F20260819%2F$region%2Fs3%2Faws4_request"))
assertTrue(query.contains("X-Amz-Date=$amzDate"))
assertTrue(query.contains("X-Amz-Expires=$expires"))
assertTrue(query.contains("X-Amz-SignedHeaders=host"))
assertTrue(query.contains("X-Amz-Signature="))
assertFalse(query.contains("&X-Amz-Expires=0"))
}
@Test
fun signatureMatchesReference() {
// эталон получен той же логикой (SigV4 presign GET, UNSIGNED-PAYLOAD, region us-east-1,
// service s3, expires=3600, amzDate=20260819T000000Z) из Python-реализации, 19.08
val signed = S3Presigner.presignGetAt(url, accessKey, secretKey, region, expires, amzDate)
assertEquals(
"https://s3.binom.pw/media/mirror/m1/video.mkv" +
"?X-Amz-Algorithm=AWS4-HMAC-SHA256" +
"&X-Amz-Credential=MirrorClient01QwertyKey%2F20260819%2Fus-east-1%2Fs3%2Faws4_request" +
"&X-Amz-Date=20260819T000000Z" +
"&X-Amz-Expires=3600" +
"&X-Amz-SignedHeaders=host" +
"&X-Amz-Signature=c023bf37aabfbc224641090bdb4082fdf54033ca3a6ef07680b00b560c7b140f",
signed,
)
}
@Test
fun signatureChangesWithTimeExpiresAndKey() {
val base = S3Presigner.presignGetAt(url, accessKey, secretKey, region, expires, amzDate)
val sigBase = base.substringAfter("X-Amz-Signature=")
val otherTime = S3Presigner.presignGetAt(url, accessKey, secretKey, region, expires, "20260820T000000Z")
assertFalse(otherTime.contains("X-Amz-Date=$amzDate"), "время должно попадать в подпись")
val otherExpires = S3Presigner.presignGetAt(url, accessKey, secretKey, region, 120, amzDate)
assertTrue(otherExpires.contains("X-Amz-Expires=120"))
val otherKey = S3Presigner.presignGetAt(url, "OtherAccess", secretKey, region, expires, amzDate)
assertFalse(
otherKey.substringAfter("X-Amz-Signature=") == sigBase,
"подпись должна зависеть от access key",
)
}
@Test
fun presignGetProducesConsistentNowUrl() {
val signed = S3Presigner.presignGet(url, accessKey, secretKey, region, expires)
assertTrue(signed.startsWith("$url?"))
val query = signed.substringAfter('?')
assertTrue(query.contains("X-Amz-Algorithm=AWS4-HMAC-SHA256"))
assertTrue(query.contains("X-Amz-Credential="))
assertTrue(query.contains("X-Amz-SignedHeaders=host"))
assertTrue(query.contains("X-Amz-Expires=$expires"))
val amzDateNow = query.substringAfter("X-Amz-Date=").substringBefore("&X-Amz-Expires")
assertTrue(
Regex("^\\d{8}T\\d{6}Z$").matches(amzDateNow),
"X-Amz-Date должен быть в формате yyyyMMdd'T'HHmmss'Z': $amzDateNow",
)
assertTrue(query.substringAfter("X-Amz-Signature=").let { it.length == 64 && it.all { c -> c in '0'..'9' || c in 'a'..'f' } })
}
}