[API] Add Vulcan OneDrive attachment downloading. Add asking for permissions on demand.

This commit is contained in:
Kuba Szczodrzyński
2020-04-07 12:16:48 +02:00
parent 0327ba37f1
commit d56afb034b
19 changed files with 320 additions and 38 deletions

View File

@ -65,6 +65,7 @@ class App : MultiDexApplication(), Configuration.Provider, CoroutineScope {
val gradesManager by lazy { GradesManager(this) }
val timetableManager by lazy { TimetableManager(this) }
val eventManager by lazy { EventManager(this) }
val permissionManager by lazy { PermissionManager(this) }
val db
get() = App.db

View File

@ -414,8 +414,6 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
R.color.md_green_500
)
isStoragePermissionGranted()
SyncWorker.scheduleNext(app)
UpdateWorker.scheduleNext(app)

View File

@ -158,6 +158,7 @@ const val ERROR_LOGIN_VULCAN_NO_PUPILS = 331
const val ERROR_VULCAN_API_MAINTENANCE = 340
const val ERROR_VULCAN_API_BAD_REQUEST = 341
const val ERROR_VULCAN_API_OTHER = 342
const val ERROR_VULCAN_ATTACHMENT_DOWNLOAD = 343
const val ERROR_LOGIN_IDZIENNIK_WEB_INVALID_LOGIN = 401
const val ERROR_LOGIN_IDZIENNIK_WEB_INVALID_SCHOOL_NAME = 402
@ -207,5 +208,6 @@ const val EXCEPTION_IDZIENNIK_WEB_API_REQUEST = 913
const val EXCEPTION_IDZIENNIK_API_REQUEST = 914
const val EXCEPTION_EDUDZIENNIK_WEB_REQUEST = 920
const val EXCEPTION_EDUDZIENNIK_FILE_REQUEST = 921
const val ERROR_ONEDRIVE_DOWNLOAD = 930
const val LOGIN_NO_ARGUMENTS = 1201

View File

@ -0,0 +1,103 @@
/*
* Copyright (c) Kuba Szczodrzyński 2020-4-7.
*/
package pl.szczodrzynski.edziennik.data.api.edziennik.helper
import im.wangchao.mhttp.Request
import im.wangchao.mhttp.Response
import im.wangchao.mhttp.callback.FileCallbackHandler
import im.wangchao.mhttp.callback.TextCallbackHandler
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.data.api.ERROR_ONEDRIVE_DOWNLOAD
import pl.szczodrzynski.edziennik.data.api.ERROR_REQUEST_FAILURE
import pl.szczodrzynski.edziennik.data.api.SYSTEM_USER_AGENT
import pl.szczodrzynski.edziennik.data.api.models.ApiError
import pl.szczodrzynski.edziennik.utils.Utils
import java.io.File
class OneDriveDownloadAttachment(
app: App,
fileUrl: String,
val onSuccess: (file: File) -> Unit,
val onProgress: (written: Long, total: Long) -> Unit,
val onError: (apiError: ApiError) -> Unit
) {
companion object {
private const val TAG = "OneDriveDownloadAttachment"
}
init {
Request.builder()
.url(fileUrl)
.userAgent(SYSTEM_USER_AGENT)
.withClient(app.httpLazy)
.callback(object : TextCallbackHandler() {
override fun onSuccess(text: String, response: Response) {
val location = response.headers().get("Location")
if (location?.contains("onedrive.live.com/redir?resid=") != true) {
onError(ApiError(TAG, ERROR_ONEDRIVE_DOWNLOAD)
.withApiResponse(text)
.withResponse(response))
return
}
val url = location
.replace("onedrive.live.com/redir?resid=", "storage.live.com/items/")
.replace("&", "?")
downloadFile(url)
}
override fun onFailure(response: Response, throwable: Throwable) {
onError(ApiError(TAG, ERROR_REQUEST_FAILURE)
.withResponse(response)
.withThrowable(throwable))
}
})
.build()
.enqueue()
}
private fun downloadFile(url: String) {
val targetFile = Utils.getStorageDir()
val callback = object : FileCallbackHandler(targetFile) {
override fun onSuccess(file: File?, response: Response?) {
if (file == null) {
onError(ApiError(TAG, ERROR_ONEDRIVE_DOWNLOAD)
.withResponse(response))
return
}
try {
onSuccess(file)
} catch (e: Exception) {
onError(ApiError(TAG, ERROR_ONEDRIVE_DOWNLOAD)
.withResponse(response)
.withThrowable(e))
}
}
override fun onProgress(bytesWritten: Long, bytesTotal: Long) {
try {
this@OneDriveDownloadAttachment.onProgress(bytesWritten, bytesTotal)
} catch (e: Exception) {
onError(ApiError(TAG, ERROR_ONEDRIVE_DOWNLOAD)
.withThrowable(e))
}
}
override fun onFailure(response: Response?, throwable: Throwable?) {
onError(ApiError(TAG, ERROR_REQUEST_FAILURE)
.withResponse(response)
.withThrowable(throwable))
}
}
Request.builder()
.url(url)
.userAgent(SYSTEM_USER_AGENT)
.callback(callback)
.build()
.enqueue()
}
}

