Move to compat shortcut manager

This commit is contained in:
Mario Danic 2020-01-02 12:31:47 +01:00 committed by Mario Đanić
parent b265994031
commit d6935d65d6
14 changed files with 217 additions and 144 deletions

View File

@ -191,7 +191,6 @@ dependencies {
implementation "com.github.stateless4j:stateless4j:2.6.0" implementation "com.github.stateless4j:stateless4j:2.6.0"
// ViewModel and LiveData // ViewModel and LiveData
implementation "androidx.core:core-ktx:1.1.0"
implementation "androidx.sqlite:sqlite-ktx:2.0.1" implementation "androidx.sqlite:sqlite-ktx:2.0.1"
implementation "androidx.collection:collection-ktx:1.1.0" implementation "androidx.collection:collection-ktx:1.1.0"
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"
@ -229,6 +228,8 @@ dependencies {
androidTestImplementation "androidx.work:work-testing:$work_version" androidTestImplementation "androidx.work:work-testing:$work_version"
implementation 'androidx.appcompat:appcompat:1.1.0' implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.core:core-ktx:1.2.0-rc01'
implementation 'androidx.sharetarget:sharetarget:1.0.0-rc01'
implementation 'com.google.android.material:material:1.2.0-alpha03' implementation 'com.google.android.material:material:1.2.0-alpha03'
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta4' implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta4'
implementation 'com.github.vanniktech:Emoji:0.6.0' implementation 'com.github.vanniktech:Emoji:0.6.0'

View File

@ -63,9 +63,9 @@
<application <application
android:name=".application.NextcloudTalkApplication" android:name=".application.NextcloudTalkApplication"
android:allowBackup="false" android:allowBackup="false"
android:fullBackupContent="@xml/backup_config"
android:allowClearUserData="false" android:allowClearUserData="false"
android:allowClearUserDataOnFailedRestore="true" android:allowClearUserDataOnFailedRestore="true"
android:fullBackupContent="@xml/backup_config"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/nc_app_name" android:label="@string/nc_app_name"
android:largeHeap="true" android:largeHeap="true"
@ -79,15 +79,34 @@
android:name="android.max_aspect" android:name="android.max_aspect"
android:value="10" /> android:value="10" />
<activity <activity
android:name=".activities.MainActivity" android:name=".activities.MainActivity"
android:label="@string/nc_app_label" android:label="@string/nc_app_label"
android:launchMode="singleTask" android:launchMode="singleTask"
android:windowSoftInputMode="adjustPan"> android:windowSoftInputMode="adjustPan">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!--
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<meta-data android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
<meta-data
android:name="android.service.chooser.chooser_target_service"
android:value="androidx.sharetarget.ChooserTargetServiceCompat"
/>
-->
</activity> </activity>
<activity <activity
@ -104,7 +123,6 @@
<service <service
android:name="com.novoda.merlin.MerlinService" android:name="com.novoda.merlin.MerlinService"
android:exported="false" /> android:exported="false" />
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}" android:authorities="${applicationId}"

View File

@ -51,7 +51,7 @@ import com.nextcloud.talk.newarch.features.conversationsList.di.module.Conversat
import com.nextcloud.talk.newarch.local.dao.UsersDao import com.nextcloud.talk.newarch.local.dao.UsersDao
import com.nextcloud.talk.newarch.local.models.UserNgEntity import com.nextcloud.talk.newarch.local.models.UserNgEntity
import com.nextcloud.talk.newarch.local.models.other.UserStatus.* import com.nextcloud.talk.newarch.local.models.other.UserStatus.*
import com.nextcloud.talk.newarch.utils.ShortcutService import com.nextcloud.talk.newarch.services.shortcuts.ShortcutService
import com.nextcloud.talk.utils.ClosedInterfaceImpl import com.nextcloud.talk.utils.ClosedInterfaceImpl
import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.DisplayUtils
import com.nextcloud.talk.utils.database.user.UserUtils import com.nextcloud.talk.utils.database.user.UserUtils
@ -133,10 +133,7 @@ class NextcloudTalkApplication : Application(), LifecycleObserver {
setAppTheme(appPreferences.theme) setAppTheme(appPreferences.theme)
super.onCreate() super.onCreate()
val shortcutService: ShortcutService = get()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val shortcutService: ShortcutService = get()
}
Security.insertProviderAt(Conscrypt.newProvider(), 1) Security.insertProviderAt(Conscrypt.newProvider(), 1)
ClosedInterfaceImpl().providerInstallerInstallIfNeededAsync() ClosedInterfaceImpl().providerInstallerInstallIfNeededAsync()

View File

@ -664,9 +664,9 @@ class ContactsController : BaseController,
private fun prepareViews() { private fun prepareViews() {
layoutManager = SmoothScrollLinearLayoutManager(activity!!) layoutManager = SmoothScrollLinearLayoutManager(activity!!)
recyclerView!!.layoutManager = layoutManager recyclerView?.layoutManager = layoutManager
recyclerView!!.setHasFixedSize(true) recyclerView?.setHasFixedSize(true)
recyclerView!!.adapter = adapter recyclerView?.adapter = adapter
adapter!!.setStickyHeaderElevation(5) adapter!!.setStickyHeaderElevation(5)
.setUnlinkAllItemsOnRemoveHeaders(true) .setUnlinkAllItemsOnRemoveHeaders(true)

View File

@ -23,7 +23,6 @@ package com.nextcloud.talk.jobs
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.text.TextUtils
import android.util.Base64 import android.util.Base64
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.ListenableWorker.Result import androidx.work.ListenableWorker.Result
@ -68,8 +67,8 @@ class PushRegistrationWorker(
} }
private fun pushRegistrationToServer() { private fun pushRegistrationToServer() {
val token: String = appPreferences.pushToken val token: String? = appPreferences.pushToken
if (!TextUtils.isEmpty(token)) { if (!token.isNullOrEmpty()) {
var credentials: String var credentials: String
val pushUtils = PushUtils(usersRepository) val pushUtils = PushUtils(usersRepository)
val pushTokenHash = token.hashWithAlgorithm("SHA-512") val pushTokenHash = token.hashWithAlgorithm("SHA-512")

View File

@ -65,8 +65,8 @@ class ConversationsRepositoryImpl(val conversationsDao: ConversationsDao) :
} }
} }
override fun getLastThreeActiveConversationsForUser(userId: Long): LiveData<List<Conversation>> { override fun getShortcutTargetConversations(userId: Long): LiveData<List<Conversation>> {
return conversationsDao.getLastThreeConversationsForUser(userId).distinctUntilChanged().map { data -> return conversationsDao.getShortcutTargetConversations(userId).distinctUntilChanged().map { data ->
data.map { data.map {
it.toConversation() it.toConversation()
} }

View File

@ -6,7 +6,7 @@ import com.nextcloud.talk.newarch.domain.repository.offline.UsersRepository
import com.nextcloud.talk.newarch.domain.usecases.GetConversationUseCase import com.nextcloud.talk.newarch.domain.usecases.GetConversationUseCase
import com.nextcloud.talk.newarch.domain.usecases.JoinConversationUseCase import com.nextcloud.talk.newarch.domain.usecases.JoinConversationUseCase
import com.nextcloud.talk.newarch.services.GlobalService import com.nextcloud.talk.newarch.services.GlobalService
import com.nextcloud.talk.newarch.utils.ShortcutService import com.nextcloud.talk.newarch.services.shortcuts.ShortcutService
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koin.dsl.module import org.koin.dsl.module
import java.net.CookieManager import java.net.CookieManager
@ -14,7 +14,6 @@ import java.net.CookieManager
val ServiceModule = module { val ServiceModule = module {
single { createGlobalService(get(), get(), get(), get(), get(), get()) } single { createGlobalService(get(), get(), get(), get(), get(), get()) }
single { createShortcutService(get(), get(), get()) } single { createShortcutService(get(), get(), get()) }
} }
fun createGlobalService(usersRepository: UsersRepository, cookieManager: CookieManager, fun createGlobalService(usersRepository: UsersRepository, cookieManager: CookieManager,

View File

@ -25,7 +25,7 @@ import com.nextcloud.talk.models.json.conversations.Conversation
interface ConversationsRepository { interface ConversationsRepository {
fun getConversationsForUser(userId: Long): LiveData<List<Conversation>> fun getConversationsForUser(userId: Long): LiveData<List<Conversation>>
fun getLastThreeActiveConversationsForUser(userId: Long): LiveData<List<Conversation>> fun getShortcutTargetConversations(userId: Long): LiveData<List<Conversation>>
suspend fun getConversationForUserWithToken(userId: Long, token: String): Conversation? suspend fun getConversationForUserWithToken(userId: Long, token: String): Conversation?
suspend fun clearConversationsForUser(userId: Long) suspend fun clearConversationsForUser(userId: Long)

View File

@ -30,8 +30,8 @@ abstract class ConversationsDao {
@Query("SELECT * FROM conversations WHERE user_id = :userId ORDER BY favorite DESC, last_activity DESC") @Query("SELECT * FROM conversations WHERE user_id = :userId ORDER BY favorite DESC, last_activity DESC")
abstract fun getConversationsForUser(userId: Long): LiveData<List<ConversationEntity>> abstract fun getConversationsForUser(userId: Long): LiveData<List<ConversationEntity>>
@Query("SELECT * FROM conversations WHERE user_id = :userId ORDER BY favorite DESC, last_activity DESC LIMIT 3") @Query("SELECT * FROM conversations WHERE user_id = :userId ORDER BY favorite DESC, last_activity DESC LIMIT 4")
abstract fun getLastThreeConversationsForUser(userId: Long): LiveData<List<ConversationEntity>> abstract fun getShortcutTargetConversations(userId: Long): LiveData<List<ConversationEntity>>
@Query("DELETE FROM conversations WHERE user_id = :userId") @Query("DELETE FROM conversations WHERE user_id = :userId")
abstract suspend fun clearConversationsForUser(userId: Long) abstract suspend fun clearConversationsForUser(userId: Long)

View File

@ -0,0 +1,149 @@
/*
*
* * Nextcloud Talk application
* *
* * @author Mario Danic
* * Copyright (C) 2017-2020 Mario Danic <mario@lovelyhq.com>
* *
* * This program is free software: you can redistribute it and/or modify
* * it under the terms of the GNU General Public License as published by
* * the Free Software Foundation, either version 3 of the License, or
* * at your option) any later version.
* *
* * This program is distributed in the hope that it will be useful,
* * but WITHOUT ANY WARRANTY; without even the implied warranty of
* * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* * GNU General Public License for more details.
* *
* * You should have received a copy of the GNU General Public License
* * along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package com.nextcloud.talk.newarch.services.shortcuts
import android.content.Context
import android.content.Intent
import android.graphics.drawable.BitmapDrawable
import androidx.core.app.Person
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import coil.Coil
import coil.api.get
import coil.transform.CircleCropTransformation
import com.nextcloud.talk.R
import com.nextcloud.talk.activities.MainActivity
import com.nextcloud.talk.models.json.conversations.Conversation
import com.nextcloud.talk.newarch.domain.repository.offline.ConversationsRepository
import com.nextcloud.talk.newarch.local.models.UserNgEntity
import com.nextcloud.talk.newarch.local.models.getCredentials
import com.nextcloud.talk.newarch.services.GlobalService
import com.nextcloud.talk.newarch.utils.Images
import com.nextcloud.talk.utils.ApiUtils
import com.nextcloud.talk.utils.bundle.BundleKeys
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlin.math.abs
class ShortcutService constructor(private var context: Context,
private val conversationsRepository: ConversationsRepository,
globalService: GlobalService
) {
private var currentUser: UserNgEntity? = null
private var lastFourActiveConversations: LiveData<List<Conversation>> = Transformations.switchMap(globalService.currentUserLiveData) { user ->
currentUser = user
var internalUserId: Long = -1
currentUser?.let {
internalUserId = it.id!!
}
conversationsRepository.getShortcutTargetConversations(internalUserId)
}
init {
lastFourActiveConversations.observeForever {
GlobalScope.launch {
registerShortcuts(it)
}
}
}
private suspend fun registerShortcuts(conversations: List<Conversation>) {
val openNewConversationIntent = Intent(context, MainActivity::class.java)
openNewConversationIntent.action = BundleKeys.KEY_NEW_CONVERSATION
val shortcuts: MutableList<ShortcutInfoCompat> = mutableListOf()
val contactCategories: MutableSet<String> = HashSet()
contactCategories.add(context.getString(R.string.nc_text_share_target))
val images = Images()
currentUser?.let { user ->
shortcuts.add(ShortcutInfoCompat.Builder(context, "new")
//.setRank(4)
.setShortLabel(context.resources.getString(R.string.nc_new_conversation))
.setIcon(IconCompat.createWithBitmap(context.resources.getDrawable(R.drawable.new_conversation_shortcut).toBitmap()))
.setIntent(openNewConversationIntent)
.setAlwaysBadged()
.build())
for ((index, conversation) in conversations.withIndex()) {
val intent = Intent(context, MainActivity::class.java)
intent.action = BundleKeys.KEY_OPEN_CONVERSATION
intent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, user.id)
intent.putExtra(BundleKeys.KEY_ROOM_TOKEN, conversation.token)
val persons = mutableListOf<Person>()
conversation.participants?.forEach {
val hashMap = it.value as HashMap<*, *>
val personBuilder = Person.Builder()
personBuilder.setName(hashMap["name"].toString())
personBuilder.setBot(false)
// we need a key for each of the users
/*val isGuest = hashMap["type"]?.equals(Participant.ParticipantType.GUEST) == true
|| hashMap["type"]?.equals(Participant.ParticipantType.GUEST_AS_MODERATOR) == true
|| hashMap["type"]?.equals(Participant.ParticipantType.USER_FOLLOWING_LINK) == true
val avatarUrl = if (isGuest) ApiUtils.getUrlForAvatarWithNameForGuests(user.baseUrl, hashMap["name"].toString(), R.dimen.avatar_size_big)
else ApiUtils.getUrlForAvatarWithName(user.baseUrl, hashMap["userId"].toString(), R.dimen.avatar_size_big)
iconImage = Coil.get(avatarUrl) {
addHeader("Authorization", user.getCredentials())
transformations(CircleCropTransformation())
}
personBuilder.setIcon(IconCompat.createWithBitmap((iconImage as BitmapDrawable).bitmap))*/
persons.add(personBuilder.build())
}
var iconImage = images.getImageForConversation(context, conversation)
if (iconImage == null) {
iconImage = Coil.get(ApiUtils.getUrlForAvatarWithName(user.baseUrl, conversation.name, R.dimen.avatar_size_big)) {
addHeader("Authorization", user.getCredentials())
transformations(CircleCropTransformation())
}
}
shortcuts.add(ShortcutInfoCompat.Builder(context, "current_conversation_" + (index + 1))
.setShortLabel(conversation.displayName as String)
.setLongLabel(conversation.displayName as String)
.setIcon(IconCompat.createWithBitmap((iconImage as BitmapDrawable).bitmap))
.setIntent(intent)
.setRank(abs(index - 4 + 1))
.setRank(0)
.setAlwaysBadged()
.setCategories(contactCategories)
.setPersons(persons.toTypedArray())
.build())
}
}
ShortcutManagerCompat.removeAllDynamicShortcuts(context)
ShortcutManagerCompat.addDynamicShortcuts(context, shortcuts)
}
}

View File

@ -1,123 +0,0 @@
/*
* Nextcloud Talk application
*
* @author Thomas Ebert<thomas@thomasebert.net>
* @author Mario Danic
* Copyright (C) 2017-2019 Mario Danic <mario@lovelyhq.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.talk.newarch.utils
import android.annotation.TargetApi
import android.content.Context
import android.content.Intent
import android.content.pm.ShortcutInfo
import android.content.pm.ShortcutManager
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Icon
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import coil.Coil
import coil.api.get
import coil.transform.CircleCropTransformation
import com.nextcloud.talk.R
import com.nextcloud.talk.activities.MainActivity
import com.nextcloud.talk.models.json.conversations.Conversation
import com.nextcloud.talk.newarch.domain.repository.offline.ConversationsRepository
import com.nextcloud.talk.newarch.local.models.UserNgEntity
import com.nextcloud.talk.newarch.local.models.getCredentials
import com.nextcloud.talk.newarch.services.GlobalService
import com.nextcloud.talk.utils.ApiUtils
import com.nextcloud.talk.utils.bundle.BundleKeys
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class ShortcutService constructor(private val context: Context,
private val conversationsRepository: ConversationsRepository,
conversationsService: GlobalService
) {
private var currentUser: UserNgEntity? = null
@RequiresApi(Build.VERSION_CODES.N_MR1)
private val shortcutManager = context.getSystemService(ShortcutManager::class.java)
@RequiresApi(Build.VERSION_CODES.P)
private var lastThreeActiveConversations: LiveData<List<Conversation>> = Transformations.switchMap(conversationsService.currentUserLiveData) { user ->
currentUser = user
var internalUserId: Long = -1
currentUser?.let {
internalUserId = it.id!!
}
conversationsRepository.getLastThreeActiveConversationsForUser(internalUserId)
}
init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
lastThreeActiveConversations.observeForever {
GlobalScope.launch {
registerShortcuts()
}
}
}
}
@TargetApi(Build.VERSION_CODES.P)
private suspend fun registerShortcuts() {
val openNewConversationIntent = Intent(context, MainActivity::class.java)
openNewConversationIntent.action = BundleKeys.KEY_NEW_CONVERSATION
val shortcuts: MutableList<ShortcutInfo> = mutableListOf()
val images = Images()
currentUser?.let { user ->
shortcuts.add(ShortcutInfo.Builder(context, "new")
.setShortLabel(context.resources.getString(R.string.nc_new_conversation_short))
.setLongLabel(context.resources.getString(R.string.nc_new_conversation))
.setIcon(Icon.createWithBitmap(context.resources.getDrawable(R.drawable.new_conversation_shortcut).toBitmap()))
.setIntent(openNewConversationIntent)
.build())
lastThreeActiveConversations.value?.let { conversations ->
for ((index, conversation) in conversations.withIndex()) {
val intent = Intent(context, MainActivity::class.java)
intent.action = BundleKeys.KEY_OPEN_CONVERSATION
intent.putExtra(BundleKeys.KEY_INTERNAL_USER_ID, user.id)
intent.putExtra(BundleKeys.KEY_ROOM_TOKEN, conversation.token)
var iconImage = images.getImageForConversation(context, conversation)
if (iconImage == null) {
iconImage = Coil.get(ApiUtils.getUrlForAvatarWithName(user.baseUrl, conversation.name, R.dimen.avatar_size_big)) {
addHeader("Authorization", user.getCredentials())
transformations(CircleCropTransformation())
}
}
shortcuts.add(ShortcutInfo.Builder(context, "current_conversation_" + (index + 1))
.setShortLabel(conversation.displayName as String)
.setLongLabel(conversation.displayName as String)
.setIcon(Icon.createWithBitmap((iconImage as BitmapDrawable).bitmap))
.setIntent(intent)
.build())
}
}
}
shortcutManager?.dynamicShortcuts = shortcuts
}
}

View File

@ -60,4 +60,5 @@ object BundleKeys {
val KEY_ACCOUNT = "KEY_ACCOUNT" val KEY_ACCOUNT = "KEY_ACCOUNT"
val KEY_FILE_ID = "KEY_FILE_ID" val KEY_FILE_ID = "KEY_FILE_ID"
val KEY_NOTIFICATION_ID = "KEY_NOTIFICATION_ID" val KEY_NOTIFICATION_ID = "KEY_NOTIFICATION_ID"
val KEY_CONVERSATION_ID = "KEY_CONVERSATION_ID"
} }

View File

@ -50,4 +50,7 @@
<string name="google_app_id" translatable="false">1:829118773643:android:54b65087c544d819</string> <string name="google_app_id" translatable="false">1:829118773643:android:54b65087c544d819</string>
<string name="google_crash_reporting_api_key" translatable="false">AIzaSyAWIyOcLafaFp8PFL61h64cy1NNZW2cU_s</string> <string name="google_crash_reporting_api_key" translatable="false">AIzaSyAWIyOcLafaFp8PFL61h64cy1NNZW2cU_s</string>
<string name="google_storage_bucket" translatable="false">nextcloud-a7dea.appspot.com</string> <string name="google_storage_bucket" translatable="false">nextcloud-a7dea.appspot.com</string>
<!-- Other things -->
<string name="nc_text_share_target">com.nextcloud.talk.newarch.services.shortcuts.category.TEXT_SHARE_TARGET</string>
</resources> </resources>

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ /*
~ * Nextcloud Talk application
~ *
~ * @author Mario Danic
~ * Copyright (C) 2017-2020 Mario Danic <mario@lovelyhq.com>
~ *
~ * This program is free software: you can redistribute it and/or modify
~ * it under the terms of the GNU General Public License as published by
~ * the Free Software Foundation, either version 3 of the License, or
~ * at your option) any later version.
~ *
~ * This program is distributed in the hope that it will be useful,
~ * but WITHOUT ANY WARRANTY; without even the implied warranty of
~ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ * GNU General Public License for more details.
~ *
~ * You should have received a copy of the GNU General Public License
~ * along with this program. If not, see <http://www.gnu.org/licenses/>.
~ */
-->
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<share-target android:targetClass="com.nextcloud.talk.activities.MainActivity" >
<data android:mimeType="text/plain" />
<category android:name="@string/nc_text_share_target" android:label="@string/nc_app_label"/>
</share-target>
</shortcuts>