diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e727599..f11a352 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,24 +18,30 @@ android { versionName(Versions.App.versionName) resConfigs("en", "de") + + addManifestPlaceholders(mapOf("firebaseDisabled" to true, "crashlyticsEnabled" to false)) } buildTypes { getByName("debug") { - addManifestPlaceholders(mapOf("firebaseDisabled" to true, "crashlyticsEnabled" to false)) - isCrunchPngs = false extra.set("enableCrashlytics", false) extra.set("alwaysUpdateBuildId", false) + } + create("staging") { + initWith(buildTypes.getByName("release")) + versionNameSuffix("-staging") - setApplicationIdSuffix(".debug") + debuggable(true) + proguardFiles("proguard-rules.pro") + + signingConfig = signingConfigs.getByName("debug") } getByName("release") { addManifestPlaceholders(mapOf("firebaseDisabled" to false, "crashlyticsEnabled" to true)) minifyEnabled(true) isShrinkResources = true - proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" diff --git a/app/src/debug/google-services.json b/app/src/debug/google-services.json index 067be0f..502e5cc 100644 --- a/app/src/debug/google-services.json +++ b/app/src/debug/google-services.json @@ -47,7 +47,7 @@ "client_info": { "mobilesdk_app_id": "FAKEfakeFAKE", "android_client_info": { - "package_name": "net.theluckycoder.stundenplan.debug" + "package_name": "net.theluckycoder.stundenplan" } }, "oauth_client": [ @@ -55,7 +55,7 @@ "client_id": "FAKEfakeFAKE", "client_type": 1, "android_info": { - "package_name": "net.theluckycoder.stundenplan.debug", + "package_name": "net.theluckycoder.stundenplan", "certificate_hash": "FAKEfakeFAKE" } }, diff --git a/app/src/main/java/net/theluckycoder/stundenplan/App.kt b/app/src/main/java/net/theluckycoder/stundenplan/App.kt index ac07405..ca13a85 100644 --- a/app/src/main/java/net/theluckycoder/stundenplan/App.kt +++ b/app/src/main/java/net/theluckycoder/stundenplan/App.kt @@ -1,6 +1,7 @@ package net.theluckycoder.stundenplan import android.app.Application +import android.util.Log import com.google.firebase.ktx.Firebase import com.google.firebase.remoteconfig.ktx.remoteConfig import com.google.firebase.remoteconfig.ktx.remoteConfigSettings @@ -12,9 +13,16 @@ class App : Application() { super.onCreate() val configSettings = remoteConfigSettings { - minimumFetchIntervalInSeconds = 5 * 60 // 5 minutes + minimumFetchIntervalInSeconds = 10 * 60 // 10 minutes } - Firebase.remoteConfig.setConfigSettingsAsync(configSettings) + Firebase.remoteConfig.also { + it.setConfigSettingsAsync(configSettings) + it.fetchAndActivate().addOnCompleteListener { task -> + if (task.isSuccessful) { + Log.i("RemoteConfig", "Remote Config Fetched Successfully") + } + } + } } } diff --git a/app/src/main/java/net/theluckycoder/stundenplan/extensions/ContextExtensions.kt b/app/src/main/java/net/theluckycoder/stundenplan/extensions/ContextExtensions.kt new file mode 100644 index 0000000..f17ea9d --- /dev/null +++ b/app/src/main/java/net/theluckycoder/stundenplan/extensions/ContextExtensions.kt @@ -0,0 +1,21 @@ +package net.theluckycoder.stundenplan.extensions + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri + +fun Context.browseUrl(url: String): Boolean { + return try { + val intent = Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(url) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + startActivity(intent) + true + } catch (e: ActivityNotFoundException) { + e.printStackTrace() + false + } +} diff --git a/app/src/main/java/net/theluckycoder/stundenplan/extensions/Extensions.kt b/app/src/main/java/net/theluckycoder/stundenplan/extensions/Extensions.kt new file mode 100644 index 0000000..6fac5b3 --- /dev/null +++ b/app/src/main/java/net/theluckycoder/stundenplan/extensions/Extensions.kt @@ -0,0 +1,15 @@ +package net.theluckycoder.stundenplan.extensions + +import android.app.Application +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import android.view.View +import android.widget.ProgressBar +import androidx.core.content.getSystemService +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel + +val AndroidViewModel.app: Application + get() = getApplication() diff --git a/app/src/main/java/net/theluckycoder/stundenplan/model/Timetable.kt b/app/src/main/java/net/theluckycoder/stundenplan/model/Timetable.kt new file mode 100644 index 0000000..295bc3e --- /dev/null +++ b/app/src/main/java/net/theluckycoder/stundenplan/model/Timetable.kt @@ -0,0 +1,6 @@ +package net.theluckycoder.stundenplan.model + +data class Timetable( + val type: TimetableType, + val url: String, +) diff --git a/app/src/main/java/net/theluckycoder/stundenplan/TimetableType.kt b/app/src/main/java/net/theluckycoder/stundenplan/model/TimetableType.kt similarity index 59% rename from app/src/main/java/net/theluckycoder/stundenplan/TimetableType.kt rename to app/src/main/java/net/theluckycoder/stundenplan/model/TimetableType.kt index 2d18ea4..57e9cea 100644 --- a/app/src/main/java/net/theluckycoder/stundenplan/TimetableType.kt +++ b/app/src/main/java/net/theluckycoder/stundenplan/model/TimetableType.kt @@ -1,4 +1,4 @@ -package net.theluckycoder.stundenplan +package net.theluckycoder.stundenplan.model enum class TimetableType { HIGH_SCHOOL, diff --git a/app/src/main/java/net/theluckycoder/stundenplan/repository/MainRepository.kt b/app/src/main/java/net/theluckycoder/stundenplan/repository/MainRepository.kt index a645168..f7c1722 100644 --- a/app/src/main/java/net/theluckycoder/stundenplan/repository/MainRepository.kt +++ b/app/src/main/java/net/theluckycoder/stundenplan/repository/MainRepository.kt @@ -2,6 +2,9 @@ package net.theluckycoder.stundenplan.repository import android.content.Context import androidx.core.net.toUri +import com.google.firebase.ktx.Firebase +import com.google.firebase.remoteconfig.ktx.get +import com.google.firebase.remoteconfig.ktx.remoteConfig import com.tonyodev.fetch2.AbstractFetchListener import com.tonyodev.fetch2.Download import com.tonyodev.fetch2.Error @@ -14,8 +17,10 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.sendBlocking import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.tasks.await import net.theluckycoder.stundenplan.R -import net.theluckycoder.stundenplan.TimetableType +import net.theluckycoder.stundenplan.model.Timetable +import net.theluckycoder.stundenplan.model.TimetableType import net.theluckycoder.stundenplan.utils.FirebaseConstants import net.theluckycoder.stundenplan.utils.NetworkResult import net.theluckycoder.stundenplan.utils.getConfigKey @@ -23,26 +28,43 @@ import java.io.File class MainRepository(private val context: Context) { - private fun getNewFile(timetableType: TimetableType): File { - val dir = File(context.cacheDir, timetableType.getConfigKey()) + private fun getNewFile(timetable: Timetable): File { + val dir = File(context.cacheDir, timetable.type.getConfigKey()) dir.mkdirs() - val minutes = System.currentTimeMillis() / 1000 / 60 - return File(dir, "$minutes.pdf") + val name = timetable.url.substringAfterLast('/') + + return File(dir, name) + } + + fun doesFileExist(timetable: Timetable): Boolean { + return File( + File(context.cacheDir, timetable.type.getConfigKey()), + timetable.url.substringAfter('/') + ).exists() + } + + suspend fun getTimetable(timetableType: TimetableType): Timetable { + val remoteConfig = Firebase.remoteConfig + + remoteConfig.fetchAndActivate().await() + val pdfUrl = remoteConfig[timetableType.getConfigKey()].asString() + + return Timetable(timetableType, pdfUrl) } @OptIn(ExperimentalCoroutinesApi::class) - suspend fun downloadPdf(timetableType: TimetableType, url: String) = callbackFlow { - val file = getNewFile(timetableType).toUri() + suspend fun downloadPdf(timetable: Timetable) = callbackFlow { + val file = getNewFile(timetable).toUri() - val request = Request(url, file).apply { + val request = Request(timetable.url, file).apply { priority = Priority.HIGH networkType = NetworkType.ALL } val listener = object : AbstractFetchListener() { override fun onCancelled(download: Download) { - sendBlocking(NetworkResult.Failed(R.string.error_download_failed)) + sendBlocking(NetworkResult.Failed(NetworkResult.FailReason.DownloadFailed)) close() } @@ -52,7 +74,7 @@ class MainRepository(private val context: Context) { } override fun onError(download: Download, error: Error, throwable: Throwable?) { - sendBlocking(NetworkResult.Failed(R.string.error_download_failed)) + sendBlocking(NetworkResult.Failed(NetworkResult.FailReason.DownloadFailed)) close() } @@ -69,11 +91,7 @@ class MainRepository(private val context: Context) { } } - val fetch = Fetch.getInstance( - FetchConfiguration.Builder(context) - .setDownloadConcurrentLimit(3) - .build() - ) + val fetch = Fetch.getInstance(FetchConfiguration.Builder(context).build()) fetch.addListener(listener) fetch.enqueue(request, { }) { error -> error.throwable?.let { throw it } } @@ -94,7 +112,7 @@ class MainRepository(private val context: Context) { return files.asSequence() .filterNotNull() - .sortedDescending() + .sortedByDescending { it.lastModified() } .firstOrNull() } diff --git a/app/src/main/java/net/theluckycoder/stundenplan/ui/MainActivity.kt b/app/src/main/java/net/theluckycoder/stundenplan/ui/MainActivity.kt index c9e880d..cf0dcc4 100644 --- a/app/src/main/java/net/theluckycoder/stundenplan/ui/MainActivity.kt +++ b/app/src/main/java/net/theluckycoder/stundenplan/ui/MainActivity.kt @@ -6,18 +6,24 @@ import android.view.Menu import android.view.MenuItem import android.view.View import androidx.activity.viewModels +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope import androidx.transition.Slide import androidx.transition.TransitionManager import com.github.barteksc.pdfviewer.util.FitPolicy import com.google.android.material.snackbar.Snackbar -import net.theluckycoder.stundenplan.viewmodel.MainViewModel +import net.theluckycoder.stundenplan.BuildConfig import net.theluckycoder.stundenplan.R -import net.theluckycoder.stundenplan.TimetableType -import net.theluckycoder.stundenplan.utils.NetworkResult import net.theluckycoder.stundenplan.databinding.MainActivityBinding +import net.theluckycoder.stundenplan.extensions.browseUrl +import net.theluckycoder.stundenplan.model.TimetableType import net.theluckycoder.stundenplan.utils.Analytics +import net.theluckycoder.stundenplan.utils.NetworkResult +import net.theluckycoder.stundenplan.utils.UpdateChecker +import net.theluckycoder.stundenplan.viewmodel.MainViewModel class MainActivity : AppCompatActivity() { @@ -25,9 +31,25 @@ class MainActivity : AppCompatActivity() { private lateinit var binding: MainActivityBinding private var isToolbarVisible = true - private var timetableType: TimetableType? = null private var useDarkTheme = true + init { + // Ensure that the proper timetable is selected in the BottomNavigationView + lifecycleScope.launchWhenStarted { + binding.bottomBar.selectedItemId = when (viewModel.timetableType()) { + TimetableType.HIGH_SCHOOL -> R.id.nav_high_school + TimetableType.MIDDLE_SCHOOL -> R.id.nav_middle_school + } + } + + lifecycleScope.launchWhenResumed { + if (!viewModel.hasSeenUpdateDialog) { + showUpdateDialog() + viewModel.hasSeenUpdateDialog = true + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -35,19 +57,21 @@ class MainActivity : AppCompatActivity() { setContentView(binding.root) setSupportActionBar(binding.toolbar) - binding.viewer.maxZoom = 6f - binding.viewer.setNightMode(useDarkTheme) - binding.viewer.setOnClickListener { - supportActionBar?.let { - TransitionManager.beginDelayedTransition(binding.root, Slide(Gravity.TOP)) + binding.pdfView.maxZoom = 6f + binding.pdfView.setOnClickListener { + isToolbarVisible = !isToolbarVisible - if (isToolbarVisible) it.hide() else it.show() + supportActionBar?.let { + TransitionManager.beginDelayedTransition(binding.toolbar, Slide(Gravity.TOP)) - isToolbarVisible = !isToolbarVisible + if (isToolbarVisible) it.show() else it.hide() } + + TransitionManager.beginDelayedTransition(binding.bottomBar, Slide(Gravity.BOTTOM)) + binding.bottomBar.isVisible = isToolbarVisible } - viewModel.getStateLiveData().observe(this) { result -> + viewModel.networkState.observe(this) { result -> when (result) { is NetworkResult.Success -> { hideProgressBar() @@ -62,42 +86,35 @@ class MainActivity : AppCompatActivity() { } is NetworkResult.Failed -> { hideProgressBar() - makeErrorSnackbar(result.reasonStringRes) - .setAction(R.string.action_retry) { - viewModel.refresh(timetableType!!) - } + + val reasonStringRes = when (result.reason) { + NetworkResult.FailReason.MissingNetworkConnection -> R.string.error_network_connection + NetworkResult.FailReason.DownloadFailed -> R.string.error_download_failed + } + + makeErrorSnackbar(reasonStringRes) + .setAction(R.string.action_retry) { viewModel.refresh(force = true) } .show() } } } - viewModel.darkThemeData.observe(this, { darkTheme -> - if (useDarkTheme != darkTheme) { - useDarkTheme = darkTheme + viewModel.darkTheme.observe(this, { darkTheme -> + useDarkTheme = darkTheme - with(binding.viewer) { - setNightMode(darkTheme) - loadPages() - } + with(binding.pdfView) { + setNightMode(darkTheme) + loadPages() } }) - viewModel.timetableTypeData.observe(this, { - // Load last file first, then attempt to download a new one - // Since it's very likely that the last downloaded PDF is also the most recent one - if (timetableType == null) - viewModel.preload(it) - - if (timetableType != it) { - timetableType = it - supportActionBar?.subtitle = - getString(if (it == TimetableType.HIGH_SCHOOL) R.string.high_school else R.string.middle_school) - - invalidateOptionsMenu() - - viewModel.refresh(it) + binding.bottomBar.setOnNavigationItemSelectedListener { + when (it.itemId) { + R.id.nav_high_school -> viewModel.switchTimetableType(TimetableType.HIGH_SCHOOL) + R.id.nav_middle_school -> viewModel.switchTimetableType(TimetableType.MIDDLE_SCHOOL) } - }) + true + } if (intent.getBooleanExtra(ARG_OPENED_FROM_NOTIFICATION, false)) Analytics.openNotificationEvent() @@ -108,21 +125,9 @@ class MainActivity : AppCompatActivity() { return super.onCreateOptionsMenu(menu) } - override fun onPrepareOptionsMenu(menu: Menu): Boolean { - menu.findItem(R.id.action_switch_to_high_school) - .isVisible = timetableType != TimetableType.HIGH_SCHOOL - menu.findItem(R.id.action_switch_to_middle_school) - .isVisible = timetableType != TimetableType.MIDDLE_SCHOOL - - return super.onPrepareOptionsMenu(menu) - } - override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.action_switch_theme -> viewModel.switchTheme(!useDarkTheme) - R.id.action_refresh -> viewModel.refresh(timetableType!!) - R.id.action_switch_to_high_school -> viewModel.switchTimetableType(TimetableType.HIGH_SCHOOL) - R.id.action_switch_to_middle_school -> viewModel.switchTimetableType(TimetableType.MIDDLE_SCHOOL) else -> return super.onOptionsItemSelected(item) } return true @@ -135,13 +140,10 @@ class MainActivity : AppCompatActivity() { .setBackgroundTint(ContextCompat.getColor(this, R.color.red_800)) private fun displayPdf(result: NetworkResult.Success) { - binding.viewer.fromUri(result.fileUri) + binding.pdfView.fromUri(result.fileUri) .enableSwipe(true) .swipeHorizontal(false) .enableDoubletap(true) - .onError { - makeErrorSnackbar(R.string.error_rendering_failed).show() - } .enableAntialiasing(true) .pageFitPolicy(FitPolicy.WIDTH) // mode to fit pages in the view .nightMode(useDarkTheme) @@ -162,7 +164,22 @@ class MainActivity : AppCompatActivity() { } } + private fun showUpdateDialog() { + UpdateChecker { + AlertDialog.Builder(this) + .setTitle(R.string.update_available) + .setMessage(R.string.update_available_desc) + .setPositiveButton(R.string.action_update) { _, _ -> + browseUrl(APP_STORE_URL) + } + .setNegativeButton(R.string.action_ignore, null) + .show() + } + } + companion object { const val ARG_OPENED_FROM_NOTIFICATION = "opened_from_notification" + private const val APP_STORE_URL = + "https://play.google.com/store/apps/details?id=${BuildConfig.APPLICATION_ID}" } } diff --git a/app/src/main/java/net/theluckycoder/stundenplan/utils/Analytics.kt b/app/src/main/java/net/theluckycoder/stundenplan/utils/Analytics.kt index 36863b5..fe28b56 100644 --- a/app/src/main/java/net/theluckycoder/stundenplan/utils/Analytics.kt +++ b/app/src/main/java/net/theluckycoder/stundenplan/utils/Analytics.kt @@ -3,7 +3,7 @@ package net.theluckycoder.stundenplan.utils import android.os.Bundle import com.google.firebase.analytics.ktx.analytics import com.google.firebase.ktx.Firebase -import net.theluckycoder.stundenplan.TimetableType +import net.theluckycoder.stundenplan.model.TimetableType object Analytics { diff --git a/app/src/main/java/net/theluckycoder/stundenplan/utils/AppPreferences.kt b/app/src/main/java/net/theluckycoder/stundenplan/utils/AppPreferences.kt index 33e3c19..5001435 100644 --- a/app/src/main/java/net/theluckycoder/stundenplan/utils/AppPreferences.kt +++ b/app/src/main/java/net/theluckycoder/stundenplan/utils/AppPreferences.kt @@ -5,9 +5,9 @@ import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map -import net.theluckycoder.stundenplan.TimetableType +import net.theluckycoder.stundenplan.model.TimetableType private val Context.appDataStore by preferencesDataStore(AppPreferences.DATA_STORE_NAME) @@ -16,7 +16,7 @@ class AppPreferences(private val context: Context) { val darkThemeFlow: Flow = context.appDataStore.data .map { preferences -> preferences[DARK_THEME] ?: false - }.distinctUntilChanged() + } suspend fun updateUseDarkTheme(useDarkTheme: Boolean) = context.appDataStore.edit { preferences -> preferences[DARK_THEME] = useDarkTheme @@ -28,7 +28,10 @@ class AppPreferences(private val context: Context) { TimetableType.MIDDLE_SCHOOL else TimetableType.HIGH_SCHOOL - }.distinctUntilChanged() + } + + suspend fun timetableType(): TimetableType = timetableTypeFlow.first() + suspend fun updateTimetableType(timetableType: TimetableType) = context.appDataStore.edit { preferences -> preferences[TIMETABLE_TYPE] = timetableType == TimetableType.HIGH_SCHOOL diff --git a/app/src/main/java/net/theluckycoder/stundenplan/utils/Extensions.kt b/app/src/main/java/net/theluckycoder/stundenplan/utils/Extensions.kt deleted file mode 100644 index 1f0ab35..0000000 --- a/app/src/main/java/net/theluckycoder/stundenplan/utils/Extensions.kt +++ /dev/null @@ -1,32 +0,0 @@ -package net.theluckycoder.stundenplan.utils - -import android.app.Application -import android.content.Context -import android.net.ConnectivityManager -import android.net.NetworkCapabilities -import android.os.Build -import android.view.View -import android.widget.ProgressBar -import androidx.core.content.getSystemService -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.ViewModel - -@Suppress("DEPRECATION") -fun Context.isNetworkAvailable(): Boolean { - val connectivityManager = getSystemService() ?: return false - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val nw = connectivityManager.activeNetwork ?: return false - val actNw = connectivityManager.getNetworkCapabilities(nw) ?: return false - - return actNw.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) - || actNw.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) - || actNw.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) - || actNw.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH) - } else { - val nwInfo = connectivityManager.activeNetworkInfo ?: return false - return nwInfo.isConnected - } -} - -val AndroidViewModel.app: Application - get() = getApplication() diff --git a/app/src/main/java/net/theluckycoder/stundenplan/utils/FirebaseConstants.kt b/app/src/main/java/net/theluckycoder/stundenplan/utils/FirebaseConstants.kt index e565b44..ca760cd 100644 --- a/app/src/main/java/net/theluckycoder/stundenplan/utils/FirebaseConstants.kt +++ b/app/src/main/java/net/theluckycoder/stundenplan/utils/FirebaseConstants.kt @@ -1,6 +1,6 @@ package net.theluckycoder.stundenplan.utils -import net.theluckycoder.stundenplan.TimetableType +import net.theluckycoder.stundenplan.model.TimetableType object FirebaseConstants { const val KEY_HIGH_SCHOOL = "url_high_school" diff --git a/app/src/main/java/net/theluckycoder/stundenplan/utils/NetworkResult.kt b/app/src/main/java/net/theluckycoder/stundenplan/utils/NetworkResult.kt index 3c508c0..cba6e31 100644 --- a/app/src/main/java/net/theluckycoder/stundenplan/utils/NetworkResult.kt +++ b/app/src/main/java/net/theluckycoder/stundenplan/utils/NetworkResult.kt @@ -4,6 +4,13 @@ import android.net.Uri sealed class NetworkResult { class Success(val fileUri: Uri) : NetworkResult() + class Loading(val indeterminate: Boolean, val progress: Int) : NetworkResult() - class Failed(val reasonStringRes: Int) : NetworkResult() + + class Failed(val reason: FailReason) : NetworkResult() + + enum class FailReason { + MissingNetworkConnection, + DownloadFailed, + } } diff --git a/app/src/main/java/net/theluckycoder/stundenplan/utils/UpdateChecker.kt b/app/src/main/java/net/theluckycoder/stundenplan/utils/UpdateChecker.kt new file mode 100644 index 0000000..5a740db --- /dev/null +++ b/app/src/main/java/net/theluckycoder/stundenplan/utils/UpdateChecker.kt @@ -0,0 +1,23 @@ +package net.theluckycoder.stundenplan.utils + +import android.util.Log +import com.google.firebase.ktx.Firebase +import com.google.firebase.remoteconfig.ktx.remoteConfig +import net.theluckycoder.stundenplan.BuildConfig + +class UpdateChecker(onUpdateNeeded: () -> Unit) { + + init { + val remoteConfig = Firebase.remoteConfig + val latestVersion = remoteConfig.getLong(KEY_CURRENT_VERSION).toInt() + + if (latestVersion > BuildConfig.VERSION_CODE) { + Log.v(UpdateChecker::class.java.name, "New Update available: $latestVersion") + onUpdateNeeded() + } + } + + companion object { + const val KEY_CURRENT_VERSION = "latest_version" + } +} diff --git a/app/src/main/java/net/theluckycoder/stundenplan/viewmodel/MainViewModel.kt b/app/src/main/java/net/theluckycoder/stundenplan/viewmodel/MainViewModel.kt index 6a63883..bebceef 100644 --- a/app/src/main/java/net/theluckycoder/stundenplan/viewmodel/MainViewModel.kt +++ b/app/src/main/java/net/theluckycoder/stundenplan/viewmodel/MainViewModel.kt @@ -10,26 +10,18 @@ import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import com.google.firebase.ktx.Firebase import com.google.firebase.messaging.ktx.messaging -import com.google.firebase.remoteconfig.ktx.get -import com.google.firebase.remoteconfig.ktx.remoteConfig import com.tonyodev.fetch2core.isNetworkAvailable -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.* import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.launch -import kotlinx.coroutines.tasks.await -import kotlinx.coroutines.withContext import net.theluckycoder.stundenplan.BuildConfig -import net.theluckycoder.stundenplan.R -import net.theluckycoder.stundenplan.TimetableType +import net.theluckycoder.stundenplan.model.TimetableType import net.theluckycoder.stundenplan.repository.MainRepository -import net.theluckycoder.stundenplan.utils.Analytics import net.theluckycoder.stundenplan.utils.AppPreferences import net.theluckycoder.stundenplan.utils.FirebaseConstants import net.theluckycoder.stundenplan.utils.NetworkResult -import net.theluckycoder.stundenplan.utils.app -import net.theluckycoder.stundenplan.utils.getConfigKey -import java.util.concurrent.atomic.AtomicBoolean +import net.theluckycoder.stundenplan.extensions.app /** * https://developer.android.com/jetpack/guide @@ -38,78 +30,95 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private val repository = MainRepository(app) private val preferences = AppPreferences(app) - private val isDownloading = AtomicBoolean(false) private val stateData = MutableLiveData() + private var downloadJob: Job? = null - val darkThemeData = preferences.darkThemeFlow.asLiveData() - val timetableTypeData = preferences.timetableTypeFlow.asLiveData() + val darkTheme = preferences.darkThemeFlow.asLiveData() + val networkState: LiveData get() = stateData + + var hasSeenUpdateDialog = false init { try { - subscribeToFirebase() + subscribeToFirebaseTopics() } catch (e: Exception) { e.printStackTrace() } - } - fun getStateLiveData(): LiveData = stateData + viewModelScope.launch { + preferences.timetableTypeFlow.collectLatest { + ensureActive() + refresh(it) + } + } + } fun switchTheme(useDarkTheme: Boolean) = viewModelScope.launch(Dispatchers.IO) { preferences.updateUseDarkTheme(useDarkTheme) } - fun switchTimetableType(newTimetableType: TimetableType) = viewModelScope.launch(Dispatchers.IO) { - preferences.updateTimetableType(newTimetableType) - } + fun switchTimetableType(newTimetableType: TimetableType) = + viewModelScope.launch(Dispatchers.IO) { + preferences.updateTimetableType(newTimetableType) + } + + suspend fun timetableType(): TimetableType = preferences.timetableType() - fun preload(timetableType: TimetableType) = viewModelScope.launch { + private fun loadLastTimetable(timetableType: TimetableType) = viewModelScope.launch { val fileUri = withContext(Dispatchers.IO) { repository.getLastFile(timetableType)?.toUri() } if (fileUri != null) - NetworkResult.Success(fileUri) + stateData.value = NetworkResult.Success(fileUri) } - fun refresh(timetableType: TimetableType) = viewModelScope.launch { - if (!app.isNetworkAvailable()) { - stateData.value = NetworkResult.Failed(R.string.error_network_connection) - return@launch - } - - if (isDownloading.get()) - return@launch - - Analytics.refreshEvent(timetableType) - - isDownloading.set(true) - - stateData.value = NetworkResult.Loading(true, 0) - - try { - val remoteConfig = Firebase.remoteConfig - - val successful = remoteConfig.fetchAndActivate().await() - if (!successful) - Log.i(PDF_TAG, "Remote Config fetch not successful") - - val pdfUrl = remoteConfig[timetableType.getConfigKey()].asString() - check(pdfUrl.isNotBlank()) - Log.i(PDF_TAG, "Url: $pdfUrl") - - repository.downloadPdf(timetableType, pdfUrl).flowOn(Dispatchers.IO).collect { - stateData.value = it + fun refresh(timetableType: TimetableType? = null, force: Boolean = false) = + viewModelScope.launch { + downloadJob?.cancelAndJoin() + + downloadJob = viewModelScope.launch { + val type = + timetableType ?: withContext(Dispatchers.IO) { preferences.timetableType() } + + val isNetworkAvailable = app.isNetworkAvailable() + val preloadJob = loadLastTimetable(type) + + if (isNetworkAvailable) { + // Let the user know that we are starting to download a new timetable + stateData.value = NetworkResult.Loading(true, 0) + + try { + val timetable = repository.getTimetable(type) + check(timetable.url.isNotBlank()) { "No PDF url link found" } + Log.i(PDF_TAG, "Url: ${timetable.url}") + + // Show the pre-existing PDF before + preloadJob.join() + + if (force || !repository.doesFileExist(timetable)) { + repository.downloadPdf(timetable) + .flowOn(Dispatchers.IO) + .collect { networkResult -> + ensureActive() + stateData.postValue(networkResult) + } + } + + Log.i(PDF_TAG, "Finished downloading") + } catch (e: Exception) { + stateData.value = NetworkResult.Failed(NetworkResult.FailReason.DownloadFailed) + Log.e(PDF_TAG, "Failed to download", e) + } + } else { + preloadJob.join() // Only load the last downloaded one + + // Let the user know that we can't download a newer timetable + stateData.value = NetworkResult.Failed(NetworkResult.FailReason.MissingNetworkConnection) + } } - - Log.i(PDF_TAG, "Finished Loading") - } catch (e: Exception) { - stateData.value = NetworkResult.Failed(R.string.error_download_failed) - Log.e(PDF_TAG, "Failed to download", e) } - isDownloading.set(false) - } - - private fun subscribeToFirebase() { + private fun subscribeToFirebaseTopics() { with(Firebase.messaging) { subscribeToTopic(FirebaseConstants.TOPIC_ALL) diff --git a/app/src/main/res/layout/main_activity.xml b/app/src/main/res/layout/main_activity.xml index 3982ddc..0a6e327 100644 --- a/app/src/main/res/layout/main_activity.xml +++ b/app/src/main/res/layout/main_activity.xml @@ -7,7 +7,7 @@ tools:context=".ui.MainActivity"> @@ -17,15 +17,30 @@ android:layout_height="?attr/actionBarSize" android:background="@color/color_primary_transparent" android:theme="?attr/actionBarTheme" - app:subtitle="@string/high_school" app:title="@string/activity_title" app:titleTextColor="@color/white" /> - + android:layout_height="wrap_content" + android:layout_gravity="bottom" + android:orientation="vertical"> + + + + + + diff --git a/app/src/main/res/menu/bottom_app_bar.xml b/app/src/main/res/menu/bottom_app_bar.xml new file mode 100644 index 0000000..9664a02 --- /dev/null +++ b/app/src/main/res/menu/bottom_app_bar.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/main_menu.xml b/app/src/main/res/menu/main_menu.xml index a41c463..41d85f6 100644 --- a/app/src/main/res/menu/main_menu.xml +++ b/app/src/main/res/menu/main_menu.xml @@ -9,13 +9,6 @@ - - - - - \ No newline at end of file + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 16ffc05..0601756 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -4,18 +4,27 @@ Lyzeum Gymnasium + Thema wechseln Neu laden Wieder versuchen Wechseln zu Lyzeum Wechseln zu Gymnasium + Aktualisiere + Ignorieren + Keine Internetverbindung! Stundenplan konnte nicht heruntergeladen werden Stundenplan konnte nicht angezeigt werden + Benachrichtigungen Lyzeum Benachrichtigungen Gymnasium Benachrichtigungen Neuer Stundenplan! + + + Aktualisierung verfügbar + Eine neue Aktualisierung für Brukplan ist verfügbar diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0e9a0f9..849de24 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,18 +5,27 @@ High School Middle School + Switch Theme Refresh Retry Switch to High School Switch to Middle School + Update + Ignore + No internet connection! Failed to download Timetable Could not display Timetable + Notifications High School Notifications Middle School Notifications New Timetable! + + + Update Available + A new update for Brukplan is available diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 40d55f8..26745b8 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -1,8 +1,8 @@ object Versions { object App { private const val major = 1 - private const val minor = 1 - private const val patch = 8 + private const val minor = 2 + private const val patch = 0 const val versionCode: Int = major * 100 + minor * 10 + patch const val versionName: String = "$major.$minor.$patch"