Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Connect SDK] Enable downloads in the Connect SDK #9583

Merged
merged 14 commits into from
Nov 15, 2024
1 change: 0 additions & 1 deletion connect/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,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 @@ -40,6 +40,9 @@ internal class StripeConnectWebViewClient(
useWideViewPort = true
userAgentString = "$userAgentString - stripe-android/${StripeSdkVersion.VERSION_NAME}"
}

setDownloadListener(StripeDownloadListener(webView.context))

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

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

import android.app.DownloadManager
import android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED
import android.app.DownloadManager.STATUS_PAUSED
import android.app.DownloadManager.STATUS_PENDING
import android.app.DownloadManager.STATUS_RUNNING
import android.content.Context
import android.os.Environment.DIRECTORY_DOWNLOADS
import android.webkit.DownloadListener
import com.stripe.android.connect.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
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),
private val mainScope: CoroutineScope = MainScope(),
) : DownloadListener {

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

ioScope.launch {
val request = stripeDownloadManager.getDownloadRequest(url)
val fileName = stripeDownloadManager.getFileName(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)

val downloadId = stripeDownloadManager.enqueue(request)
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 cursor = stripeDownloadManager.query(query)
if (cursor == null) {
showErrorToast()
return@launch
}
cursor.use { resource ->
resource.moveToFirst()
val index = resource.getColumnIndex(DownloadManager.COLUMN_STATUS)
if (index < 0) return@launch // status does not exist - abort
val status = resource.getInt(index)
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() {
mainScope.launch {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you may actually just want to make the toast manager call mainScope so you don't need to worry about it here. it will never be called on any other thread anyway

stripeToastManager.showToast(context.getString(R.string.stripe_unable_to_download_file))
}
}

private fun showOpenFileToast() {
mainScope.launch {
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,62 @@
package com.stripe.android.connect.webview

import android.app.DownloadManager
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.webkit.URLUtil

/**
* Provides an interface for various download and file operations. Useful for mocking in tests.
*/
internal interface StripeDownloadManager {
/**
* Returns the ID of the download, or null if the download could not be started.
*/
fun enqueue(request: DownloadManager.Request): Long?

/**
* Returns a [DownloadManager.Request] for the given URI.
*/
fun getDownloadRequest(uri: String): DownloadManager.Request

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this need to be part of the interface? sort of feels like an implementation detail


/**
* Returns a best-guess file name for the given URL, content disposition, and MIME type.
*/
fun getFileName(url: String, contentDisposition: String? = null, mimeType: String? = null): String

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same with this one, with both of these it seems like the end result is just that you want the file name, and i'm wondering if you could just return that along with the ID when you start the DL request


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

/**
* Queries the download manager for downloads matching the given query, or null if downloads can't be queried.
*/
fun query(query: DownloadManager.Query): Cursor?
}

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

override fun enqueue(request: DownloadManager.Request): Long? {
return downloadManager?.enqueue(request)
}

override fun getDownloadRequest(uri: String): DownloadManager.Request {
return DownloadManager.Request(Uri.parse(uri))
}

override fun getFileName(url: String, contentDisposition: String?, mimeType: String?): String {
return URLUtil.guessFileName(url, contentDisposition, mimeType)
}

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

override fun query(query: DownloadManager.Query): Cursor? {
return downloadManager?.query(query)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.stripe.android.connect.webview

import android.content.Context
import android.widget.Toast

/**
* 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) : StripeToastManager {
override fun showToast(toastString: String) {
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,112 @@
package com.stripe.android.connect.webview

import android.app.DownloadManager
import android.content.Context
import android.database.Cursor
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.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 downloadManagerRequest: DownloadManager.Request = mock {
on { setDestinationInExternalPublicDir(any(), any()) } doReturn mock
on { setNotificationVisibility(any()) } doReturn mock
on { setTitle(any()) } doReturn mock
on { setDescription(any()) } doReturn mock
on { setMimeType(any()) } doReturn mock
}
private val context: Context = mock {
on { getString(any()) } doReturn "Placeholder string"
}
private val stripeDownloadManager: StripeDownloadManager = mock {
on { getDownloadRequest(any()) } doReturn downloadManagerRequest
on { getFileName(any(), any(), any()) } doReturn "file.pdf"
on { enqueue(any()) } doReturn 123L
}
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,
mainScope: CoroutineScope = testScope,
) {
stripeDownloadListener = StripeDownloadListener(
context = context,
stripeDownloadManager = stripeDownloadManager,
stripeToastManager = stripeToastManager,
ioScope = ioScope,
mainScope = mainScope,
)
}

@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

val cursor: Cursor = mock {
on { moveToFirst() } doReturn true
on { getColumnIndex(any()) } doReturn 0
on { getInt(any()) } doReturn DownloadManager.STATUS_SUCCESSFUL
}
whenever(stripeDownloadManager.getQueryById(any())).thenReturn(mock())
whenever(stripeDownloadManager.query(any())).thenReturn(cursor)

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

verify(stripeDownloadManager).enqueue(any())
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.enqueue(any())).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())
}
}
Loading