about summary refs log tree commit diff
path: root/modules
diff options
context:
space:
mode:
authorHailey <me@haileyok.com>2024-08-15 11:23:48 -0700
committerGitHub <noreply@github.com>2024-08-15 11:23:48 -0700
commit11061b628ef5b5805c6435155ca2a571001e4643 (patch)
treed1e3c672d225592af7e1341332c6c6aeb979f216 /modules
parentb9975697e22ef729e60b9111883127961258445b (diff)
downloadvoidsky-11061b628ef5b5805c6435155ca2a571001e4643.tar.zst
[Video] Download videos (#4886)
Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>
Diffstat (limited to 'modules')
-rw-r--r--modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/ExpoHLSDownloadModule.kt35
-rw-r--r--modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/HLSDownloadView.kt141
-rw-r--r--modules/expo-bluesky-swiss-army/expo-module.config.json4
-rw-r--r--modules/expo-bluesky-swiss-army/index.ts10
-rw-r--r--modules/expo-bluesky-swiss-army/ios/HLSDownload/ExpoHLSDownloadModule.swift31
-rw-r--r--modules/expo-bluesky-swiss-army/ios/HLSDownload/HLSDownloadView.swift148
-rw-r--r--modules/expo-bluesky-swiss-army/src/HLSDownload/index.native.tsx39
-rw-r--r--modules/expo-bluesky-swiss-army/src/HLSDownload/index.tsx22
-rw-r--r--modules/expo-bluesky-swiss-army/src/HLSDownload/types.ts10
9 files changed, 438 insertions, 2 deletions
diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/ExpoHLSDownloadModule.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/ExpoHLSDownloadModule.kt
new file mode 100644
index 000000000..786b84e41
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/ExpoHLSDownloadModule.kt
@@ -0,0 +1,35 @@
+package expo.modules.blueskyswissarmy.hlsdownload
+
+import android.net.Uri
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+class ExpoHLSDownloadModule : Module() {
+  override fun definition() =
+    ModuleDefinition {
+      Name("ExpoHLSDownload")
+
+      Function("isAvailable") {
+        return@Function true
+      }
+
+      View(HLSDownloadView::class) {
+        Events(
+          arrayOf(
+            "onStart",
+            "onError",
+            "onProgress",
+            "onSuccess",
+          ),
+        )
+
+        Prop("downloaderUrl") { view: HLSDownloadView, downloaderUrl: Uri ->
+          view.downloaderUrl = downloaderUrl
+        }
+
+        AsyncFunction("startDownloadAsync") { view: HLSDownloadView, sourceUrl: Uri ->
+          view.startDownload(sourceUrl)
+        }
+      }
+    }
+}
diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/HLSDownloadView.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/HLSDownloadView.kt
new file mode 100644
index 000000000..5f3082a81
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/HLSDownloadView.kt
@@ -0,0 +1,141 @@
+package expo.modules.blueskyswissarmy.hlsdownload
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.net.Uri
+import android.util.Base64
+import android.util.Log
+import android.webkit.DownloadListener
+import android.webkit.JavascriptInterface
+import android.webkit.WebView
+import expo.modules.kotlin.AppContext
+import expo.modules.kotlin.viewevent.EventDispatcher
+import expo.modules.kotlin.viewevent.ViewEventCallback
+import expo.modules.kotlin.views.ExpoView
+import org.json.JSONObject
+import java.io.File
+import java.io.FileOutputStream
+import java.net.URI
+import java.util.UUID
+
+class HLSDownloadView(
+  context: Context,
+  appContext: AppContext,
+) : ExpoView(context, appContext),
+  DownloadListener {
+  private val webView = WebView(context)
+
+  var downloaderUrl: Uri? = null
+
+  private val onStart by EventDispatcher()
+  private val onError by EventDispatcher()
+  private val onProgress by EventDispatcher()
+  private val onSuccess by EventDispatcher()
+
+  init {
+    this.setupWebView()
+    this.addView(this.webView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
+  }
+
+  @SuppressLint("SetJavaScriptEnabled")
+  private fun setupWebView() {
+    val webSettings = this.webView.settings
+    webSettings.javaScriptEnabled = true
+    webSettings.domStorageEnabled = true
+
+    webView.setDownloadListener(this)
+    webView.addJavascriptInterface(WebAppInterface(this.onProgress, this.onError), "AndroidInterface")
+  }
+
+  override fun onDetachedFromWindow() {
+    super.onDetachedFromWindow()
+    this.webView.stopLoading()
+    this.webView.clearHistory()
+    this.webView.removeAllViews()
+    this.webView.destroy()
+  }
+
+  fun startDownload(sourceUrl: Uri) {
+    if (this.downloaderUrl == null) {
+      this.onError(mapOf(ERROR_KEY to "Downloader URL is not set."))
+      return
+    }
+
+    val url = URI("${this.downloaderUrl}?videoUrl=$sourceUrl")
+    this.webView.loadUrl(url.toString())
+    this.onStart(mapOf())
+  }
+
+  override fun onDownloadStart(
+    url: String?,
+    userAgent: String?,
+    contentDisposition: String?,
+    mimeType: String?,
+    contentLength: Long,
+  ) {
+    if (url == null) {
+      this.onError(mapOf(ERROR_KEY to "Failed to retrieve download URL from webview."))
+      return
+    }
+
+    val tempDir = context.cacheDir
+    val fileName = "${UUID.randomUUID()}.mp4"
+    val file = File(tempDir, fileName)
+
+    val base64 = url.split(",")[1]
+    val bytes = Base64.decode(base64, Base64.DEFAULT)
+
+    val fos = FileOutputStream(file)
+    try {
+      fos.write(bytes)
+    } catch (e: Exception) {
+      Log.e("FileDownload", "Error downloading file", e)
+      this.onError(mapOf(ERROR_KEY to e.message.toString()))
+      return
+    } finally {
+      fos.close()
+    }
+
+    val uri = Uri.fromFile(file)
+    this.onSuccess(mapOf("uri" to uri.toString()))
+  }
+
+  companion object {
+    const val ERROR_KEY = "message"
+  }
+}
+
+public class WebAppInterface(
+  val onProgress: ViewEventCallback<Map<String, Any>>,
+  val onError: ViewEventCallback<Map<String, Any>>,
+) {
+  @JavascriptInterface
+  public fun onMessage(message: String) {
+    val jsonObject = JSONObject(message)
+    val action = jsonObject.getString("action")
+
+    when (action) {
+      "error" -> {
+        val messageStr = jsonObject.get("messageStr")
+        if (messageStr !is String) {
+          this.onError(mapOf(ERROR_KEY to "Failed to decode JSON post message."))
+          return
+        }
+        this.onError(mapOf(ERROR_KEY to messageStr))
+      }
+      "progress" -> {
+        val messageFloat = jsonObject.get("messageFloat")
+        if (messageFloat !is Number) {
+          this.onError(mapOf(ERROR_KEY to "Failed to decode JSON post message."))
+          return
+        }
+        this.onProgress(mapOf(PROGRESS_KEY to messageFloat))
+      }
+    }
+  }
+
+  companion object {
+    const val PROGRESS_KEY = "progress"
+    const val ERROR_KEY = "message"
+  }
+}
diff --git a/modules/expo-bluesky-swiss-army/expo-module.config.json b/modules/expo-bluesky-swiss-army/expo-module.config.json
index 4cdc11e99..04411ecf7 100644
--- a/modules/expo-bluesky-swiss-army/expo-module.config.json
+++ b/modules/expo-bluesky-swiss-army/expo-module.config.json
@@ -5,6 +5,7 @@
       "ExpoBlueskySharedPrefsModule",
       "ExpoBlueskyReferrerModule",
       "ExpoBlueskyVisibilityViewModule",
+      "ExpoHLSDownloadModule",
       "ExpoPlatformInfoModule"
     ]
   },