View File

@ -54,13 +54,13 @@ class LibrusSandboxDownloadAttachment(override val data: DataLibrus,
}
private fun getAttachmentCheckKey(attachmentKey: String, callback: () -> Unit) {
sandboxGet(LibrusMessagesGetAttachment.TAG, "CSCheckKey",
sandboxGet(TAG, "CSCheckKey",
parameters = mapOf("singleUseKey" to attachmentKey)) { json ->
when (json.getString("status")) {
"not_downloaded_yet" -> {
if (getAttachmentCheckKeyTries++ > 5) {
data.error(ApiError(LibrusMessagesGetAttachment.TAG, ERROR_FILE_DOWNLOAD)
data.error(ApiError(TAG, ERROR_FILE_DOWNLOAD)
.withApiResponse(json))
return@sandboxGet
}
@ -75,7 +75,7 @@ class LibrusSandboxDownloadAttachment(override val data: DataLibrus,
}
else -> {
data.error(ApiError(LibrusMessagesGetAttachment.TAG, EXCEPTION_LIBRUS_MESSAGES_REQUEST)
data.error(ApiError(TAG, EXCEPTION_LIBRUS_MESSAGES_REQUEST)
.withApiResponse(json))
}
}
@ -85,7 +85,7 @@ class LibrusSandboxDownloadAttachment(override val data: DataLibrus,
private fun downloadAttachment(url: String, method: Int = GET) {
val targetFile = File(Utils.getStorageDir(), attachmentName)
sandboxGetFile(LibrusMessagesGetAttachment.TAG, url, targetFile, { file ->
sandboxGetFile(TAG, url, targetFile, { file ->
val event = AttachmentGetEvent(
profileId,

View File

@ -7,28 +7,29 @@ package pl.szczodrzynski.edziennik.data.api.edziennik.vulcan
import com.google.gson.JsonObject
import org.greenrobot.eventbus.EventBus
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.data.api.LOGIN_METHOD_VULCAN_API
import pl.szczodrzynski.edziennik.data.api.*
import pl.szczodrzynski.edziennik.data.api.edziennik.helper.OneDriveDownloadAttachment
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.VulcanData
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.api.VulcanApiAttachments
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.api.VulcanApiMessagesChangeStatus
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.api.VulcanApiSendMessage
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.firstlogin.VulcanFirstLogin
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.login.VulcanLogin
import pl.szczodrzynski.edziennik.data.api.events.AttachmentGetEvent
import pl.szczodrzynski.edziennik.data.api.events.EventGetEvent
import pl.szczodrzynski.edziennik.data.api.events.MessageGetEvent
import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikCallback
import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikInterface
import pl.szczodrzynski.edziennik.data.api.models.ApiError
import pl.szczodrzynski.edziennik.data.api.prepare
import pl.szczodrzynski.edziennik.data.api.prepareFor
import pl.szczodrzynski.edziennik.data.api.vulcanLoginMethods
import pl.szczodrzynski.edziennik.data.db.entity.LoginStore
import pl.szczodrzynski.edziennik.data.db.entity.Profile
import pl.szczodrzynski.edziennik.data.db.entity.Teacher
import pl.szczodrzynski.edziennik.data.db.full.AnnouncementFull
import pl.szczodrzynski.edziennik.data.db.full.EventFull
import pl.szczodrzynski.edziennik.data.db.full.MessageFull
import pl.szczodrzynski.edziennik.utils.Utils
import pl.szczodrzynski.edziennik.utils.Utils.d
import java.io.File
class Vulcan(val app: App, val profile: Profile?, val loginStore: LoginStore, val callback: EdziennikCallback) : EdziennikInterface {
companion object {
@ -128,8 +129,51 @@ class Vulcan(val app: App, val profile: Profile?, val loginStore: LoginStore, va
override fun markAllAnnouncementsAsRead() {}
override fun getAnnouncement(announcement: AnnouncementFull) {}
override fun getAttachment(owner: Any, attachmentId: Long, attachmentName: String) {}
override fun getRecipientList() {}
override fun getAttachment(owner: Any, attachmentId: Long, attachmentName: String) {
val fileUrl = attachmentName.substringAfter(":")
if (attachmentName == fileUrl) {
data.error(ApiError(TAG, ERROR_ONEDRIVE_DOWNLOAD))
return
}
OneDriveDownloadAttachment(
app,
fileUrl,
onSuccess = { file ->
val event = AttachmentGetEvent(
data.profileId,
owner,
attachmentId,
AttachmentGetEvent.TYPE_FINISHED,
file.absolutePath
)
val attachmentDataFile = File(Utils.getStorageDir(), ".${data.profileId}_${event.ownerId}_${event.attachmentId}")
Utils.writeStringToFile(attachmentDataFile, event.fileName)
EventBus.getDefault().postSticky(event)
completed()
},
onProgress = { written, total ->
val event = AttachmentGetEvent(
data.profileId,
owner,
attachmentId,
AttachmentGetEvent.TYPE_PROGRESS,
bytesWritten = written
)
EventBus.getDefault().postSticky(event)
},
onError = { apiError ->
data.error(apiError)
}
)
}
override fun getEvent(eventFull: EventFull) {
login(LOGIN_METHOD_VULCAN_API) {
val list = data.app.db.eventDao().getAllNow(data.profileId).filter { !it.addedManually }

View File

@ -17,6 +17,7 @@ import com.google.gson.JsonObject
import pl.droidsonroids.gif.GifDrawable
import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.data.api.LOGIN_TYPE_EDUDZIENNIK
import pl.szczodrzynski.edziennik.utils.ProfileImageHolder
import pl.szczodrzynski.edziennik.utils.models.Date
import pl.szczodrzynski.navlib.ImageHolder
import pl.szczodrzynski.navlib.R
@ -128,7 +129,7 @@ open class Profile(
override fun getImageHolder(context: Context): ImageHolder {
return if (!image.isNullOrEmpty()) {
try {
ImageHolder(image ?: "")
ProfileImageHolder(image ?: "")
} catch (_: Exception) {
ImageHolder(R.drawable.profile, colorFromName(name))
}

View File

@ -85,6 +85,7 @@ class LoginChooserFragment : Fragment() {
}
b.devMode.visibility = if (App.debugMode) View.VISIBLE else View.GONE
b.devMode.isChecked = app.config.debugMode
b.devMode.onChange { v, isChecked ->
if (isChecked) {
MaterialDialog.Builder(activity)
@ -94,6 +95,7 @@ class LoginChooserFragment : Fragment() {
.negativeText(R.string.no)
.onPositive { _: MaterialDialog?, _: DialogAction? ->
app.config.debugMode = true
App.devMode = true
MaterialAlertDialogBuilder(activity)
.setTitle("Restart")
.setMessage("Wymagany restart aplikacji")
@ -104,12 +106,10 @@ class LoginChooserFragment : Fragment() {
}
.setCancelable(false)
.show()
/*if (b.devModeLayout.getVisibility() !== View.VISIBLE) {
Anim.expand(b.devModeTitle, 500, null)
Anim.expand(b.devModeLayout, 500, null)
}*/
}
.onNegative { _: MaterialDialog?, _: DialogAction? ->
app.config.debugMode = false
App.devMode = false
b.devMode.isChecked = app.config.debugMode
b.devMode.jumpDrawablesToCurrentState()
Anim.collapse(b.devMode, 1000, null)
@ -117,6 +117,7 @@ class LoginChooserFragment : Fragment() {
.show()
} else {
app.config.debugMode = false
App.devMode = false
/*if (b.devModeLayout.getVisibility() === View.VISIBLE) {
Anim.collapse(b.devModeTitle, 500, null)
Anim.collapse(b.devModeLayout, 500, null)

View File

@ -58,8 +58,9 @@ class AttachmentAdapter(
val item = items[position]
val b = holder.b
val fileName = item.name.substringBefore(":http")
// create an icon for the attachment
val icon: IIcon = when (Utils.getExtensionFromFileName(item.name)) {
val icon: IIcon = when (Utils.getExtensionFromFileName(fileName)) {
"doc", "docx", "odt", "rtf" -> SzkolnyFont.Icon.szf_file_word_outline
"xls", "xlsx", "ods" -> SzkolnyFont.Icon.szf_file_excel_outline
"ppt", "pptx", "odp" -> SzkolnyFont.Icon.szf_file_powerpoint_outline
@ -73,12 +74,12 @@ class AttachmentAdapter(
}
b.chip.text = if (item.isDownloading) {
app.getString(R.string.messages_attachment_downloading_format, item.name, item.downloadProgress)
app.getString(R.string.messages_attachment_downloading_format, fileName, item.downloadProgress)
}
else {
item.size?.let {
app.getString(R.string.messages_attachment_format, item.name, Utils.readableFileSize(it))
} ?: item.name
app.getString(R.string.messages_attachment_format, fileName, Utils.readableFileSize(it))
} ?: fileName
}
b.chip.chipIcon = IconicsDrawable(context)

View File

@ -8,12 +8,14 @@ import android.content.Context
import android.os.Bundle
import android.os.Environment
import android.util.AttributeSet
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.PopupMenu
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.data.api.edziennik.EdziennikTask
import pl.szczodrzynski.edziennik.data.api.events.AttachmentGetEvent
@ -41,6 +43,8 @@ class AttachmentsView @JvmOverloads constructor(
}
fun init(arguments: Bundle, owner: Any) {
val app = context.applicationContext as App
val activity = context as? AppCompatActivity ?: return
val list = this as? RecyclerView ?: return
val profileId = arguments.get<Int>("profileId") ?: return
@ -49,12 +53,16 @@ class AttachmentsView @JvmOverloads constructor(
val attachmentSizes = arguments.getLongArray("attachmentSizes")
val adapter = AttachmentAdapter(context, onAttachmentClick = { item ->
downloadAttachment(item)
app.permissionManager.requestStoragePermission(activity, R.string.permissions_attachment) {
downloadAttachment(item)
}
}, onAttachmentLongClick = { chip, item ->
val popupMenu = PopupMenu(chip.context, chip)
popupMenu.menu.add(0, 1, 0, R.string.messages_attachment_download_again)
popupMenu.setOnMenuItemClickListener {
downloadAttachment(item, forceDownload = true)
app.permissionManager.requestStoragePermission(activity, R.string.permissions_attachment) {
downloadAttachment(item, forceDownload = true)
}
true
}
popupMenu.show()
@ -97,7 +105,8 @@ class AttachmentsView @JvmOverloads constructor(
private fun downloadAttachment(attachment: AttachmentAdapter.Item, forceDownload: Boolean = false) {
if (!forceDownload && attachment.isDownloaded) {
Utils.openFile(context, File(Utils.getStorageDir(), attachment.name))
// open file by name, or first part before ':' (Vulcan OneDrive)
Utils.openFile(context, File(Utils.getStorageDir(), attachment.name.substringBefore(":")))
return
}
@ -128,17 +137,19 @@ class AttachmentsView @JvmOverloads constructor(
when (event.eventType) {
AttachmentGetEvent.TYPE_FINISHED -> {
// save the downloaded file name
attachment.downloadedName = event.fileName
attachment.isDownloading = false
attachment.isDownloaded = true
// update file name for iDziennik which
// does not provide the name before downloading
if (!attachment.name.contains("."))
attachment.name = File(attachment.downloadedName).name
// get the download url before updating file name
val fileUrl = attachment.name.substringBefore(":", missingDelimiterValue = "")
// update file name with the downloaded one
attachment.name = File(event.fileName).name
// save the download url back
if (fileUrl != "")
attachment.name += ":$fileUrl"
// open the file
Utils.openFile(context, File(Utils.getStorageDir(), attachment.name))
Utils.openFile(context, File(event.fileName))
}
AttachmentGetEvent.TYPE_PROGRESS -> {

View File

@ -0,0 +1,17 @@
/*
* Copyright (c) Kuba Szczodrzyński 2020-4-7.
*/
package pl.szczodrzynski.edziennik.utils
import android.widget.ImageView
import pl.szczodrzynski.navlib.ImageHolder
class ProfileImageHolder(url: String) : ImageHolder(url) {
override fun applyTo(imageView: ImageView, tag: String?): Boolean {
return try {
super.applyTo(imageView, tag)
} catch (_: Exception) { false }
}
}

View File

@ -0,0 +1,79 @@
/*
* Copyright (c) Kuba Szczodrzyński 2020-4-7.
*/
package pl.szczodrzynski.edziennik.utils.managers
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.qifan.powerpermission.coroutines.awaitAskPermissions
import com.qifan.powerpermission.data.hasAllGranted
import com.qifan.powerpermission.data.hasPermanentDenied
import com.qifan.powerpermission.data.hasRational
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.R
import kotlin.coroutines.CoroutineContext
class PermissionManager(val app: App) : CoroutineScope {
private val job = Job()
override val coroutineContext: CoroutineContext
get() = job + Dispatchers.Main
private fun isStoragePermissionGranted() = if (Build.VERSION.SDK_INT >= 23) {
app.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED
} else {
true
}
fun requestStoragePermission(
activity: AppCompatActivity,
@StringRes permissionMessage: Int,
onSuccess: suspend CoroutineScope.() -> Unit
) {
launch {
if (isStoragePermissionGranted()) {
onSuccess()
return@launch
}
val result = activity.awaitAskPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE)
when {
result.hasAllGranted() -> onSuccess()
result.hasRational() -> {
MaterialAlertDialogBuilder(activity)
.setTitle(R.string.permissions_required)
.setMessage(permissionMessage)
.setPositiveButton(R.string.ok) { _, _ ->
requestStoragePermission(activity, permissionMessage, onSuccess)
}
.setNegativeButton(R.string.cancel, null)
.show()
}
result.hasPermanentDenied() -> {
MaterialAlertDialogBuilder(activity)
.setTitle(R.string.permissions_required)
.setMessage(R.string.permissions_denied)
.setPositiveButton(R.string.ok) { _, _ ->
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
val uri = Uri.fromParts("package", app.packageName, null)
intent.data = uri
activity.startActivity(intent)
}
.setNegativeButton(R.string.cancel, null)
.show()
}
}
}
}
}