[UI/WebPush] Implement Web Push pairing fragment and API. Add more templates.

This commit is contained in:
Kuba Szczodrzyński
2019-12-19 21:57:01 +01:00
parent b905283b61
commit 3e4accb82c
18 changed files with 635 additions and 14 deletions

View File

@ -67,6 +67,7 @@ import pl.szczodrzynski.edziennik.ui.modules.notifications.NotificationsFragment
import pl.szczodrzynski.edziennik.ui.modules.settings.ProfileManagerFragment
import pl.szczodrzynski.edziennik.ui.modules.settings.SettingsNewFragment
import pl.szczodrzynski.edziennik.ui.modules.timetable.v2.TimetableFragment
import pl.szczodrzynski.edziennik.ui.modules.webpush.WebPushFragment
import pl.szczodrzynski.edziennik.utils.SwipeRefreshLayoutNoTouch
import pl.szczodrzynski.edziennik.utils.Themes
import pl.szczodrzynski.edziennik.utils.Utils
@ -118,6 +119,7 @@ class MainActivity : AppCompatActivity() {
const val TARGET_HELP = 502
const val TARGET_FEEDBACK = 120
const val TARGET_MESSAGES_DETAILS = 503
const val TARGET_WEB_PUSH = 140
const val HOME_ID = DRAWER_ITEM_HOME
@ -209,6 +211,7 @@ class MainActivity : AppCompatActivity() {
list += NavTarget(TARGET_HELP, R.string.menu_help, HelpFragment::class)
list += NavTarget(TARGET_FEEDBACK, R.string.menu_feedback, FeedbackFragment::class)
list += NavTarget(TARGET_MESSAGES_DETAILS, R.string.menu_message, MessageFragment::class)
list += NavTarget(TARGET_WEB_PUSH, R.string.menu_web_push, WebPushFragment::class)
list += NavTarget(DRAWER_ITEM_DEBUG, R.string.menu_debug, DebugFragment::class)
list

View File

@ -14,6 +14,8 @@ import pl.szczodrzynski.edziennik.data.api.szkolny.adapter.TimeAdapter
import pl.szczodrzynski.edziennik.data.api.szkolny.interceptor.SignatureInterceptor
import pl.szczodrzynski.edziennik.data.api.szkolny.request.EventShareRequest
import pl.szczodrzynski.edziennik.data.api.szkolny.request.ServerSyncRequest
import pl.szczodrzynski.edziennik.data.api.szkolny.request.WebPushRequest
import pl.szczodrzynski.edziennik.data.api.szkolny.response.WebPushResponse
import pl.szczodrzynski.edziennik.data.db.modules.events.EventFull
import pl.szczodrzynski.edziennik.data.db.modules.profiles.ProfileFull
import pl.szczodrzynski.edziennik.utils.models.Date
@ -121,4 +123,34 @@ class SzkolnyApi(val app: App) {
eventId = event.id
)).execute()
}
fun pairBrowser(browserId: String?, pairToken: String?): List<WebPushResponse.Browser> {
val response = api.webPush(WebPushRequest(
action = "pairBrowser",
deviceId = app.deviceId,
browserId = browserId,
pairToken = pairToken
)).execute().body()
return response?.data?.browsers ?: emptyList()
}
fun listBrowsers(): List<WebPushResponse.Browser> {
val response = api.webPush(WebPushRequest(
action = "listBrowsers",
deviceId = app.deviceId
)).execute().body()
return response?.data?.browsers ?: emptyList()
}
fun unpairBrowser(browserId: String): List<WebPushResponse.Browser> {
val response = api.webPush(WebPushRequest(
action = "unpairBrowser",
deviceId = app.deviceId,
browserId = browserId
)).execute().body()
return response?.data?.browsers ?: emptyList()
}
}

View File