@@ -13,7 +14,8 @@
       "expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule",
       "expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule",
       "expo.modules.blueskyswissarmy.visibilityview.ExpoBlueskyVisibilityViewModule",
-      "expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule"
+      "expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule",
+      "expo.modules.blueskyswissarmy.hlsdownload.ExpoHLSDownloadModule"
     ]
   }
 }
diff --git a/modules/expo-bluesky-swiss-army/index.ts b/modules/expo-bluesky-swiss-army/index.ts
index 2cf4f36c5..67dc6ee60 100644
--- a/modules/expo-bluesky-swiss-army/index.ts
+++ b/modules/expo-bluesky-swiss-army/index.ts
@@ -1,7 +1,15 @@
+import HLSDownloadView from './src/HLSDownload'
 import * as PlatformInfo from './src/PlatformInfo'
 import {AudioCategory} from './src/PlatformInfo/types'
 import * as Referrer from './src/Referrer'
 import * as SharedPrefs from './src/SharedPrefs'
 import VisibilityView from './src/VisibilityView'
 
-export {AudioCategory, PlatformInfo, Referrer, SharedPrefs, VisibilityView}
+export {
+  AudioCategory,
+  HLSDownloadView,
+  PlatformInfo,
+  Referrer,
+  SharedPrefs,
+  VisibilityView,
+}
diff --git a/modules/expo-bluesky-swiss-army/ios/HLSDownload/ExpoHLSDownloadModule.swift b/modules/expo-bluesky-swiss-army/ios/HLSDownload/ExpoHLSDownloadModule.swift
new file mode 100644
index 000000000..a9b445e48
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/ios/HLSDownload/ExpoHLSDownloadModule.swift
@@ -0,0 +1,31 @@
+import ExpoModulesCore
+
+public class ExpoHLSDownloadModule: Module {
+  public func definition() -> ModuleDefinition {
+    Name("ExpoHLSDownload")
+
+    Function("isAvailable") {
+      if #available(iOS 14.5, *) {
+        return true
+      }
+      return false
+    }
+
+    View(HLSDownloadView.self) {
+      Events([
+        "onStart",
+        "onError",
+        "onProgress",
+        "onSuccess"
+      ])
+
+      Prop("downloaderUrl") { (view: HLSDownloadView, downloaderUrl: URL) in
+        view.downloaderUrl = downloaderUrl
+      }
+
+      AsyncFunction("startDownloadAsync") { (view: HLSDownloadView, sourceUrl: URL) in
+        view.startDownload(sourceUrl: sourceUrl)
+      }
+    }
+  }
+}
diff --git a/modules/expo-bluesky-swiss-army/ios/HLSDownload/HLSDownloadView.swift b/modules/expo-bluesky-swiss-army/ios/HLSDownload/HLSDownloadView.swift
new file mode 100644
index 000000000..591c09335
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/ios/HLSDownload/HLSDownloadView.swift
@@ -0,0 +1,148 @@
+import ExpoModulesCore
+import WebKit
+
+class HLSDownloadView: ExpoView, WKScriptMessageHandler, WKNavigationDelegate, WKDownloadDelegate {
+  var webView: WKWebView!
+  var downloaderUrl: URL?
+
+  private var onStart = EventDispatcher()
+  private var onError = EventDispatcher()
+  private var onProgress = EventDispatcher()
+  private var onSuccess = EventDispatcher()
+
+  private var outputUrl: URL?
+
+  public required init(appContext: AppContext? = nil) {
+    super.init(appContext: appContext)
+
+    // controller for post message api
+    let contentController = WKUserContentController()
+    contentController.add(self, name: "onMessage")
+    let configuration = WKWebViewConfiguration()
+    configuration.userContentController = contentController
+
+    // create webview
+    let webView = WKWebView(frame: .zero, configuration: configuration)
+
+    // Use these for debugging, to see the webview itself
+    webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
+    webView.layer.masksToBounds = false
+    webView.backgroundColor = .clear
+    webView.contentMode = .scaleToFill
+
+    webView.navigationDelegate = self
+
+    self.addSubview(webView)
+    self.webView = webView
+  }
+
+  required init?(coder: NSCoder) {
+    fatalError("init(coder:) has not been implemented")
+  }
+
+  // MARK: - view functions
+
+  func startDownload(sourceUrl: URL) {
+    guard let downloaderUrl = self.downloaderUrl,
+          let url = URL(string: "\(downloaderUrl.absoluteString)?videoUrl=\(sourceUrl.absoluteString)") else {
+      self.onError([
+        "message": "Downloader URL is not set."
+      ])
+      return
+    }
+
+    self.onStart()
+    self.webView.load(URLRequest(url: url))
+  }
+
+  // webview message handling
+
+  func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
+    guard let response = message.body as? String,
+          let data = response.data(using: .utf8),
+          let payload = try? JSONDecoder().decode(WebViewActionPayload.self, from: data) else {
+      self.onError([
+        "message": "Failed to decode JSON post message."
+      ])
+      return
+    }
+
+    switch payload.action {
+    case .progress:
+      guard let progress = payload.messageFloat else {
+        self.onError([
+          "message": "Failed to decode JSON post message."
+        ])
+        return
+      }
+      self.onProgress([
+        "progress": progress
+      ])
+      case .error:
+      guard let messageStr = payload.messageStr else {
+        self.onError([
+          "message": "Failed to decode JSON post message."
+        ])
+        return
+      }
+      self.onError([
+        "message": messageStr
+      ])
+    }
+  }
+
+  func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy {
+    guard #available(iOS 14.5, *) else {
+      return .cancel
+    }
+
+    if navigationAction.shouldPerformDownload {
+      return .download
+    } else {
+      return .allow
+    }
+  }
+
+  // MARK: - wkdownloaddelegate
+
+  @available(iOS 14.5, *)
+  func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
+    download.delegate = self
+  }
+
+  @available(iOS 14.5, *)
+  func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
+    download.delegate = self
+  }
+
+  @available(iOS 14.5, *)
+  func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping (URL?) -> Void) {
+    let directory = NSTemporaryDirectory()
+    let fileName = "\(NSUUID().uuidString).mp4"
+    let url = NSURL.fileURL(withPathComponents: [directory, fileName])
+
+    self.outputUrl = url
+    completionHandler(url)
+  }
+
+  @available(iOS 14.5, *)
+  func downloadDidFinish(_ download: WKDownload) {
+    guard let url = self.outputUrl else {
+      return
+    }
+    self.onSuccess([
+      "uri": url.absoluteString
+    ])
+    self.outputUrl = nil
+  }
+}
+
+struct WebViewActionPayload: Decodable {
+  enum Action: String, Decodable {
+    case progress, error
+  }
+
+  let action: Action
+  let messageStr: String?
+  let messageFloat: Float?
+}
diff --git a/modules/expo-bluesky-swiss-army/src/HLSDownload/index.native.tsx b/modules/expo-bluesky-swiss-army/src/HLSDownload/index.native.tsx
new file mode 100644
index 000000000..92f26192e
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/HLSDownload/index.native.tsx
@@ -0,0 +1,39 @@
+import React from 'react'
+import {StyleProp, ViewStyle} from 'react-native'
+import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
+
+import {HLSDownloadViewProps} from './types'
+
+const NativeModule = requireNativeModule('ExpoHLSDownload')
+const NativeView: React.ComponentType<
+  HLSDownloadViewProps & {
+    ref: React.RefObject<any>
+    style: StyleProp<ViewStyle>
+  }
+> = requireNativeViewManager('ExpoHLSDownload')
+
+export default class HLSDownloadView extends React.PureComponent<HLSDownloadViewProps> {
+  private nativeRef: React.RefObject<any> = React.createRef()
+
+  constructor(props: HLSDownloadViewProps) {
+    super(props)
+  }
+
+  static isAvailable(): boolean {
+    return NativeModule.isAvailable()
+  }
+
+  async startDownloadAsync(sourceUrl: string): Promise<void> {
+    return await this.nativeRef.current.startDownloadAsync(sourceUrl)
+  }
+
+  render() {
+    return (
+      <NativeView
+        ref={this.nativeRef}
+        style={{height: 0, width: 0}}
+        {...this.props}
+      />
+    )
+  }
+}
diff --git a/modules/expo-bluesky-swiss-army/src/HLSDownload/index.tsx b/modules/expo-bluesky-swiss-army/src/HLSDownload/index.tsx
new file mode 100644
index 000000000..93c50497f
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/HLSDownload/index.tsx
@@ -0,0 +1,22 @@
+import React from 'react'
+
+import {NotImplementedError} from '../NotImplemented'
+import {HLSDownloadViewProps} from './types'
+
+export default class HLSDownloadView extends React.PureComponent<HLSDownloadViewProps> {
+  constructor(props: HLSDownloadViewProps) {
+    super(props)
+  }
+
+  static isAvailable(): boolean {
+    return false
+  }
+
+  async startDownloadAsync(sourceUrl: string): Promise<void> {
+    throw new NotImplementedError({sourceUrl})
+  }
+
+  render() {
+    return null
+  }
+}
diff --git a/modules/expo-bluesky-swiss-army/src/HLSDownload/types.ts b/modules/expo-bluesky-swiss-army/src/HLSDownload/types.ts
new file mode 100644
index 000000000..6a474d282
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/HLSDownload/types.ts
@@ -0,0 +1,10 @@
+import {NativeSyntheticEvent} from 'react-native'
+
+export interface HLSDownloadViewProps {
+  downloaderUrl: string
+  onSuccess: (e: NativeSyntheticEvent<{uri: string}>) => void
+
+  onStart?: () => void
+  onError?: (e: NativeSyntheticEvent<{message: string}>) => void
+  onProgress?: (e: NativeSyntheticEvent<{progress: number}>) => void
+}