Skip to content

Commit

Permalink
[Connect SDK] Enable downloads in the Connect SDK (#9583)
Browse files Browse the repository at this point in the history
* Initial implementation

* minor refactors

* Fix strict mode violations

* implement basic download finished toast

* Remove file opening code (not working), make internal

* Add test

* Fix test

* Fix lint (unused import)

* Close resource

* Refactor into interfaces

* Make interfaces internal

* Move scope into toast manager

* Better abstractions

* Fix test
  • Loading branch information
simond-stripe authored Nov 15, 2024
1 parent 701bcb0 commit 06beaa8
Show file tree
Hide file tree
Showing 8 changed files with 264 additions and 1 deletion.
1 change: 0 additions & 1 deletion connect/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ dependencies {
testImplementation testLibs.mockito.kotlin
testImplementation testLibs.robolectric
testImplementation testLibs.truth
testImplementation project(':stripe-core')

androidTestImplementation testLibs.androidx.composeUi
androidTestImplementation testLibs.androidx.coreKtx
Expand Down
3 changes: 3 additions & 0 deletions connect/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="stripe_not_yet_built">Not yet built…</string>
<string name="stripe_downloading_file">Downloading file</string>
<string name="stripe_download_complete">Download complete</string>
<string name="stripe_unable_to_download_file">Unable to download file</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ internal class StripeConnectWebViewClient(
useWideViewPort = true
userAgentString = "$userAgentString - stripe-android/${StripeSdkVersion.VERSION_NAME}"
}

setDownloadListener(StripeDownloadListener(webView.context))

addJavascriptInterface(StripeJsInterface(), ANDROID_JS_INTERFACE)
addJavascriptInterface(StripeJsInterfaceInternal(this), ANDROID_JS_INTERNAL_INTERFACE)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.stripe.android.connect.webview

import android.app.DownloadManager.STATUS_PAUSED
import android.app.DownloadManager.STATUS_PENDING
import android.app.DownloadManager.STATUS_RUNNING
import android.content.Context
import android.webkit.DownloadListener
import com.stripe.android.connect.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

internal class StripeDownloadListener(
private val context: Context,
private val stripeDownloadManager: StripeDownloadManager = StripeDownloadManagerImpl(context),
private val stripeToastManager: StripeToastManager = StripeToastManagerImpl(context),
private val ioScope: CoroutineScope = CoroutineScope(Dispatchers.IO),
) : DownloadListener {

override fun onDownloadStart(
url: String?,
userAgent: String?,
contentDisposition: String?,
mimetype: String?,
contentLength: Long
) {
if (url == null) {
showErrorToast()
return
}

ioScope.launch {
val downloadId = stripeDownloadManager.enqueueDownload(url, contentDisposition, mimetype)
if (downloadId == null) {
showErrorToast()
return@launch
}

// Monitor the download progress and show a toast when done
val query = stripeDownloadManager.getQueryById(downloadId)
var isDownloading = true
while (isDownloading) {
val status = stripeDownloadManager.getDownloadStatus(query)
if (status == null) {
showErrorToast()
return@launch
} else if (status !in listOf(STATUS_PENDING, STATUS_RUNNING, STATUS_PAUSED)) {
showOpenFileToast()
isDownloading = false // download complete - exit the loop
}
delay(DOWNLOAD_DELAY_MS)
}
}
}

private fun showErrorToast() {
stripeToastManager.showToast(context.getString(R.string.stripe_unable_to_download_file))
}

private fun showOpenFileToast() {
stripeToastManager.showToast(context.getString(R.string.stripe_download_complete))
}

internal companion object {
private const val DOWNLOAD_DELAY_MS = 1_000L
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.stripe.android.connect.webview

import android.app.DownloadManager
import android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED
import android.content.Context
import android.net.Uri
import android.os.Environment.DIRECTORY_DOWNLOADS
import android.webkit.URLUtil
import com.stripe.android.connect.R

/**
* Provides an interface for various download and file operations. Useful for mocking in tests.
*/
internal interface StripeDownloadManager {

/**
* Enqueues a download for the given URL, content disposition, and MIME type.
* Returns null if a download could not be started.
*/
fun enqueueDownload(url: String, contentDisposition: String? = null, mimeType: String? = null): Long?

/**
* Returns a [DownloadManager.Query] for the given download ID, used for monitoring the status of a download.
*/
fun getQueryById(id: Long): DownloadManager.Query

/**
* Returns the status of the download represented by [query]. Maps to [DownloadManager.COLUMN_STATUS], ie.
* [DownloadManager.STATUS_PENDING], [DownloadManager.STATUS_RUNNING], [DownloadManager.STATUS_PAUSED], etc.
*
* Returns null if the status could not be determined. This operation should not be retried if null is returned.
*/
fun getDownloadStatus(query: DownloadManager.Query): Int?
}

internal class StripeDownloadManagerImpl(private val context: Context) : StripeDownloadManager {
private val downloadManager: DownloadManager? =
context.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager

override fun enqueueDownload(url: String, contentDisposition: String?, mimeType: String?): Long? {
downloadManager ?: return null

val request = DownloadManager.Request(Uri.parse(url))
val fileName = URLUtil.guessFileName(url, contentDisposition, mimeType)
request.setDestinationInExternalPublicDir(DIRECTORY_DOWNLOADS, fileName)
request.setNotificationVisibility(VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
request.setTitle(fileName)
request.setDescription(context.getString(R.string.stripe_downloading_file))
request.setMimeType(mimeType)

return downloadManager.enqueue(request)
}

override fun getQueryById(id: Long): DownloadManager.Query {
return DownloadManager.Query().setFilterById(id)
}

override fun getDownloadStatus(query: DownloadManager.Query): Int? {
downloadManager ?: return null

val cursor = downloadManager.query(query) ?: return null
return cursor.use { resource ->
resource.moveToFirst()
val index = resource.getColumnIndex(DownloadManager.COLUMN_STATUS)
if (index < 0) return null // status does not exist - abort
resource.getInt(index)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.stripe.android.connect.webview

import android.content.Context
import android.widget.Toast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch

/**
* Provides an interface for various download and file operations. Useful for mocking in tests.
*/
internal interface StripeToastManager {
fun showToast(toastString: String)
}

internal class StripeToastManagerImpl(
private val context: Context,
private val scope: CoroutineScope = MainScope()
) : StripeToastManager {
override fun showToast(toastString: String) {
scope.launch {
Toast.makeText(context, toastString, Toast.LENGTH_LONG).show()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class StripeConnectWebViewClientTest {
}
private val mockWebView: WebView = mock {
on { settings } doReturn mockSettings
on { context } doReturn mock()
}

private lateinit var webViewClient: StripeConnectWebViewClient
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.stripe.android.connect.webview

import android.app.DownloadManager
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.Before
import org.junit.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.kotlin.verifyNoInteractions
import org.mockito.kotlin.whenever

class StripeDownloadListenerTest {

private val context: Context = mock {
on { getString(any()) } doReturn "Placeholder string"
}
private val stripeDownloadManager: StripeDownloadManager = mock {
on { enqueueDownload(any(), anyOrNull(), anyOrNull()) } doReturn 123L
on { getQueryById(any()) } doReturn mock()
on { getDownloadStatus(any()) } doReturn DownloadManager.STATUS_SUCCESSFUL
}
private val stripeToastManager: StripeToastManager = mock()
private val testScope = TestScope()

private lateinit var stripeDownloadListener: StripeDownloadListener

@Before
fun setup() {
Dispatchers.setMain(UnconfinedTestDispatcher())
initDownloadListener()
}

private fun initDownloadListener(
context: Context = this.context,
stripeDownloadManager: StripeDownloadManager = this.stripeDownloadManager,
stripeToastManager: StripeToastManager = this.stripeToastManager,
ioScope: CoroutineScope = testScope,
) {
stripeDownloadListener = StripeDownloadListener(
context = context,
stripeDownloadManager = stripeDownloadManager,
stripeToastManager = stripeToastManager,
ioScope = ioScope,
)
}

@Test
fun `onDownloadStart creates download request`() = runTest {
val url = "https://example.com/file.pdf"
val userAgent = "Mozilla/5.0"
val contentDisposition = "attachment; filename=file.pdf"
val mimeType = "application/pdf"
val contentLength = 1024L

stripeDownloadListener.onDownloadStart(url, userAgent, contentDisposition, mimeType, contentLength)
testScope.testScheduler.advanceUntilIdle()

verify(stripeDownloadManager).enqueueDownload(url, contentDisposition, mimeType)
verify(stripeToastManager).showToast(any())
}

@Test
fun `onDownloadStart does nothing when URL is null`() = runTest {
stripeDownloadListener.onDownloadStart(null, "", "", "", 0)
testScope.testScheduler.advanceUntilIdle()

verifyNoInteractions(stripeDownloadManager)
verify(stripeToastManager).showToast(any())
}

@Test
fun `onDownloadStart shows error toast when enqueue returns null`() = runTest {
whenever(stripeDownloadManager.enqueueDownload(any(), anyOrNull(), anyOrNull())).thenReturn(null)

val url = "https://example.com/file.pdf"
val userAgent = "Mozilla/5.0"
val contentDisposition = "attachment; filename=file.pdf"
val mimeType = "application/pdf"
val contentLength = 1024L

stripeDownloadListener.onDownloadStart(url, userAgent, contentDisposition, mimeType, contentLength)
testScope.testScheduler.advanceUntilIdle()

verify(stripeToastManager).showToast(any())
}
}

0 comments on commit 06beaa8

Please sign in to comment.