@ -6,8 +6,10 @@ package pl.szczodrzynski.edziennik.data.api.szkolny
import pl.szczodrzynski.edziennik.data.api.szkolny.request.EventShareRequest
import pl.szczodrzynski.edziennik.data.api.szkolny.request.ServerSyncRequest
import pl.szczodrzynski.edziennik.data.api.szkolny.request.WebPushRequest
import pl.szczodrzynski.edziennik.data.api.szkolny.response.ApiResponse
import pl.szczodrzynski.edziennik.data.api.szkolny.response.ServerSyncResponse
import pl.szczodrzynski.edziennik.data.api.szkolny.response.WebPushResponse
import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.POST
@ -19,4 +21,7 @@ interface SzkolnyService {
@POST("share")
fun shareEvent(@Body request: EventShareRequest): Call<ApiResponse<Nothing>>
@POST("webPush")
fun webPush(@Body request: WebPushRequest): Call<ApiResponse<WebPushResponse>>
}

View File

@ -0,0 +1,14 @@
/*
* Copyright (c) Kuba Szczodrzyński 2019-12-19.
*/
package pl.szczodrzynski.edziennik.data.api.szkolny.request
data class WebPushRequest(
val action: String,
val deviceId: String,
val browserId: String? = null,
val pairToken: String? = null
)

View File

@ -0,0 +1,14 @@
/*
* Copyright (c) Kuba Szczodrzyński 2019-12-19.
*/
package pl.szczodrzynski.edziennik.data.api.szkolny.response
data class WebPushResponse(val browsers: List<Browser>) {
data class Browser(
val id: Int,
val browserId: String,
val userAgent: String,
val dateRegistered: String
)
}

View File

@ -0,0 +1,50 @@
/*
* Copyright (c) Kuba Szczodrzyński 2019-12-19.
*/
package pl.szczodrzynski.edziennik.ui
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.databinding.TemplateListItemBinding
import pl.szczodrzynski.edziennik.onClick
class TemplateAdapter(
val context: Context,
val onItemClick: ((item: TemplateItem) -> Unit)? = null,
val onItemButtonClick: ((item: TemplateItem) -> Unit)? = null
) : RecyclerView.Adapter<TemplateAdapter.ViewHolder>() {
private val app by lazy { context.applicationContext as App }
var items = listOf<TemplateItem>()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val inflater = LayoutInflater.from(parent.context)
val view = TemplateListItemBinding.inflate(inflater, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = items[position]
val b = holder.b
onItemClick?.let { listener ->
b.root.onClick { listener(item) }
}
/*b.someButton.visibility = if (buttonVisible) View.VISIBLE else View.GONE
onItemButtonClick?.let { listener ->
b.someButton.onClick { listener(item) }
}*/
}
override fun getItemCount() = items.size
class ViewHolder(val b: TemplateListItemBinding) : RecyclerView.ViewHolder(b.root)
data class TemplateItem(val text: String)
}

View File

@ -0,0 +1,62 @@
/*
* Copyright (c) Kuba Szczodrzyński 2019-12-19.
*/
package pl.szczodrzynski.edziennik.ui.dialogs
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import me.dm7.barcodescanner.zxing.ZXingScannerView
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.dp
import kotlin.coroutines.CoroutineContext
class QrScannerDialog(
val activity: AppCompatActivity,
val onCodeScanned: (text: String) -> Unit,
val onShowListener: ((tag: String) -> Unit)? = null,
val onDismissListener: ((tag: String) -> Unit)? = null
) : CoroutineScope {
companion object {
private const val TAG = "QrScannerDialog"
}
private lateinit var app: App
private lateinit var scannerView: ZXingScannerView
private lateinit var dialog: AlertDialog
private val job = Job()
override val coroutineContext: CoroutineContext
get() = job + Dispatchers.Main
init { run {
if (activity.isFinishing)
return@run
onShowListener?.invoke(TAG)
app = activity.applicationContext as App
scannerView = ZXingScannerView(activity)
scannerView.setPadding(0, 16.dp, 0, 0)
dialog = MaterialAlertDialogBuilder(activity)
.setTitle(R.string.qr_scanner_dialog_title)
.setView(scannerView)
.setPositiveButton(R.string.close) { dialog, _ ->
dialog.dismiss()
}
.setOnDismissListener {
onDismissListener?.invoke(TAG)
}
.show()
scannerView.setResultHandler {
scannerView.stopCamera()
dialog.dismiss()
onCodeScanned(it.text)
}
scannerView.startCamera()
}}
}

View File

