-
Notifications
You must be signed in to change notification settings - Fork 657
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
Changes from 11 commits
43f0e40
0871c10
a816820
c536794
6b770cb
cd77613
d9f3898
6f012e7
e667558
bc6da3b
8b79aa8
77a7157
97107aa
59eea10
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
---|---|---|
@@ -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 { | ||
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
---|---|---|
@@ -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()) | ||
} | ||
} |
There was a problem hiding this comment.
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