feat: add local video share upload support
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user