Add last synced time to collection properties (bitfireAT/davx5#260)

* Add last synced time to collection properties

* Show last synced time for every to the collection relevant authority

* Try finding the application name for given package name

* Resolve authority to package name before finding application label

* Use AndroidViewModel instead of ViewModel; change model LiveData to val

* Rewrite to Compose, use relative time description

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
This commit is contained in:
Sunik Kupfer 2023-05-17 17:33:29 +02:00 committed by Ricki Hirner
parent 5aff37a9a6
commit a072cf0404
5 changed files with 119 additions and 95 deletions

View file

@ -111,7 +111,7 @@ dependencies {
implementation project(':vcard4android')
implementation "org.jetbrains.kotlin:kotlin-stdlib:${versions.kotlin}"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
implementation "com.google.dagger:hilt-android:${versions.hilt}"
@ -122,7 +122,7 @@ dependencies {
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.concurrent:concurrent-futures-ktx:1.1.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.core:core-ktx:1.10.0'
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.fragment:fragment-ktx:1.5.7'
implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0'
@ -141,6 +141,7 @@ dependencies {
implementation composeBom
androidTestImplementation composeBom
implementation 'androidx.compose.material:material'
implementation 'androidx.compose.runtime:runtime-livedata'
implementation 'com.google.accompanist:accompanist-themeadapter-material:0.30.1'
// Jetpack Room

View file

@ -4,9 +4,11 @@
package at.bitfire.davdroid.db
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface SyncStatsDao {
@ -14,4 +16,6 @@ interface SyncStatsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrReplace(syncStats: SyncStats)
@Query("SELECT * FROM syncstats WHERE collectionId=:id")
fun getLiveByCollectionId(id: Long): LiveData<List<SyncStats>>
}

View file

@ -4,17 +4,39 @@
package at.bitfire.davdroid.ui.account
import android.app.Application
import android.content.pm.PackageManager
import android.os.Bundle
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.map
import androidx.lifecycle.switchMap
import at.bitfire.davdroid.databinding.CollectionPropertiesBinding
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.TaskUtils
import com.google.accompanist.themeadapter.material.MdcTheme
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -39,7 +61,7 @@ class CollectionInfoFragment: DialogFragment() {
}
@Inject lateinit var modelFactory: Model.Factory
val model by viewModels<Model>() {
val model by viewModels<Model> {
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>) =
@ -48,31 +70,110 @@ class CollectionInfoFragment: DialogFragment() {
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = CollectionPropertiesBinding.inflate(inflater, container, false)
view.lifecycleOwner = this
view.model = model
return ComposeView(requireContext()).apply {
setContent {
MdcTheme {
CollectionInfoDialog()
}
}
}
}
return view.root
@Composable
fun CollectionInfoDialog() {
Column(Modifier.padding(16.dp)) {
// URL
val collectionState = model.collection.observeAsState()
collectionState.value?.let { collection ->
Text(stringResource(R.string.collection_properties_url), style = MaterialTheme.typography.h5)
Text(collection.url.toString(), modifier = Modifier.padding(bottom = 16.dp), fontFamily = FontFamily.Monospace)
}
// Owner
val owner = model.owner.observeAsState()
owner.value?.let { principal ->
Text(stringResource(R.string.collection_properties_owner), style = MaterialTheme.typography.h5)
Text(principal.displayName ?: principal.url.toString(), Modifier.padding(bottom = 16.dp))
}
// Last synced (for all applicable authorities)
val lastSyncedState = model.lastSynced.observeAsState()
lastSyncedState.value?.let { lastSynced ->
Text(stringResource(R.string.collection_properties_sync_time), style = MaterialTheme.typography.h5)
if (lastSynced.isEmpty())
Text(stringResource(R.string.collection_properties_sync_time_never))
else
for ((app, timestamp) in lastSynced.entries) {
Text(app)
val timeStr = DateUtils.getRelativeDateTimeString(requireContext(), timestamp,
DateUtils.SECOND_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0).toString()
Text(timeStr, Modifier.padding(bottom = 8.dp))
}
}
}
}
class Model @AssistedInject constructor(
application: Application,
val db: AppDatabase,
@Assisted collectionId: Long
): ViewModel() {
): AndroidViewModel(application) {
@AssistedFactory
interface Factory {
fun create(collectionId: Long): Model
}
var collection = db.collectionDao().getLive(collectionId)
var owner = collection.switchMap { collection ->
val collection = db.collectionDao().getLive(collectionId)
val owner = collection.switchMap { collection ->
collection.ownerId?.let { ownerId ->
db.principalDao().getLive(ownerId)
}
}
val lastSynced: LiveData<Map<String, Long>> = // map: app name -> last sync timestamp
db.syncStatsDao().getLiveByCollectionId(collectionId).map { syncStatsList ->
// map: authority -> syncStats
val syncStatsMap = syncStatsList.associateBy { it.authority }
val interestingAuthorities = listOfNotNull(
ContactsContract.AUTHORITY,
CalendarContract.AUTHORITY,
TaskUtils.currentProvider(getApplication())?.authority
)
val result = mutableMapOf<String, Long>()
// map (authority name) -> (app name, last sync timestamp)
for (authority in interestingAuthorities) {
val lastSync = syncStatsMap[authority]?.lastSync
if (lastSync != null)
result[getAppNameFromAuthority(authority)] = lastSync
}
result
}
/**
* Tries to find the application name for given authority. Returns the authority if not
* found.
*
* @param authority authority to find the application name for (ie "at.techbee.jtx")
* @return the application name of authority (ie "jtx Board")
*/
private fun getAppNameFromAuthority(authority: String): String {
val packageManager = getApplication<Application>().packageManager
@Suppress("DEPRECATION")
val packageName = packageManager.resolveContentProvider(authority, 0)?.packageName ?: authority
return try {
@Suppress("DEPRECATION")
val appInfo = packageManager.getPackageInfo(packageName, 0).applicationInfo
packageManager.getApplicationLabel(appInfo).toString()
} catch (e: PackageManager.NameNotFoundException) {
Logger.log.warning("Application name not found for authority: $authority")
authority
}
}
}
}

View file

@ -1,84 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="model"
type="at.bitfire.davdroid.ui.account.CollectionInfoFragment.Model"/>
<import type="android.view.View"/>
</data>
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="10dp">
<TextView
style="@style/TextAppearance.MaterialComponents.Headline5"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@{model.collection.title()}"
android:textAlignment="viewStart"
tools:text="Collection Title" />
<TextView
android:id="@+id/url_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="4dp"
android:text="@string/collection_properties_url"
android:labelFor="@id/url" />
<TextView
android:id="@+id/url"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:text="@{model.collection.url.toString()}"
android:textAlignment="viewStart"
android:textIsSelectable="true"
android:textSize="12sp"
tools:text="https://example.com/dav/url/user/12345/calendars/1234-1234-9834-31234-12/" />
<TextView
android:id="@+id/owner_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="4dp"
android:visibility="@{model.owner.displayName != null || model.owner.url != null ? View.VISIBLE : View.GONE}"
android:text="@string/collection_properties_owner"
android:labelFor="@id/ownerDisplayName" />
<TextView
android:id="@+id/ownerDisplayName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="@{model.owner.displayName != null ? View.VISIBLE : View.GONE}"
android:textSize="16sp"
android:textIsSelectable="true"
android:text="@{model.owner.displayName}"
tools:text="Max Murmelmann"/>
<TextView
android:id="@+id/ownerUrl"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="@{model.owner.displayName == null &amp;&amp; model.owner.url != null ? View.VISIBLE : View.GONE}"
android:fontFamily="monospace"
android:textSize="12sp"
android:textIsSelectable="true"
android:text="@{model.owner.url.toString()}"
tools:text="/remote.php/dav/principals/users/sample-owner/"/>
</LinearLayout>
</ScrollView>
</layout>

View file

@ -417,6 +417,8 @@
<string name="delete_collection_deleting_collection">Deleting collection</string>
<string name="collection_force_read_only">Force read-only</string>
<string name="collection_properties">Properties</string>
<string name="collection_properties_sync_time">Last synced:</string>
<string name="collection_properties_sync_time_never">Never synced</string>
<string name="collection_properties_url">Address (URL):</string>
<string name="collection_properties_copy_url">Copy URL</string>
<string name="collection_properties_owner">Owner:</string>