@ -5,25 +5,34 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.navigation.NavController
import androidx.navigation.Navigation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.MainActivity
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.databinding.FragmentTemplateBinding
import pl.szczodrzynski.edziennik.utils.Themes
import kotlin.coroutines.CoroutineContext
class TemplateFragment : Fragment() {
class TemplateFragment : Fragment(), CoroutineScope {
companion object {
private const val TAG = "TemplateFragment"
}
private lateinit var app: App
private lateinit var activity: MainActivity
private lateinit var b: FragmentTemplateBinding
private val navController: NavController by lazy { Navigation.findNavController(b.root) }
private val job: Job = Job()
override val coroutineContext: CoroutineContext
get() = job + Dispatchers.Main
// local/private variables go here
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
activity = (getActivity() as MainActivity?) ?: return null
if (context == null)
return null
context ?: return null
app = activity.application as App
context!!.theme.applyStyle(Themes.appTheme, true)
if (app.profile == null)

View File

@ -34,8 +34,7 @@ class NotificationsFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
activity = (getActivity() as MainActivity?) ?: return null
if (context == null)
return null
context ?: return null
app = activity.application as App
context!!.theme.applyStyle(Themes.appTheme, true)
if (app.profile == null)

View File

@ -53,7 +53,6 @@ import pl.szczodrzynski.edziennik.sync.SyncWorker;
import pl.szczodrzynski.edziennik.ui.dialogs.changelog.ChangelogDialog;
import pl.szczodrzynski.edziennik.ui.dialogs.settings.ProfileRemoveDialog;
import pl.szczodrzynski.edziennik.ui.modules.home.HomeFragment;
import pl.szczodrzynski.edziennik.ui.modules.webpush.WebPushConfigActivity;
import pl.szczodrzynski.edziennik.utils.Themes;
import pl.szczodrzynski.edziennik.utils.Utils;
import pl.szczodrzynski.edziennik.utils.models.Date;
@ -718,8 +717,7 @@ public class SettingsNewFragment extends MaterialAboutFragment {
.color(IconicsColor.colorInt(iconColor))
)
.setOnClickAction(() -> {
Intent i = new Intent(activity, WebPushConfigActivity.class);
startActivity(i);
activity.loadTarget(MainActivity.TARGET_WEB_PUSH, null);
})
);

View File

@ -53,8 +53,7 @@ class TimetableFragment : Fragment(), CoroutineScope {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
activity = (getActivity() as MainActivity?) ?: return null
if (context == null)
return null
context ?: return null
app = activity.application as App
job = Job()
context!!.theme.applyStyle(Themes.appTheme, true)

View File

@ -0,0 +1,53 @@
/*
* Copyright (c) Kuba Szczodrzyński 2019-12-19.
*/
package pl.szczodrzynski.edziennik.ui.modules.webpush
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.data.api.szkolny.response.WebPushResponse
import pl.szczodrzynski.edziennik.databinding.WebPushBrowserItemBinding
import pl.szczodrzynski.edziennik.onClick
import pl.szczodrzynski.edziennik.setText
class WebPushBrowserAdapter(
val context: Context,
val onItemClick: ((browser: WebPushResponse.Browser) -> Unit)? = null,
val onUnpairButtonClick: ((browser: WebPushResponse.Browser) -> Unit)? = null
) : RecyclerView.Adapter<WebPushBrowserAdapter.ViewHolder>() {
private val app by lazy { context.applicationContext as App }
var items = listOf<WebPushResponse.Browser>()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val inflater = LayoutInflater.from(parent.context)
val view = WebPushBrowserItemBinding.inflate(inflater, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val browser = items[position]
val b = holder.b
onItemClick?.let { listener ->
b.root.onClick { listener(browser) }
}
b.browserName.text = browser.userAgent
b.datePaired.setText(R.string.web_push_date_paired_format, browser.dateRegistered)
onUnpairButtonClick?.let { listener ->
b.unpair.onClick { listener(browser) }
}
}
override fun getItemCount() = items.size
class ViewHolder(val b: WebPushBrowserItemBinding) : RecyclerView.ViewHolder(b.root)
}

View File

@ -0,0 +1,149 @@
/*
* Copyright (c) Kuba Szczodrzyński 2019-12-19.
*/
package pl.szczodrzynski.edziennik.ui.modules.webpush
import android.Manifest
import android.annotation.SuppressLint
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.coroutines.*
import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.data.api.szkolny.SzkolnyApi
import pl.szczodrzynski.edziennik.data.api.szkolny.response.WebPushResponse
import pl.szczodrzynski.edziennik.databinding.WebPushFragmentBinding
import pl.szczodrzynski.edziennik.ui.dialogs.QrScannerDialog
import pl.szczodrzynski.edziennik.utils.SimpleDividerItemDecoration
import pl.szczodrzynski.edziennik.utils.Themes
import kotlin.coroutines.CoroutineContext
class WebPushFragment : Fragment(), CoroutineScope {
companion object {
private const val TAG = "TemplateFragment"
}
private lateinit var app: App
private lateinit var activity: MainActivity
private lateinit var b: WebPushFragmentBinding
private val job: Job = Job()
override val coroutineContext: CoroutineContext
get() = job + Dispatchers.Main
private lateinit var adapter: WebPushBrowserAdapter
private val api by lazy {
SzkolnyApi(app)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
activity = (getActivity() as MainActivity?) ?: return null
context ?: return null
app = activity.application as App
context!!.theme.applyStyle(Themes.appTheme, true)
if (app.profile == null)
return inflater.inflate(R.layout.fragment_loading, container, false)
// activity, context and profile is valid
b = WebPushFragmentBinding.inflate(inflater)
return b.root
}
@SuppressLint("DefaultLocale")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// TODO check if app, activity, b can be null
if (app.profile == null || !isAdded)
return
b.scanQrCode.onClick {
val result = ContextCompat.checkSelfPermission(activity, Manifest.permission.CAMERA)
if (result == PackageManager.PERMISSION_GRANTED) {
QrScannerDialog(activity, {
b.tokenEditText.setText(it.crc32().toString(36).toUpperCase())
pairBrowser(browserId = it)
})
} else {
ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.CAMERA), 1)
}
}
b.tokenAccept.onClick {
val pairToken = b.tokenEditText.text.toString().toUpperCase()
if (!"[0-9A-Z]{3,13}".toRegex().matches(pairToken)) {
b.tokenLayout.error = app.getString(R.string.web_push_token_invalid)
return@onClick
}
b.tokenLayout.error = null
b.tokenEditText.setText(pairToken)
pairBrowser(pairToken = pairToken)
}
adapter = WebPushBrowserAdapter(
activity,
onItemClick = null,
onUnpairButtonClick = {
unpairBrowser(it.browserId)
}
)
launch {
val browsers = withContext(Dispatchers.Default) {
api.listBrowsers()
}
updateBrowserList(browsers)
}
}
private fun updateBrowserList(browsers: List<WebPushResponse.Browser>) {
adapter.items = browsers
if (b.browsersView.adapter == null) {
b.browsersView.adapter = adapter
b.browsersView.apply {
isNestedScrollingEnabled = false
setHasFixedSize(true)
layoutManager = LinearLayoutManager(context)
addItemDecoration(SimpleDividerItemDecoration(context))
}
}
adapter.notifyDataSetChanged()
if (browsers.isNotEmpty()) {
b.browsersView.visibility = View.VISIBLE
b.browsersNoData.visibility = View.GONE
} else {
b.browsersView.visibility = View.GONE
b.browsersNoData.visibility = View.VISIBLE
}
}
private fun pairBrowser(browserId: String? = null, pairToken: String? = null) {
b.scanQrCode.isEnabled = false
b.tokenAccept.isEnabled = false
b.tokenEditText.isEnabled = false
b.tokenEditText.clearFocus()
launch {
val browsers = withContext(Dispatchers.Default) {
api.pairBrowser(browserId, pairToken)
}
b.scanQrCode.isEnabled = true
b.tokenAccept.isEnabled = true
b.tokenEditText.isEnabled = true
updateBrowserList(browsers)
}
}
private fun unpairBrowser(browserId: String) {
launch {
val browsers = withContext(Dispatchers.Default) {
api.unpairBrowser(browserId)
}
updateBrowserList(browsers)
}
}
}