feat: add local video share upload support

This commit is contained in:
2026-04-09 21:38:39 -05:00
parent d940e4de9f
commit d8e40bf847
6 changed files with 299 additions and 16 deletions
+5 -3
View File
@@ -33,7 +33,7 @@
android:finishOnTaskLaunch="true"
android:taskAffinity=""
android:launchMode="singleTask"
android:theme="@android:style/Theme.NoDisplay"/>
android:theme="@style/Theme.RelayTV.Share"/>
<activity-alias
android:name=".ShareQueueAlias"
@@ -45,7 +45,7 @@
android:taskAffinity=""
android:launchMode="singleTask"
android:label="@string/share_queue_label"
android:theme="@android:style/Theme.NoDisplay">
android:theme="@style/Theme.RelayTV.Share">
<meta-data
android:name="pro.relaytv.SHARE_ENDPOINT_PATH"
android:value="/smart" />
@@ -56,6 +56,7 @@
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="text/plain"/>
<data android:mimeType="video/*"/>
</intent-filter>
</activity-alias>
@@ -69,7 +70,7 @@
android:taskAffinity=""
android:launchMode="singleTask"
android:label="@string/share_play_now_label"
android:theme="@android:style/Theme.NoDisplay">
android:theme="@style/Theme.RelayTV.Share">
<meta-data
android:name="pro.relaytv.SHARE_ENDPOINT_PATH"
android:value="/play_now" />
@@ -80,6 +81,7 @@
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="text/plain"/>
<data android:mimeType="video/*"/>
</intent-filter>
</activity-alias>
+40
View File
@@ -1,9 +1,15 @@
package pro.relaytv
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
import java.util.concurrent.TimeUnit
object Net {
@@ -14,6 +20,12 @@ object Net {
.writeTimeout(25, TimeUnit.SECONDS)
.build()
val uploadClient: OkHttpClient = client.newBuilder()
.callTimeout(20, TimeUnit.MINUTES)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.MINUTES)
.build()
fun get(url: String): Request = Request.Builder().url(url).get().build()
fun postJson(url: String, json: String): Request {
@@ -21,4 +33,32 @@ object Net {
val body = json.toRequestBody(mt)
return Request.Builder().url(url).post(body).build()
}
fun postMultipartFile(
url: String,
file: File,
fileFieldName: String = "file",
fileName: String = file.name,
mimeType: String? = null,
title: String? = null,
): Request {
val contentType = mimeType?.toMediaTypeOrNull()
?: "application/octet-stream".toMediaType()
val bodyBuilder = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
fileFieldName,
fileName,
file.asRequestBody(contentType)
)
if (!title.isNullOrBlank()) {
bodyBuilder.addFormDataPart("title", title)
}
return Request.Builder()
.url(url)
.post(bodyBuilder.build())
.build()
}
}
+163 -8
View File
@@ -1,21 +1,38 @@
package pro.relaytv
import android.content.ContentResolver
import android.content.pm.PackageManager
import android.content.Intent
import android.database.Cursor
import android.net.Uri
import android.os.Bundle
import android.provider.OpenableColumns
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.work.Data
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import java.io.File
import java.util.UUID
class ShareActivity : AppCompatActivity() {
companion object {
private const val META_ENDPOINT_PATH = "pro.relaytv.SHARE_ENDPOINT_PATH"
private const val META_SUCCESS_TEMPLATE = "pro.relaytv.SHARE_SUCCESS_TEMPLATE"
private val SUPPORTED_VIDEO_MIME_TYPES = setOf("video/mp4", "video/webm")
}
private sealed class SharePayload {
data class RemoteUrl(val url: String) : SharePayload()
data class LocalVideo(val filePath: String, val mimeType: String, val title: String) : SharePayload()
}
private data class LocalVideoStageResult(
val payload: SharePayload.LocalVideo? = null,
val errorMessageResId: Int? = null,
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -30,25 +47,69 @@ class ShareActivity : AppCompatActivity() {
return
}
val shared = intent.getStringExtra(Intent.EXTRA_TEXT) ?: ""
val url = extractUrl(shared) ?: run { finish(); return }
val endpointPath = resolveMetaString(META_ENDPOINT_PATH).ifBlank { "/smart" }
val successTemplate = resolveMetaString(META_SUCCESS_TEMPLATE).ifBlank { "Sent to \"%s\"" }
val streamUri = extractStreamUri()
if (streamUri != null) {
Thread {
val result = stageLocalVideo(streamUri)
runOnUiThread {
if (result.payload != null) {
dispatchPayload(base, endpointPath, successTemplate, result.payload)
} else if (result.errorMessageResId != null) {
Toast.makeText(this, getString(result.errorMessageResId), Toast.LENGTH_LONG).show()
}
finishAndRemoveTask()
}
}.start()
return
}
val input = Data.Builder()
val payload = extractRemoteUrlPayload() ?: run { finish(); return }
dispatchPayload(base, endpointPath, successTemplate, payload)
finishAndRemoveTask()
}
private fun dispatchPayload(
base: String,
endpointPath: String,
successTemplate: String,
payload: SharePayload,
) {
val inputBuilder = Data.Builder()
.putString(ShareWorker.KEY_BASE, base)
.putString(ShareWorker.KEY_URL, url)
.putString(ShareWorker.KEY_ENDPOINT_PATH, endpointPath)
.build()
val toastTemplate = when (payload) {
is SharePayload.RemoteUrl -> successTemplate
is SharePayload.LocalVideo -> {
if (endpointPath == "/play_now") {
getString(R.string.share_video_play_started)
} else {
getString(R.string.share_video_queue_started)
}
}
}
when (payload) {
is SharePayload.RemoteUrl -> {
inputBuilder.putString(ShareWorker.KEY_URL, payload.url)
}
is SharePayload.LocalVideo -> {
inputBuilder
.putString(ShareWorker.KEY_UPLOAD_FILE_PATH, payload.filePath)
.putString(ShareWorker.KEY_UPLOAD_MIME_TYPE, payload.mimeType)
.putString(ShareWorker.KEY_UPLOAD_TITLE, payload.title)
}
}
val req = OneTimeWorkRequestBuilder<ShareWorker>()
.setInputData(input)
.setInputData(inputBuilder.build())
.build()
WorkManager.getInstance(this).enqueue(req)
val serverName = HostStore.getActiveHost(this)?.name?.ifBlank { "Server" } ?: "Server"
Toast.makeText(this, successTemplate.format(serverName), Toast.LENGTH_SHORT).show()
finishAndRemoveTask()
Toast.makeText(this, toastTemplate.format(serverName), Toast.LENGTH_SHORT).show()
}
private fun resolveMetaString(key: String): String {
@@ -65,4 +126,98 @@ class ShareActivity : AppCompatActivity() {
val m = Regex("""https?://\S+""").find(text)
return m?.value?.trim()?.trimEnd(')', ']', '>', '"', '\'')
}
private fun extractRemoteUrlPayload(): SharePayload? {
val shared = intent.getStringExtra(Intent.EXTRA_TEXT) ?: ""
val url = extractUrl(shared) ?: return null
return SharePayload.RemoteUrl(url)
}
private fun extractStreamUri(): Uri? {
@Suppress("DEPRECATION")
val extra = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
return extra
}
private fun stageLocalVideo(uri: Uri): LocalVideoStageResult {
val mimeType = contentResolver.getType(uri)
?.substringBefore(';')
?.trim()
?.lowercase()
?: ""
val displayName = queryDisplayName(uri)
val inferredMimeType = when {
mimeType.isNotBlank() -> mimeType
displayName.endsWith(".mp4", ignoreCase = true) || displayName.endsWith(".m4v", ignoreCase = true) -> "video/mp4"
displayName.endsWith(".webm", ignoreCase = true) -> "video/webm"
else -> ""
}
if (inferredMimeType !in SUPPORTED_VIDEO_MIME_TYPES) {
return LocalVideoStageResult(errorMessageResId = R.string.share_video_unsupported)
}
val staged = runCatching {
copyUriToCache(uri, displayName, inferredMimeType)
}.getOrNull()
if (staged == null) {
return LocalVideoStageResult(errorMessageResId = R.string.share_video_read_failed)
}
val title = staged.nameWithoutExtension.ifBlank { displayName.substringBeforeLast('.', displayName) }
return LocalVideoStageResult(
payload = SharePayload.LocalVideo(
filePath = staged.absolutePath,
mimeType = inferredMimeType,
title = title
)
)
}
private fun copyUriToCache(uri: Uri, displayName: String, mimeType: String): File {
pruneStagedFiles()
val dir = File(cacheDir, "shared-media").apply { mkdirs() }
val ext = when {
displayName.contains('.') -> ".${displayName.substringAfterLast('.')}"
mimeType == "video/webm" -> ".webm"
else -> ".mp4"
}
val baseName = sanitizeFilename(displayName.substringBeforeLast('.', displayName))
val outFile = File(dir, "${baseName}-${UUID.randomUUID().toString().take(8)}${ext.lowercase()}")
contentResolver.openInputStream(uri)?.use { input ->
outFile.outputStream().use { output ->
input.copyTo(output)
output.flush()
}
} ?: error("No readable input stream for shared media")
return outFile
}
private fun queryDisplayName(uri: Uri): String {
val fallback = uri.lastPathSegment?.substringAfterLast('/')?.takeIf { it.isNotBlank() } ?: "upload"
val cursor: Cursor = contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?: return fallback
cursor.use {
if (!it.moveToFirst()) return fallback
val idx = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (idx < 0) return fallback
return it.getString(idx)?.takeIf { value -> value.isNotBlank() } ?: fallback
}
}
private fun sanitizeFilename(name: String): String {
val cleaned = name.trim().replace(Regex("[^A-Za-z0-9._-]+"), "-").trim('-', '.', '_')
return if (cleaned.isBlank()) "relaytv-upload" else cleaned.take(96)
}
private fun pruneStagedFiles() {
val dir = File(cacheDir, "shared-media")
if (!dir.exists()) return
val cutoff = System.currentTimeMillis() - (2L * 24L * 60L * 60L * 1000L)
dir.listFiles().orEmpty().forEach { file ->
if (file.isFile && file.lastModified() < cutoff) {
runCatching { file.delete() }
}
}
}
}
+81 -5
View File
@@ -9,22 +9,42 @@ import androidx.core.app.NotificationCompat
import androidx.work.Worker
import androidx.work.WorkerParameters
import org.json.JSONObject
import java.io.File
class ShareWorker(appContext: Context, params: WorkerParameters) : Worker(appContext, params) {
private class ShareFailure(
message: String,
val retryable: Boolean,
) : Exception(message)
companion object {
const val KEY_BASE = "base"
const val KEY_URL = "url"
const val KEY_ENDPOINT_PATH = "endpoint_path"
const val KEY_UPLOAD_FILE_PATH = "upload_file_path"
const val KEY_UPLOAD_MIME_TYPE = "upload_mime_type"
const val KEY_UPLOAD_TITLE = "upload_title"
private const val CHANNEL_ID = "relaytv_silent"
private const val NOTIF_ID = 4242
}
override fun doWork(): Result {
val base = inputData.getString(KEY_BASE)?.trim()?.trimEnd('/') ?: return Result.failure()
val url = inputData.getString(KEY_URL) ?: return Result.failure()
val endpointPath = inputData.getString(KEY_ENDPOINT_PATH)?.trim()?.ifBlank { "/smart" } ?: "/smart"
val uploadPath = inputData.getString(KEY_UPLOAD_FILE_PATH)?.trim().orEmpty()
val localUpload = uploadPath.takeIf { it.isNotBlank() }?.let { File(it) }
val displayText = inputData.getString(KEY_UPLOAD_TITLE)?.takeIf { !it.isNullOrBlank() }
?: localUpload?.name
?: inputData.getString(KEY_URL)
?: "item"
return try {
val url = when {
localUpload != null -> uploadMedia(base, localUpload)
else -> inputData.getString(KEY_URL) ?: return Result.failure()
}
val payload = JSONObject().put("url", url).toString()
val req = Net.postJson(base + endpointPath, payload)
@@ -40,21 +60,77 @@ class ShareWorker(appContext: Context, params: WorkerParameters) : Worker(appCon
}
}.getOrDefault(if (ok) "Sent" else "Error")
postNotification(msg, url, tapToOpen = true)
if (ok) Result.success() else Result.retry()
postNotification(msg, displayText, tapToOpen = true)
when {
ok -> {
localUpload?.delete()
Result.success()
}
shouldRetry(resp.code) -> Result.retry()
else -> {
localUpload?.delete()
Result.failure()
}
}
}
} catch (e: ShareFailure) {
postNotification("Send failed: ${e.message.orEmpty().take(80)}", displayText, tapToOpen = true)
if (!e.retryable) {
localUpload?.delete()
}
if (e.retryable) Result.retry() else Result.failure()
} catch (e: Exception) {
val reason = (e.message ?: e.javaClass.simpleName).take(80)
if (reason.contains("timeout", ignoreCase = true)) {
postNotification("Sent (processing)", url, tapToOpen = true)
postNotification("Sent (processing)", displayText, tapToOpen = true)
Result.success()
} else {
postNotification("Send failed: $reason", url, tapToOpen = true)
postNotification("Send failed: $reason", displayText, tapToOpen = true)
Result.retry()
}
}
}
private fun uploadMedia(base: String, file: File): String {
if (!file.exists() || !file.isFile) {
throw ShareFailure("Shared media file is no longer available", retryable = false)
}
val mimeType = inputData.getString(KEY_UPLOAD_MIME_TYPE)?.trim().orEmpty()
val title = inputData.getString(KEY_UPLOAD_TITLE)?.trim().orEmpty()
val req = Net.postMultipartFile(
url = "$base/ingest/media",
file = file,
mimeType = mimeType.ifBlank { null },
title = title.ifBlank { null },
)
Net.uploadClient.newCall(req).execute().use { resp ->
val body = resp.body?.string().orEmpty()
if (!resp.isSuccessful) {
val detail = extractDetail(body) ?: "Upload failed"
throw ShareFailure(detail, retryable = shouldRetry(resp.code))
}
val json = JSONObject(body)
val uploadedUrl = json.optString("url").trim()
if (uploadedUrl.isBlank()) {
throw ShareFailure("Upload completed without a playable URL", retryable = false)
}
return uploadedUrl
}
}
private fun extractDetail(body: String): String? {
if (body.isBlank()) return null
return runCatching {
val json = JSONObject(body)
json.optString("detail").takeIf { it.isNotBlank() }
?: json.optString("error").takeIf { it.isNotBlank() }
}.getOrNull()
}
private fun shouldRetry(code: Int): Boolean {
return code == 408 || code == 429 || code >= 500
}
private fun postNotification(title: String, text: String, tapToOpen: Boolean) {
val nm = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+4
View File
@@ -25,6 +25,10 @@
<string name="share_play_now_label">RelayTV Play</string>
<string name="share_queue_toast">Queued on "%s"</string>
<string name="share_play_now_toast">Playing now on "%s"</string>
<string name="share_video_queue_started">Uploading video to "%s"</string>
<string name="share_video_play_started">Uploading video for playback on "%s"</string>
<string name="share_video_unsupported">Unsupported shared video. RelayTV currently accepts MP4 or WebM uploads.</string>
<string name="share_video_read_failed">Couldn\'t read the shared video from this app.</string>
<string name="privacy_policy">Privacy policy</string>
<string name="privacy_policy_url">https://github.com/mcgeezy/relaytv-android/blob/main/docs/PRIVACY_POLICY.md</string>
<string name="privacy_open_failed">No browser available to open the privacy policy.</string>
+6
View File
@@ -10,4 +10,10 @@
</style>
<style name="Theme.RelayTV.NoActionBar" parent="Theme.Material3.DayNight.NoActionBar" />
<style name="Theme.RelayTV.Share" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:colorBackground">@android:color/transparent</item>
<item name="android:backgroundDimEnabled">false</item>
</style>
</resources>