Initial QSfera import
This commit is contained in:
+826
@@ -0,0 +1,826 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Abel García de Prada
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
* @author Jorge Aguado Recio
|
||||
*
|
||||
* Copyright (C) 2024 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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 eu.qsfera.android.authentication
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.accounts.AccountManager.KEY_ACCOUNT_NAME
|
||||
import android.accounts.AccountManager.KEY_ACCOUNT_TYPE
|
||||
import android.app.Activity.RESULT_OK
|
||||
import android.app.Instrumentation
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.espresso.Espresso.closeSoftKeyboard
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.action.ViewActions.click
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.intent.Intents
|
||||
import androidx.test.espresso.intent.Intents.intended
|
||||
import androidx.test.espresso.intent.matcher.IntentMatchers
|
||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
|
||||
import androidx.test.espresso.matcher.RootMatchers.isDialog
|
||||
import androidx.test.espresso.matcher.ViewMatchers.Visibility
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.domain.exceptions.NoNetworkConnectionException
|
||||
import eu.qsfera.android.domain.exceptions.QSferaVersionNotSupportedException
|
||||
import eu.qsfera.android.domain.exceptions.ServerNotReachableException
|
||||
import eu.qsfera.android.domain.exceptions.UnauthorizedException
|
||||
import eu.qsfera.android.domain.server.model.ServerInfo
|
||||
import eu.qsfera.android.domain.utils.Event
|
||||
import eu.qsfera.android.extensions.parseError
|
||||
import eu.qsfera.android.presentation.authentication.ACTION_UPDATE_EXPIRED_TOKEN
|
||||
import eu.qsfera.android.presentation.authentication.ACTION_UPDATE_TOKEN
|
||||
import eu.qsfera.android.presentation.authentication.AuthenticationViewModel
|
||||
import eu.qsfera.android.presentation.authentication.BASIC_TOKEN_TYPE
|
||||
import eu.qsfera.android.presentation.authentication.EXTRA_ACCOUNT
|
||||
import eu.qsfera.android.presentation.authentication.EXTRA_ACTION
|
||||
import eu.qsfera.android.presentation.authentication.KEY_AUTH_TOKEN_TYPE
|
||||
import eu.qsfera.android.presentation.authentication.LoginActivity
|
||||
import eu.qsfera.android.presentation.authentication.OAUTH_TOKEN_TYPE
|
||||
import eu.qsfera.android.presentation.authentication.oauth.OAuthViewModel
|
||||
import eu.qsfera.android.presentation.common.UIResult
|
||||
import eu.qsfera.android.presentation.settings.SettingsActivity
|
||||
import eu.qsfera.android.presentation.settings.SettingsViewModel
|
||||
import eu.qsfera.android.providers.ContextProvider
|
||||
import eu.qsfera.android.providers.MdmProvider
|
||||
import eu.qsfera.android.testutil.OC_ACCOUNT
|
||||
import eu.qsfera.android.testutil.OC_AUTH_TOKEN_TYPE
|
||||
import eu.qsfera.android.testutil.OC_BASIC_PASSWORD
|
||||
import eu.qsfera.android.testutil.OC_BASIC_USERNAME
|
||||
import eu.qsfera.android.testutil.OC_INSECURE_SERVER_INFO_BASIC_AUTH
|
||||
import eu.qsfera.android.testutil.OC_SECURE_SERVER_INFO_BASIC_AUTH
|
||||
import eu.qsfera.android.testutil.OC_SECURE_SERVER_INFO_BEARER_AUTH
|
||||
import eu.qsfera.android.utils.CONFIGURATION_SERVER_URL
|
||||
import eu.qsfera.android.utils.CONFIGURATION_SERVER_URL_INPUT_VISIBILITY
|
||||
import eu.qsfera.android.utils.NO_MDM_RESTRICTION_YET
|
||||
import eu.qsfera.android.utils.matchers.assertVisibility
|
||||
import eu.qsfera.android.utils.matchers.isDisplayed
|
||||
import eu.qsfera.android.utils.matchers.isEnabled
|
||||
import eu.qsfera.android.utils.matchers.isFocusable
|
||||
import eu.qsfera.android.utils.matchers.withText
|
||||
import eu.qsfera.android.utils.mockIntentToComponent
|
||||
import eu.qsfera.android.utils.replaceText
|
||||
import eu.qsfera.android.utils.scrollAndClick
|
||||
import eu.qsfera.android.utils.typeText
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import io.mockk.verify
|
||||
import org.hamcrest.Matchers.allOf
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.koin.dsl.module
|
||||
|
||||
class LoginActivityTest {
|
||||
|
||||
private lateinit var activityScenario: ActivityScenario<LoginActivity>
|
||||
|
||||
private lateinit var authenticationViewModel: AuthenticationViewModel
|
||||
private lateinit var oauthViewModel: OAuthViewModel
|
||||
private lateinit var settingsViewModel: SettingsViewModel
|
||||
private lateinit var ocContextProvider: ContextProvider
|
||||
private lateinit var mdmProvider: MdmProvider
|
||||
private lateinit var context: Context
|
||||
|
||||
private lateinit var loginResultLiveData: MutableLiveData<Event<UIResult<String>>>
|
||||
private lateinit var serverInfoLiveData: MutableLiveData<Event<UIResult<ServerInfo>>>
|
||||
private lateinit var supportsOauth2LiveData: MutableLiveData<Event<UIResult<Boolean>>>
|
||||
private lateinit var baseUrlLiveData: MutableLiveData<Event<UIResult<String>>>
|
||||
private lateinit var accountDiscoveryLiveData: MutableLiveData<Event<UIResult<Unit>>>
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
authenticationViewModel = mockk(relaxed = true)
|
||||
oauthViewModel = mockk(relaxed = true)
|
||||
settingsViewModel = mockk(relaxUnitFun = true)
|
||||
ocContextProvider = mockk(relaxed = true)
|
||||
mdmProvider = mockk(relaxed = true)
|
||||
val accountManager = mockk<AccountManager>(relaxed = true)
|
||||
every { accountManager.getUserData(any(), any()) } returns null
|
||||
every { accountManager.getPassword(any()) } returns null
|
||||
|
||||
mockkStatic(AccountManager::class)
|
||||
every { AccountManager.get(any()) } returns accountManager
|
||||
|
||||
loginResultLiveData = MutableLiveData()
|
||||
serverInfoLiveData = MutableLiveData()
|
||||
supportsOauth2LiveData = MutableLiveData()
|
||||
baseUrlLiveData = MutableLiveData()
|
||||
accountDiscoveryLiveData = MutableLiveData()
|
||||
|
||||
every { authenticationViewModel.loginResult } returns loginResultLiveData
|
||||
every { authenticationViewModel.serverInfo } returns serverInfoLiveData
|
||||
every { authenticationViewModel.supportsOAuth2 } returns supportsOauth2LiveData
|
||||
every { authenticationViewModel.baseUrl } returns baseUrlLiveData
|
||||
every { authenticationViewModel.accountDiscovery } returns accountDiscoveryLiveData
|
||||
every { settingsViewModel.isThereAttachedAccount() } returns false
|
||||
|
||||
stopKoin()
|
||||
|
||||
startKoin {
|
||||
context
|
||||
allowOverride(override = true)
|
||||
modules(
|
||||
module {
|
||||
viewModel {
|
||||
authenticationViewModel
|
||||
}
|
||||
viewModel {
|
||||
oauthViewModel
|
||||
}
|
||||
viewModel {
|
||||
settingsViewModel
|
||||
}
|
||||
factory {
|
||||
ocContextProvider
|
||||
}
|
||||
factory {
|
||||
mdmProvider
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
private fun launchTest(
|
||||
showServerUrlInput: Boolean = true,
|
||||
serverUrl: String = "",
|
||||
showLoginBackGroundImage: Boolean = true,
|
||||
showWelcomeLink: Boolean = true,
|
||||
accountType: String = "qsfera",
|
||||
loginWelcomeText: String = "",
|
||||
webfingerLookupServer: String = "",
|
||||
intent: Intent? = null
|
||||
) {
|
||||
every { mdmProvider.getBrandingBoolean(CONFIGURATION_SERVER_URL_INPUT_VISIBILITY, R.bool.show_server_url_input) } returns showServerUrlInput
|
||||
every { mdmProvider.getBrandingString(CONFIGURATION_SERVER_URL, R.string.server_url) } returns serverUrl
|
||||
every { mdmProvider.getBrandingString(NO_MDM_RESTRICTION_YET, R.string.webfinger_lookup_server) } returns webfingerLookupServer
|
||||
every { ocContextProvider.getBoolean(R.bool.use_login_background_image) } returns showLoginBackGroundImage
|
||||
every { ocContextProvider.getBoolean(R.bool.show_welcome_link) } returns showWelcomeLink
|
||||
every { ocContextProvider.getString(R.string.account_type) } returns accountType
|
||||
every { ocContextProvider.getString(R.string.login_welcome_text) } returns loginWelcomeText
|
||||
every { ocContextProvider.getString(R.string.app_name) } returns BRANDED_APP_NAME
|
||||
|
||||
activityScenario = if (intent == null) {
|
||||
ActivityScenario.launch(LoginActivity::class.java)
|
||||
} else {
|
||||
ActivityScenario.launch(intent)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialViewStatus_notBrandedOptions() {
|
||||
launchTest()
|
||||
|
||||
assertViewsDisplayed()
|
||||
assertWebfingerFlowDisplayed(webfingerEnabled = false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialViewStatus_brandedOptions_webfinger() {
|
||||
launchTest(webfingerLookupServer = OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl)
|
||||
|
||||
assertWebfingerFlowDisplayed(webfingerEnabled = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialViewStatus_brandedOptions_serverInfoInSetup() {
|
||||
launchTest(showServerUrlInput = false, serverUrl = OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl)
|
||||
|
||||
assertViewsDisplayed(
|
||||
showHostUrlFrame = false,
|
||||
showHostUrlInput = false,
|
||||
showCenteredRefreshButton = true,
|
||||
showEmbeddedCheckServerButton = false
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialViewStatus_brandedOptions_serverInfoInSetup_connectionFails() {
|
||||
|
||||
launchTest(showServerUrlInput = false, serverUrl = OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl)
|
||||
|
||||
serverInfoLiveData.postValue(Event(UIResult.Error(NoNetworkConnectionException())))
|
||||
|
||||
R.id.centeredRefreshButton.isDisplayed(true)
|
||||
R.id.centeredRefreshButton.scrollAndClick()
|
||||
|
||||
verify(exactly = 1) { authenticationViewModel.getServerInfo(OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl, true) }
|
||||
serverInfoLiveData.postValue(Event(UIResult.Success(SECURE_SERVER_INFO_BASIC)))
|
||||
|
||||
R.id.centeredRefreshButton.isDisplayed(false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialViewStatus_brandedOptions_dontUseLoginBackgroundImage() {
|
||||
launchTest(showLoginBackGroundImage = false)
|
||||
|
||||
assertViewsDisplayed(showLoginBackGroundImage = false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialViewStatus_brandedOptions_dontShowWelcomeLink() {
|
||||
launchTest(showWelcomeLink = false)
|
||||
|
||||
assertViewsDisplayed(showWelcomeLink = false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialViewStatus_brandedOptions_customWelcomeText() {
|
||||
launchTest(showWelcomeLink = true, loginWelcomeText = CUSTOM_WELCOME_TEXT)
|
||||
|
||||
assertViewsDisplayed(showWelcomeLink = true)
|
||||
|
||||
R.id.welcome_link.withText(CUSTOM_WELCOME_TEXT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialViewStatus_brandedOptions_defaultWelcomeText() {
|
||||
launchTest(showWelcomeLink = true, loginWelcomeText = "")
|
||||
|
||||
assertViewsDisplayed(showWelcomeLink = true)
|
||||
|
||||
R.id.welcome_link.withText(String.format(ocContextProvider.getString(R.string.auth_register), BRANDED_APP_NAME))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkServerInfo_clickButton_callGetServerInfo() {
|
||||
launchTest()
|
||||
|
||||
R.id.hostUrlInput.typeText(OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl)
|
||||
|
||||
R.id.embeddedCheckServerButton.scrollAndClick()
|
||||
|
||||
verify(exactly = 1) { authenticationViewModel.getServerInfo(OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl, true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkServerInfo_clickLogo_callGetServerInfo() {
|
||||
launchTest()
|
||||
R.id.hostUrlInput.typeText(OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl)
|
||||
|
||||
R.id.thumbnail.scrollAndClick()
|
||||
|
||||
verify(exactly = 1) { authenticationViewModel.getServerInfo(OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl, true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkServerInfo_isLoading_show() {
|
||||
launchTest()
|
||||
serverInfoLiveData.postValue(Event(UIResult.Loading()))
|
||||
|
||||
with(R.id.server_status_text) {
|
||||
isDisplayed(true)
|
||||
withText(R.string.auth_testing_connection)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkServerInfo_isSuccess_updateUrlInput() {
|
||||
launchTest()
|
||||
R.id.hostUrlInput.typeText("demo.qsfera.eu")
|
||||
|
||||
serverInfoLiveData.postValue(Event(UIResult.Success(SECURE_SERVER_INFO_BASIC)))
|
||||
|
||||
R.id.hostUrlInput.withText(SECURE_SERVER_INFO_BASIC.baseUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkServerInfo_isSuccess_Secure() {
|
||||
launchTest()
|
||||
serverInfoLiveData.postValue(Event(UIResult.Success(SECURE_SERVER_INFO_BASIC)))
|
||||
|
||||
with(R.id.server_status_text) {
|
||||
isDisplayed(true)
|
||||
assertVisibility(Visibility.VISIBLE)
|
||||
withText(R.string.auth_secure_connection)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkServerInfo_isSuccess_NotSecure() {
|
||||
launchTest()
|
||||
serverInfoLiveData.postValue(Event(UIResult.Success(INSECURE_SERVER_INFO_BASIC)))
|
||||
|
||||
onView(withText(R.string.insecure_http_url_title_dialog)).check(matches(isDisplayed()))
|
||||
onView(withText(R.string.insecure_http_url_message_dialog)).check(matches(isDisplayed()))
|
||||
onView(withText(R.string.insecure_http_url_continue_button)).inRoot(isDialog()).check(matches(isDisplayed())).perform(click())
|
||||
|
||||
with(R.id.server_status_text) {
|
||||
isDisplayed(true)
|
||||
assertVisibility(Visibility.VISIBLE)
|
||||
withText(R.string.auth_connection_established)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkServerInfo_isSuccess_Basic() {
|
||||
launchTest()
|
||||
|
||||
serverInfoLiveData.postValue(Event(UIResult.Success(SECURE_SERVER_INFO_BASIC)))
|
||||
|
||||
checkBasicFieldsVisibility(loginButtonShouldBeVisible = false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkServerInfo_isSuccess_Bearer() {
|
||||
Intents.init()
|
||||
launchTest()
|
||||
avoidOpeningChromeCustomTab()
|
||||
|
||||
serverInfoLiveData.postValue(Event(UIResult.Success(SECURE_SERVER_INFO_BEARER)))
|
||||
|
||||
checkBearerFieldsVisibility()
|
||||
Intents.release()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkServerInfo_isSuccess_basicModifyUrlInput() {
|
||||
launchTest()
|
||||
|
||||
serverInfoLiveData.postValue(Event(UIResult.Success(SECURE_SERVER_INFO_BASIC)))
|
||||
|
||||
checkBasicFieldsVisibility()
|
||||
|
||||
R.id.account_username.typeText(OC_BASIC_USERNAME)
|
||||
|
||||
R.id.account_password.typeText(OC_BASIC_PASSWORD)
|
||||
|
||||
R.id.hostUrlInput.typeText("anything")
|
||||
|
||||
with(R.id.account_username) {
|
||||
withText("")
|
||||
assertVisibility(Visibility.GONE)
|
||||
}
|
||||
|
||||
with(R.id.account_password) {
|
||||
withText("")
|
||||
assertVisibility(Visibility.GONE)
|
||||
}
|
||||
|
||||
R.id.loginButton.assertVisibility(Visibility.GONE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkServerInfo_isSuccess_bearerModifyUrlInput() {
|
||||
Intents.init()
|
||||
launchTest()
|
||||
avoidOpeningChromeCustomTab()
|
||||
|
||||
serverInfoLiveData.postValue(Event(UIResult.Success(SECURE_SERVER_INFO_BEARER)))
|
||||
|
||||
checkBearerFieldsVisibility()
|
||||
|
||||
R.id.hostUrlInput.typeText("anything")
|
||||
|
||||
R.id.auth_status_text.assertVisibility(Visibility.GONE)
|
||||
Intents.release()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkServerInfo_isError_emptyUrl() {
|
||||
launchTest()
|
||||
|
||||
R.id.hostUrlInput.typeText("")
|
||||
|
||||
R.id.embeddedCheckServerButton.scrollAndClick()
|
||||
|
||||
with(R.id.server_status_text) {
|
||||
isDisplayed(true)
|
||||
assertVisibility(Visibility.VISIBLE)
|
||||
withText(R.string.auth_can_not_auth_against_server)
|
||||
}
|
||||
|
||||
verify(exactly = 0) { authenticationViewModel.getServerInfo(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkServerInfo_isError_qsferaVersionNotSupported() {
|
||||
launchTest()
|
||||
|
||||
serverInfoLiveData.postValue(Event(UIResult.Error(QSferaVersionNotSupportedException())))
|
||||
|
||||
with(R.id.server_status_text) {
|
||||
isDisplayed(true)
|
||||
assertVisibility(Visibility.VISIBLE)
|
||||
withText(R.string.server_not_supported)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkServerInfo_isError_noNetworkConnection() {
|
||||
launchTest()
|
||||
|
||||
serverInfoLiveData.postValue(Event(UIResult.Error(NoNetworkConnectionException())))
|
||||
|
||||
with(R.id.server_status_text) {
|
||||
isDisplayed(true)
|
||||
assertVisibility(Visibility.VISIBLE)
|
||||
withText(R.string.error_no_network_connection)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkServerInfo_isError_otherExceptions() {
|
||||
launchTest()
|
||||
|
||||
serverInfoLiveData.postValue(Event(UIResult.Error(ServerNotReachableException())))
|
||||
|
||||
with(R.id.server_status_text) {
|
||||
isDisplayed(true)
|
||||
assertVisibility(Visibility.VISIBLE)
|
||||
withText(R.string.network_host_not_available)
|
||||
}
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun loginBasic_callLoginBasic() {
|
||||
launchTest()
|
||||
|
||||
serverInfoLiveData.postValue(Event(UIResult.Success(SECURE_SERVER_INFO_BASIC)))
|
||||
|
||||
R.id.account_username.typeText(OC_BASIC_USERNAME)
|
||||
|
||||
R.id.account_password.typeText(OC_BASIC_PASSWORD)
|
||||
|
||||
with(R.id.loginButton) {
|
||||
isDisplayed(true)
|
||||
scrollAndClick()
|
||||
}
|
||||
|
||||
verify(exactly = 1) { authenticationViewModel.loginBasic(OC_BASIC_USERNAME, OC_BASIC_PASSWORD, null) }
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun loginBasic_callLoginBasic_trimUsername() {
|
||||
launchTest()
|
||||
|
||||
serverInfoLiveData.postValue(Event(UIResult.Success(SECURE_SERVER_INFO_BASIC)))
|
||||
|
||||
R.id.account_username.typeText(" $OC_BASIC_USERNAME ")
|
||||
|
||||
R.id.account_password.typeText(OC_BASIC_PASSWORD)
|
||||
|
||||
with(R.id.loginButton) {
|
||||
isDisplayed(true)
|
||||
scrollAndClick()
|
||||
}
|
||||
|
||||
verify(exactly = 1) { authenticationViewModel.loginBasic(OC_BASIC_USERNAME, OC_BASIC_PASSWORD, null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loginBasic_showOrHideFields() {
|
||||
launchTest()
|
||||
|
||||
serverInfoLiveData.postValue(Event(UIResult.Success(SECURE_SERVER_INFO_BASIC)))
|
||||
|
||||
R.id.account_username.typeText(OC_BASIC_USERNAME)
|
||||
|
||||
R.id.loginButton.isDisplayed(false)
|
||||
|
||||
R.id.account_password.typeText(OC_BASIC_PASSWORD)
|
||||
|
||||
R.id.loginButton.isDisplayed(true)
|
||||
|
||||
R.id.account_username.replaceText("")
|
||||
|
||||
R.id.loginButton.isDisplayed(false)
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
fun login_isLoading() {
|
||||
launchTest()
|
||||
|
||||
loginResultLiveData.postValue(Event(UIResult.Loading()))
|
||||
|
||||
with(R.id.auth_status_text) {
|
||||
withText(R.string.auth_trying_to_login)
|
||||
isDisplayed(true)
|
||||
assertVisibility(Visibility.VISIBLE)
|
||||
}
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun login_isSuccess_finishResultCode() {
|
||||
launchTest()
|
||||
|
||||
loginResultLiveData.postValue(Event(UIResult.Success(data = "Account_name")))
|
||||
accountDiscoveryLiveData.postValue(Event(UIResult.Success()))
|
||||
|
||||
assertEquals(activityScenario.result.resultCode, RESULT_OK)
|
||||
val accountName: String? = activityScenario.result?.resultData?.extras?.getString(KEY_ACCOUNT_NAME)
|
||||
val accountType: String? = activityScenario.result?.resultData?.extras?.getString(KEY_ACCOUNT_TYPE)
|
||||
|
||||
assertNotNull(accountName)
|
||||
assertNotNull(accountType)
|
||||
assertEquals("Account_name", accountName)
|
||||
assertEquals("qsfera", accountType)
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun login_isSuccess_finishResultCodeBrandedAccountType() {
|
||||
launchTest(accountType = "notQSfera")
|
||||
|
||||
loginResultLiveData.postValue(Event(UIResult.Success(data = "Account_name")))
|
||||
accountDiscoveryLiveData.postValue(Event(UIResult.Success()))
|
||||
|
||||
assertEquals(activityScenario.result.resultCode, RESULT_OK)
|
||||
val accountName: String? = activityScenario.result?.resultData?.extras?.getString(KEY_ACCOUNT_NAME)
|
||||
val accountType: String? = activityScenario.result?.resultData?.extras?.getString(KEY_ACCOUNT_TYPE)
|
||||
|
||||
assertNotNull(accountName)
|
||||
assertNotNull(accountType)
|
||||
assertEquals("Account_name", accountName)
|
||||
assertEquals("notQSfera", accountType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun login_isError_NoNetworkConnectionException() {
|
||||
launchTest()
|
||||
|
||||
loginResultLiveData.postValue(Event(UIResult.Error(NoNetworkConnectionException())))
|
||||
|
||||
R.id.server_status_text.withText(R.string.error_no_network_connection)
|
||||
|
||||
checkBasicFieldsVisibility(fieldsShouldBeVisible = false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun login_isError_ServerNotReachableException() {
|
||||
launchTest()
|
||||
|
||||
loginResultLiveData.postValue(Event(UIResult.Error(ServerNotReachableException())))
|
||||
|
||||
R.id.server_status_text.withText(R.string.error_no_network_connection)
|
||||
|
||||
checkBasicFieldsVisibility(fieldsShouldBeVisible = false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun login_isError_OtherException() {
|
||||
launchTest()
|
||||
|
||||
val exception = UnauthorizedException()
|
||||
|
||||
loginResultLiveData.postValue(Event(UIResult.Error(exception)))
|
||||
|
||||
R.id.auth_status_text.withText(exception.parseError("", context.resources, true) as String)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun intent_withSavedAccount_viewModelCalls() {
|
||||
val intentWithAccount = Intent(context, LoginActivity::class.java).apply {
|
||||
putExtra(EXTRA_ACCOUNT, OC_ACCOUNT)
|
||||
}
|
||||
|
||||
launchTest(intent = intentWithAccount)
|
||||
|
||||
verify(exactly = 1) { authenticationViewModel.supportsOAuth2(OC_ACCOUNT.name) }
|
||||
verify(exactly = 1) { authenticationViewModel.getBaseUrl(OC_ACCOUNT.name) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun supportsOAuth_isSuccess_actionUpdateExpiredTokenOAuth() {
|
||||
val intentWithAccount = Intent(context, LoginActivity::class.java).apply {
|
||||
putExtra(EXTRA_ACCOUNT, OC_ACCOUNT)
|
||||
putExtra(EXTRA_ACTION, ACTION_UPDATE_EXPIRED_TOKEN)
|
||||
putExtra(KEY_AUTH_TOKEN_TYPE, OAUTH_TOKEN_TYPE)
|
||||
}
|
||||
|
||||
launchTest(intent = intentWithAccount)
|
||||
|
||||
supportsOauth2LiveData.postValue(Event(UIResult.Success(true)))
|
||||
|
||||
with(R.id.instructions_message) {
|
||||
isDisplayed(true)
|
||||
assertVisibility(Visibility.VISIBLE)
|
||||
withText(context.getString(R.string.auth_expired_oauth_token_toast))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun supportsOAuth_isSuccess_actionUpdateToken() {
|
||||
val intentWithAccount = Intent(context, LoginActivity::class.java).apply {
|
||||
putExtra(EXTRA_ACCOUNT, OC_ACCOUNT)
|
||||
putExtra(EXTRA_ACTION, ACTION_UPDATE_TOKEN)
|
||||
putExtra(KEY_AUTH_TOKEN_TYPE, OC_AUTH_TOKEN_TYPE)
|
||||
}
|
||||
|
||||
launchTest(intent = intentWithAccount)
|
||||
|
||||
supportsOauth2LiveData.postValue(Event(UIResult.Success(false)))
|
||||
|
||||
R.id.instructions_message.assertVisibility(Visibility.GONE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun supportsOAuth_isSuccess_actionUpdateExpiredTokenBasic() {
|
||||
val intentWithAccount = Intent(context, LoginActivity::class.java).apply {
|
||||
putExtra(EXTRA_ACCOUNT, OC_ACCOUNT)
|
||||
putExtra(EXTRA_ACTION, ACTION_UPDATE_EXPIRED_TOKEN)
|
||||
putExtra(KEY_AUTH_TOKEN_TYPE, BASIC_TOKEN_TYPE)
|
||||
}
|
||||
|
||||
launchTest(intent = intentWithAccount)
|
||||
|
||||
supportsOauth2LiveData.postValue(Event(UIResult.Success(false)))
|
||||
|
||||
with(R.id.instructions_message) {
|
||||
isDisplayed(true)
|
||||
assertVisibility(Visibility.VISIBLE)
|
||||
withText(context.getString(R.string.auth_expired_basic_auth_toast))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getBaseUrl_isSuccess_updatesBaseUrl() {
|
||||
val intentWithAccount = Intent(context, LoginActivity::class.java).apply {
|
||||
putExtra(EXTRA_ACCOUNT, OC_ACCOUNT)
|
||||
}
|
||||
|
||||
launchTest(intent = intentWithAccount)
|
||||
|
||||
baseUrlLiveData.postValue(Event(UIResult.Success(OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl)))
|
||||
|
||||
with(R.id.hostUrlInput) {
|
||||
withText(OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl)
|
||||
assertVisibility(Visibility.VISIBLE)
|
||||
isDisplayed(true)
|
||||
isEnabled(false)
|
||||
isFocusable(false)
|
||||
}
|
||||
|
||||
verify(exactly = 0) { authenticationViewModel.getServerInfo(OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getBaseUrlAndActionNotCreate_isSuccess_updatesBaseUrl() {
|
||||
val intentWithAccount = Intent(context, LoginActivity::class.java).apply {
|
||||
putExtra(EXTRA_ACCOUNT, OC_ACCOUNT)
|
||||
putExtra(EXTRA_ACTION, ACTION_UPDATE_EXPIRED_TOKEN)
|
||||
}
|
||||
|
||||
launchTest(intent = intentWithAccount)
|
||||
|
||||
baseUrlLiveData.postValue(Event(UIResult.Success(OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl)))
|
||||
|
||||
with(R.id.hostUrlInput) {
|
||||
withText(OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl)
|
||||
assertVisibility(Visibility.VISIBLE)
|
||||
isDisplayed(true)
|
||||
isEnabled(false)
|
||||
isFocusable(false)
|
||||
}
|
||||
|
||||
verify(exactly = 1) { authenticationViewModel.getServerInfo(OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsLink() {
|
||||
Intents.init()
|
||||
launchTest()
|
||||
|
||||
closeSoftKeyboard()
|
||||
|
||||
mockIntentToComponent(RESULT_OK, SettingsActivity::class.java.name)
|
||||
onView(withId(R.id.settings_link)).perform(click())
|
||||
intended(hasComponent(SettingsActivity::class.java.name))
|
||||
|
||||
Intents.release()
|
||||
}
|
||||
|
||||
private fun avoidOpeningChromeCustomTab() {
|
||||
Intents.intending(allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW)))
|
||||
.respondWith(Instrumentation.ActivityResult(RESULT_OK, null))
|
||||
}
|
||||
|
||||
private fun checkBasicFieldsVisibility(
|
||||
fieldsShouldBeVisible: Boolean = true,
|
||||
loginButtonShouldBeVisible: Boolean = false
|
||||
) {
|
||||
val visibilityMatcherFields = if (fieldsShouldBeVisible) Visibility.VISIBLE else Visibility.GONE
|
||||
val visibilityMatcherLoginButton = if (loginButtonShouldBeVisible) Visibility.VISIBLE else Visibility.GONE
|
||||
|
||||
with(R.id.account_username) {
|
||||
isDisplayed(fieldsShouldBeVisible)
|
||||
assertVisibility(visibilityMatcherFields)
|
||||
withText("")
|
||||
}
|
||||
|
||||
with(R.id.account_password) {
|
||||
isDisplayed(fieldsShouldBeVisible)
|
||||
assertVisibility(visibilityMatcherFields)
|
||||
withText("")
|
||||
}
|
||||
|
||||
R.id.loginButton.assertVisibility(visibilityMatcherLoginButton)
|
||||
R.id.auth_status_text.assertVisibility(visibilityMatcherLoginButton)
|
||||
}
|
||||
|
||||
private fun checkBearerFieldsVisibility() {
|
||||
R.id.account_username.assertVisibility(Visibility.GONE)
|
||||
R.id.account_password.assertVisibility(Visibility.GONE)
|
||||
R.id.auth_status_text.assertVisibility(Visibility.GONE)
|
||||
|
||||
with(R.id.server_status_text) {
|
||||
isDisplayed(true)
|
||||
assertVisibility(Visibility.VISIBLE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertWebfingerFlowDisplayed(
|
||||
webfingerEnabled: Boolean,
|
||||
) {
|
||||
R.id.webfinger_layout.isDisplayed(webfingerEnabled)
|
||||
R.id.webfinger_username.isDisplayed(webfingerEnabled)
|
||||
R.id.webfinger_button.isDisplayed(webfingerEnabled)
|
||||
}
|
||||
|
||||
private fun assertViewsDisplayed(
|
||||
showLoginBackGroundImage: Boolean = true,
|
||||
showThumbnail: Boolean = true,
|
||||
showCenteredRefreshButton: Boolean = false,
|
||||
showInstructionsMessage: Boolean = false,
|
||||
showHostUrlFrame: Boolean = true,
|
||||
showHostUrlInput: Boolean = true,
|
||||
showEmbeddedCheckServerButton: Boolean = true,
|
||||
showEmbeddedRefreshButton: Boolean = false,
|
||||
showServerStatusText: Boolean = false,
|
||||
showAccountUsername: Boolean = false,
|
||||
showAccountPassword: Boolean = false,
|
||||
showAuthStatus: Boolean = false,
|
||||
showLoginButton: Boolean = false,
|
||||
showWelcomeLink: Boolean = true
|
||||
) {
|
||||
R.id.login_background_image.isDisplayed(displayed = showLoginBackGroundImage)
|
||||
R.id.thumbnail.isDisplayed(displayed = showThumbnail)
|
||||
R.id.centeredRefreshButton.isDisplayed(displayed = showCenteredRefreshButton)
|
||||
R.id.instructions_message.isDisplayed(displayed = showInstructionsMessage)
|
||||
R.id.hostUrlFrame.isDisplayed(displayed = showHostUrlFrame)
|
||||
R.id.hostUrlInput.isDisplayed(displayed = showHostUrlInput)
|
||||
R.id.embeddedCheckServerButton.isDisplayed(displayed = showEmbeddedCheckServerButton)
|
||||
R.id.embeddedRefreshButton.isDisplayed(displayed = showEmbeddedRefreshButton)
|
||||
R.id.server_status_text.isDisplayed(displayed = showServerStatusText)
|
||||
R.id.account_username_container.isDisplayed(displayed = showAccountUsername)
|
||||
R.id.account_username.isDisplayed(displayed = showAccountUsername)
|
||||
R.id.account_password_container.isDisplayed(displayed = showAccountPassword)
|
||||
R.id.account_password.isDisplayed(displayed = showAccountPassword)
|
||||
R.id.auth_status_text.isDisplayed(displayed = showAuthStatus)
|
||||
R.id.loginButton.isDisplayed(displayed = showLoginButton)
|
||||
R.id.welcome_link.isDisplayed(displayed = showWelcomeLink)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val SECURE_SERVER_INFO_BASIC = OC_SECURE_SERVER_INFO_BASIC_AUTH
|
||||
val INSECURE_SERVER_INFO_BASIC = OC_INSECURE_SERVER_INFO_BASIC_AUTH
|
||||
val SECURE_SERVER_INFO_BEARER = OC_SECURE_SERVER_INFO_BEARER_AUTH
|
||||
private const val CUSTOM_WELCOME_TEXT = "Welcome to this test"
|
||||
private const val BRANDED_APP_NAME = "BrandedAppName"
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Abel García de Prada
|
||||
*
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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 eu.qsfera.android.files
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.testing.FragmentScenario
|
||||
import androidx.fragment.app.testing.launchFragment
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.action.ViewActions
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.matcher.RootMatchers
|
||||
import androidx.test.espresso.matcher.ViewMatchers
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.presentation.files.SortBottomSheetFragment
|
||||
import eu.qsfera.android.presentation.files.SortOrder
|
||||
import eu.qsfera.android.presentation.files.SortType
|
||||
import eu.qsfera.android.utils.matchers.bsfItemWithIcon
|
||||
import eu.qsfera.android.utils.matchers.bsfItemWithTitle
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class SortBottomSheetFragmentTest {
|
||||
|
||||
private lateinit var fragmentScenario: FragmentScenario<SortBottomSheetFragment>
|
||||
private val fragmentListener = mockk<SortBottomSheetFragment.SortDialogListener>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val fragmentArgs = Bundle().apply {
|
||||
putParcelable(SortBottomSheetFragment.ARG_SORT_TYPE, SortType.SORT_TYPE_BY_NAME)
|
||||
putParcelable(SortBottomSheetFragment.ARG_SORT_ORDER, SortOrder.SORT_ORDER_ASCENDING)
|
||||
}
|
||||
fragmentScenario = launchFragment(fragmentArgs)
|
||||
every { fragmentListener.onSortSelected(any()) } returns Unit
|
||||
fragmentScenario.onFragment { it.sortDialogListener = fragmentListener }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_initial_view() {
|
||||
onView(withId(R.id.title))
|
||||
.inRoot(RootMatchers.isDialog())
|
||||
.check(matches(ViewMatchers.withText(R.string.actionbar_sort_title)))
|
||||
.check(matches(ViewMatchers.hasTextColor(R.color.bottom_sheet_fragment_title_color)))
|
||||
|
||||
with(R.id.sort_by_name) {
|
||||
bsfItemWithTitle(R.string.global_name, R.color.primary)
|
||||
bsfItemWithIcon(R.drawable.ic_sort_by_name, R.color.primary)
|
||||
}
|
||||
with(R.id.sort_by_size) {
|
||||
bsfItemWithTitle(R.string.global_size, R.color.bottom_sheet_fragment_item_color)
|
||||
bsfItemWithIcon(R.drawable.ic_sort_by_size, R.color.bottom_sheet_fragment_item_color)
|
||||
}
|
||||
with(R.id.sort_by_date) {
|
||||
bsfItemWithTitle(R.string.global_date, R.color.bottom_sheet_fragment_item_color)
|
||||
bsfItemWithIcon(R.drawable.ic_sort_by_date, R.color.bottom_sheet_fragment_item_color)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_sort_by_name_click() {
|
||||
onView(withId(R.id.sort_by_name)).inRoot(RootMatchers.isDialog()).perform(ViewActions.click())
|
||||
verify { fragmentListener.onSortSelected(SortType.SORT_TYPE_BY_NAME) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_sort_by_date_click() {
|
||||
onView(withId(R.id.sort_by_date)).inRoot(RootMatchers.isDialog()).perform(ViewActions.click())
|
||||
verify { fragmentListener.onSortSelected(SortType.SORT_TYPE_BY_DATE) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_sort_by_size_click() {
|
||||
onView(withId(R.id.sort_by_size)).inRoot(RootMatchers.isDialog()).perform(ViewActions.click())
|
||||
verify { fragmentListener.onSortSelected(SortType.SORT_TYPE_BY_SIZE) }
|
||||
}
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
package eu.qsfera.android.files.details
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ActivityScenario.launch
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.matcher.ViewMatchers
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.domain.files.model.OCFileWithSyncInfo
|
||||
import eu.qsfera.android.presentation.files.details.FileDetailsFragment
|
||||
import eu.qsfera.android.presentation.files.details.FileDetailsViewModel
|
||||
import eu.qsfera.android.presentation.files.operations.FileOperationsViewModel
|
||||
import eu.qsfera.android.sharing.shares.ui.TestShareFileActivity
|
||||
import eu.qsfera.android.testutil.OC_ACCOUNT
|
||||
import eu.qsfera.android.testutil.OC_FILE
|
||||
import eu.qsfera.android.testutil.OC_FILE_WITH_SYNC_INFO_AVAILABLE_OFFLINE
|
||||
import eu.qsfera.android.testutil.OC_FILE_WITH_SYNC_INFO
|
||||
import eu.qsfera.android.testutil.OC_FILE_WITH_SYNC_INFO_AND_SPACE
|
||||
import eu.qsfera.android.testutil.OC_FILE_WITH_SYNC_INFO_AND_WITHOUT_PERSONAL_SPACE
|
||||
import eu.qsfera.android.utils.DisplayUtils
|
||||
import eu.qsfera.android.utils.matchers.assertVisibility
|
||||
import eu.qsfera.android.utils.matchers.isDisplayed
|
||||
import eu.qsfera.android.utils.matchers.withDrawable
|
||||
import eu.qsfera.android.utils.matchers.withText
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.koin.dsl.module
|
||||
|
||||
@Ignore
|
||||
class FileDetailsFragmentTest {
|
||||
|
||||
private lateinit var fileDetailsViewModel: FileDetailsViewModel
|
||||
private lateinit var fileOperationsViewModel: FileOperationsViewModel
|
||||
private lateinit var context: Context
|
||||
|
||||
private var currentFile: MutableStateFlow<OCFileWithSyncInfo?> = MutableStateFlow(OC_FILE_WITH_SYNC_INFO_AND_SPACE)
|
||||
private var currentFileWithoutPersonalSpace: MutableStateFlow<OCFileWithSyncInfo?> =
|
||||
MutableStateFlow(OC_FILE_WITH_SYNC_INFO_AND_WITHOUT_PERSONAL_SPACE)
|
||||
private var currentFileSyncInfo: MutableStateFlow<OCFileWithSyncInfo?> = MutableStateFlow(OC_FILE_WITH_SYNC_INFO)
|
||||
private var currentFileAvailableOffline: MutableStateFlow<OCFileWithSyncInfo?> = MutableStateFlow(OC_FILE_WITH_SYNC_INFO_AVAILABLE_OFFLINE)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
fileDetailsViewModel = mockk(relaxed = true)
|
||||
fileOperationsViewModel = mockk(relaxed = true)
|
||||
every { fileDetailsViewModel.currentFile } returns currentFile
|
||||
stopKoin()
|
||||
|
||||
startKoin {
|
||||
context
|
||||
allowOverride(override = true)
|
||||
modules(
|
||||
module {
|
||||
viewModel {
|
||||
fileDetailsViewModel
|
||||
}
|
||||
viewModel {
|
||||
fileOperationsViewModel
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
val fileDetailsFragment = FileDetailsFragment.newInstance(
|
||||
OC_FILE,
|
||||
OC_ACCOUNT,
|
||||
syncFileAtOpen = false
|
||||
)
|
||||
|
||||
launch(TestShareFileActivity::class.java).onActivity {
|
||||
it.startFragment(fileDetailsFragment)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun display_visibility_of_detail_view_when_it_is_displayed() {
|
||||
assertViewsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun show_space_personal_when_it_has_value() {
|
||||
R.id.fdSpace.assertVisibility(ViewMatchers.Visibility.VISIBLE)
|
||||
R.id.fdSpaceLabel.assertVisibility(ViewMatchers.Visibility.VISIBLE)
|
||||
R.id.fdIconSpace.assertVisibility(ViewMatchers.Visibility.VISIBLE)
|
||||
|
||||
R.id.fdSpace.withText(R.string.bottom_nav_personal)
|
||||
R.id.fdSpaceLabel.withText(R.string.space_label)
|
||||
onView(withId(R.id.fdIconSpace))
|
||||
.check(matches(withDrawable(R.drawable.ic_spaces)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hide_space_when_it_has_no_value() {
|
||||
every { fileDetailsViewModel.currentFile } returns currentFileSyncInfo
|
||||
|
||||
R.id.fdSpace.assertVisibility(ViewMatchers.Visibility.GONE)
|
||||
R.id.fdSpaceLabel.assertVisibility(ViewMatchers.Visibility.GONE)
|
||||
R.id.fdIconSpace.assertVisibility(ViewMatchers.Visibility.GONE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun show_space_not_personal_when_it_has_value() {
|
||||
every { fileDetailsViewModel.currentFile } returns currentFileWithoutPersonalSpace
|
||||
|
||||
R.id.fdSpace.assertVisibility(ViewMatchers.Visibility.VISIBLE)
|
||||
R.id.fdSpaceLabel.assertVisibility(ViewMatchers.Visibility.VISIBLE)
|
||||
R.id.fdIconSpace.assertVisibility(ViewMatchers.Visibility.VISIBLE)
|
||||
|
||||
R.id.fdSpace.withText(currentFileWithoutPersonalSpace.value?.space?.name.toString())
|
||||
R.id.fdSpaceLabel.withText(R.string.space_label)
|
||||
onView(withId(R.id.fdIconSpace))
|
||||
.check(matches(withDrawable(R.drawable.ic_spaces)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun show_last_sync_when_it_has_value() {
|
||||
currentFile.value?.file?.lastSyncDateForData = 1212121212212
|
||||
R.id.fdLastSync.assertVisibility(ViewMatchers.Visibility.VISIBLE)
|
||||
R.id.fdLastSyncLabel.assertVisibility(ViewMatchers.Visibility.VISIBLE)
|
||||
|
||||
R.id.fdLastSyncLabel.withText(R.string.filedetails_last_sync)
|
||||
R.id.fdLastSync.withText(DisplayUtils.unixTimeToHumanReadable(currentFile.value?.file?.lastSyncDateForData!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hide_last_sync_when_it_has_no_value() {
|
||||
every { fileDetailsViewModel.currentFile } returns currentFile
|
||||
|
||||
R.id.fdLastSync.assertVisibility(ViewMatchers.Visibility.GONE)
|
||||
R.id.fdLastSyncLabel.assertVisibility(ViewMatchers.Visibility.GONE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verifyTests() {
|
||||
R.id.fdCreatedLabel.withText(R.string.filedetails_created)
|
||||
R.id.fdCreated.withText(DisplayUtils.unixTimeToHumanReadable(currentFile.value?.file?.creationTimestamp!!))
|
||||
|
||||
R.id.fdModifiedLabel.withText(R.string.filedetails_modified)
|
||||
R.id.fdModified.withText(DisplayUtils.unixTimeToHumanReadable(currentFile.value?.file?.modificationTimestamp!!))
|
||||
|
||||
R.id.fdPathLabel.withText(R.string.ssl_validator_label_L)
|
||||
R.id.fdPath.withText(currentFile.value?.file?.getParentRemotePath()!!)
|
||||
|
||||
R.id.fdname.withText(currentFile.value?.file?.fileName!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun badge_available_offline_in_image_is_not_viewed_when_file_does_not_change_state() {
|
||||
every { fileDetailsViewModel.currentFile } returns currentFileAvailableOffline
|
||||
|
||||
R.id.badgeDetailFile.assertVisibility(ViewMatchers.Visibility.VISIBLE)
|
||||
onView(withId(R.id.badgeDetailFile))
|
||||
.check(matches(withDrawable(R.drawable.offline_available_pin)))
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
fun show_badge_isAvailableLocally_in_image_when_file_change_state() {
|
||||
currentFile.value?.file?.etagInConflict = "error"
|
||||
|
||||
R.id.badgeDetailFile.assertVisibility(ViewMatchers.Visibility.VISIBLE)
|
||||
onView(withId(R.id.badgeDetailFile))
|
||||
.check(matches(withDrawable(R.drawable.error_pin)))
|
||||
}
|
||||
|
||||
private fun assertViewsDisplayed(
|
||||
showImage: Boolean = true,
|
||||
showFdName: Boolean = true,
|
||||
showFdProgressText: Boolean = false,
|
||||
showFdProgressBar: Boolean = false,
|
||||
showFdCancelBtn: Boolean = false,
|
||||
showDivider: Boolean = true,
|
||||
showDivider2: Boolean = true,
|
||||
showFdTypeLabel: Boolean = true,
|
||||
showFdType: Boolean = true,
|
||||
showFdSizeLabel: Boolean = true,
|
||||
showFdSize: Boolean = true,
|
||||
showFdModifiedLabel: Boolean = true,
|
||||
showFdModified: Boolean = true,
|
||||
showFdCreatedLabel: Boolean = true,
|
||||
showFdCreated: Boolean = true,
|
||||
showDivider3: Boolean = true,
|
||||
showFdPathLabel: Boolean = true,
|
||||
showFdPath: Boolean = true
|
||||
) {
|
||||
R.id.fdImageDetailFile.isDisplayed(displayed = showImage)
|
||||
R.id.fdname.isDisplayed(displayed = showFdName)
|
||||
R.id.fdProgressText.isDisplayed(displayed = showFdProgressText)
|
||||
R.id.fdProgressBar.isDisplayed(displayed = showFdProgressBar)
|
||||
R.id.fdCancelBtn.isDisplayed(displayed = showFdCancelBtn)
|
||||
R.id.divider.isDisplayed(displayed = showDivider)
|
||||
R.id.fdTypeLabel.isDisplayed(displayed = showFdTypeLabel)
|
||||
R.id.fdType.isDisplayed(displayed = showFdType)
|
||||
R.id.fdSizeLabel.isDisplayed(displayed = showFdSizeLabel)
|
||||
R.id.fdSize.isDisplayed(displayed = showFdSize)
|
||||
R.id.divider2.isDisplayed(displayed = showDivider2)
|
||||
R.id.fdModifiedLabel.isDisplayed(displayed = showFdModifiedLabel)
|
||||
R.id.fdModified.isDisplayed(displayed = showFdModified)
|
||||
R.id.fdCreatedLabel.isDisplayed(displayed = showFdCreatedLabel)
|
||||
R.id.fdCreated.isDisplayed(displayed = showFdCreated)
|
||||
R.id.divider3.isDisplayed(displayed = showDivider3)
|
||||
R.id.fdPathLabel.isDisplayed(displayed = showFdPathLabel)
|
||||
R.id.fdPath.isDisplayed(displayed = showFdPath)
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Fernando Sanz Velasco
|
||||
* Copyright (C) 2021 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.logging
|
||||
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.presentation.logging.LogsListActivity
|
||||
import eu.qsfera.android.presentation.logging.LogListViewModel
|
||||
import eu.qsfera.android.utils.matchers.assertChildCount
|
||||
import eu.qsfera.android.utils.matchers.isDisplayed
|
||||
import eu.qsfera.android.utils.matchers.withText
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.unmockkAll
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.koin.dsl.module
|
||||
import java.io.File
|
||||
|
||||
@Ignore
|
||||
class LogsListActivityTest {
|
||||
|
||||
private lateinit var activityScenario: ActivityScenario<LogsListActivity>
|
||||
|
||||
private lateinit var logListViewModel: LogListViewModel
|
||||
|
||||
private fun launchTest(logs: List<File>) {
|
||||
every { logListViewModel.getLogsFiles() } returns logs
|
||||
activityScenario = ActivityScenario.launch(LogsListActivity::class.java)
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
logListViewModel = mockk(relaxed = true)
|
||||
|
||||
stopKoin()
|
||||
|
||||
startKoin {
|
||||
allowOverride(override = true)
|
||||
modules(
|
||||
module {
|
||||
viewModel {
|
||||
logListViewModel
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_visibility_toolbar() {
|
||||
launchTest(logs = emptyList())
|
||||
R.id.toolbar_activity_logs_list.isDisplayed(true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_isRecyclerViewEmpty_show_label() {
|
||||
launchTest(logs = emptyList())
|
||||
R.id.logs_list_empty.isDisplayed(true)
|
||||
R.id.list_empty_dataset_title.withText(R.string.prefs_log_no_logs_list_view)
|
||||
R.id.list_empty_dataset_sub_title.withText(R.string.prefs_log_empty_subtitle)
|
||||
R.id.recyclerView_activity_logs_list.isDisplayed(false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_isRecyclerViewNotEmpty_hide_label() {
|
||||
launchTest(logs = listOf(File("path")))
|
||||
R.id.logs_list_empty.isDisplayed(false)
|
||||
R.id.recyclerView_activity_logs_list.isDisplayed(true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_childCount() {
|
||||
launchTest(logs = listOf(File("qsfera.2021-01.01.log"), File("qsfera.2021-01-02.log")))
|
||||
R.id.recyclerView_activity_logs_list.assertChildCount(2)
|
||||
}
|
||||
}
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
*
|
||||
* Copyright (C) 2021 ownCloud GmbH.
|
||||
* <p>
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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 eu.qsfera.android.settings
|
||||
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import androidx.fragment.app.testing.FragmentScenario
|
||||
import androidx.fragment.app.testing.launchFragmentInContainer
|
||||
import androidx.preference.Preference
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.action.ViewActions.click
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.intent.Intents
|
||||
import androidx.test.espresso.intent.matcher.IntentMatchers
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import eu.qsfera.android.BuildConfig
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.presentation.releasenotes.ReleaseNotesActivity
|
||||
import eu.qsfera.android.presentation.settings.privacypolicy.PrivacyPolicyActivity
|
||||
import eu.qsfera.android.presentation.settings.SettingsFragment
|
||||
import eu.qsfera.android.presentation.releasenotes.ReleaseNotesViewModel
|
||||
import eu.qsfera.android.presentation.settings.more.SettingsMoreViewModel
|
||||
import eu.qsfera.android.presentation.settings.SettingsViewModel
|
||||
import eu.qsfera.android.utils.matchers.verifyPreference
|
||||
import eu.qsfera.android.utils.releaseNotesList
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.unmockkAll
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.koin.dsl.module
|
||||
|
||||
class SettingsFragmentTest {
|
||||
|
||||
private lateinit var fragmentScenario: FragmentScenario<SettingsFragment>
|
||||
|
||||
private var subsectionSecurity: Preference? = null
|
||||
private var subsectionLogging: Preference? = null
|
||||
private var subsectionPictureUploads: Preference? = null
|
||||
private var subsectionVideoUploads: Preference? = null
|
||||
private var subsectionMore: Preference? = null
|
||||
private var prefPrivacyPolicy: Preference? = null
|
||||
private var subsectionWhatsNew: Preference? = null
|
||||
private var prefAboutApp: Preference? = null
|
||||
|
||||
private lateinit var settingsViewModel: SettingsViewModel
|
||||
private lateinit var moreViewModel: SettingsMoreViewModel
|
||||
private lateinit var releaseNotesViewModel: ReleaseNotesViewModel
|
||||
private lateinit var context: Context
|
||||
|
||||
private lateinit var version: String
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
settingsViewModel = mockk(relaxed = true)
|
||||
moreViewModel = mockk(relaxed = true)
|
||||
releaseNotesViewModel = mockk(relaxed = true)
|
||||
|
||||
stopKoin()
|
||||
|
||||
startKoin {
|
||||
context
|
||||
allowOverride(override = true)
|
||||
modules(
|
||||
module {
|
||||
viewModel {
|
||||
settingsViewModel
|
||||
}
|
||||
viewModel {
|
||||
moreViewModel
|
||||
}
|
||||
viewModel {
|
||||
releaseNotesViewModel
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
version = String.format(
|
||||
context.getString(R.string.prefs_app_version_summary),
|
||||
context.getString(R.string.app_name),
|
||||
BuildConfig.BUILD_TYPE,
|
||||
BuildConfig.VERSION_NAME,
|
||||
BuildConfig.COMMIT_SHA1
|
||||
)
|
||||
|
||||
Intents.init()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Intents.release()
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
private fun launchTest(
|
||||
attachedAccount: Boolean,
|
||||
moreSectionVisible: Boolean = true,
|
||||
privacyPolicyEnabled: Boolean = true,
|
||||
whatsNewSectionVisible: Boolean = true
|
||||
) {
|
||||
every { settingsViewModel.isThereAttachedAccount() } returns attachedAccount
|
||||
every { moreViewModel.shouldMoreSectionBeVisible() } returns moreSectionVisible
|
||||
every { moreViewModel.isPrivacyPolicyEnabled() } returns privacyPolicyEnabled
|
||||
every { releaseNotesViewModel.shouldWhatsNewSectionBeVisible() } returns whatsNewSectionVisible
|
||||
|
||||
fragmentScenario = launchFragmentInContainer(themeResId = R.style.Theme_qsfera)
|
||||
fragmentScenario.onFragment { fragment ->
|
||||
subsectionSecurity = fragment.findPreference(SUBSECTION_SECURITY)
|
||||
subsectionLogging = fragment.findPreference(SUBSECTION_LOGGING)
|
||||
subsectionPictureUploads = fragment.findPreference(SUBSECTION_PICTURE_UPLOADS)
|
||||
subsectionVideoUploads = fragment.findPreference(SUBSECTION_VIDEO_UPLOADS)
|
||||
subsectionMore = fragment.findPreference(SUBSECTION_MORE)
|
||||
prefPrivacyPolicy = fragment.findPreference(PREFERENCE_PRIVACY_POLICY)
|
||||
subsectionWhatsNew = fragment.findPreference(SUBSECTION_WHATSNEW)
|
||||
prefAboutApp = fragment.findPreference(PREFERENCE_ABOUT_APP)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsViewCommon() {
|
||||
launchTest(attachedAccount = false)
|
||||
|
||||
subsectionSecurity?.verifyPreference(
|
||||
keyPref = SUBSECTION_SECURITY,
|
||||
titlePref = context.getString(R.string.prefs_subsection_security),
|
||||
summaryPref = context.getString(R.string.prefs_subsection_security_summary),
|
||||
visible = true,
|
||||
enabled = true
|
||||
)
|
||||
|
||||
subsectionLogging?.verifyPreference(
|
||||
keyPref = SUBSECTION_LOGGING,
|
||||
titlePref = context.getString(R.string.prefs_subsection_logging),
|
||||
summaryPref = context.getString(R.string.prefs_subsection_logging_summary),
|
||||
visible = true,
|
||||
enabled = true
|
||||
)
|
||||
|
||||
subsectionMore?.verifyPreference(
|
||||
keyPref = SUBSECTION_MORE,
|
||||
titlePref = context.getString(R.string.prefs_subsection_more),
|
||||
summaryPref = context.getString(R.string.prefs_subsection_more_summary),
|
||||
visible = true,
|
||||
enabled = true
|
||||
)
|
||||
|
||||
prefPrivacyPolicy?.verifyPreference(
|
||||
keyPref = PREFERENCE_PRIVACY_POLICY,
|
||||
titlePref = context.getString(R.string.prefs_privacy_policy),
|
||||
visible = true,
|
||||
enabled = true
|
||||
)
|
||||
|
||||
subsectionWhatsNew?.verifyPreference(
|
||||
keyPref = SUBSECTION_WHATSNEW,
|
||||
titlePref = context.getString(R.string.prefs_subsection_whatsnew),
|
||||
visible = true,
|
||||
enabled = true
|
||||
)
|
||||
|
||||
prefAboutApp?.verifyPreference(
|
||||
keyPref = PREFERENCE_ABOUT_APP,
|
||||
titlePref = context.getString(R.string.prefs_app_version),
|
||||
summaryPref = version,
|
||||
visible = true,
|
||||
enabled = true
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsViewNoAccountAttached() {
|
||||
launchTest(attachedAccount = false)
|
||||
|
||||
subsectionPictureUploads?.verifyPreference(
|
||||
keyPref = SUBSECTION_PICTURE_UPLOADS,
|
||||
titlePref = context.getString(R.string.prefs_subsection_picture_uploads),
|
||||
summaryPref = context.getString(R.string.prefs_subsection_picture_uploads_summary),
|
||||
visible = false
|
||||
)
|
||||
|
||||
subsectionVideoUploads?.verifyPreference(
|
||||
keyPref = SUBSECTION_VIDEO_UPLOADS,
|
||||
titlePref = context.getString(R.string.prefs_subsection_video_uploads),
|
||||
summaryPref = context.getString(R.string.prefs_subsection_video_uploads_summary),
|
||||
visible = false
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsViewAccountAttached() {
|
||||
launchTest(attachedAccount = true)
|
||||
|
||||
subsectionPictureUploads?.verifyPreference(
|
||||
keyPref = SUBSECTION_PICTURE_UPLOADS,
|
||||
titlePref = context.getString(R.string.prefs_subsection_picture_uploads),
|
||||
summaryPref = context.getString(R.string.prefs_subsection_picture_uploads_summary),
|
||||
visible = true,
|
||||
enabled = true
|
||||
)
|
||||
|
||||
subsectionVideoUploads?.verifyPreference(
|
||||
keyPref = SUBSECTION_VIDEO_UPLOADS,
|
||||
titlePref = context.getString(R.string.prefs_subsection_video_uploads),
|
||||
summaryPref = context.getString(R.string.prefs_subsection_video_uploads_summary),
|
||||
visible = true,
|
||||
enabled = true
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsMoreSectionHidden() {
|
||||
launchTest(attachedAccount = false, moreSectionVisible = false)
|
||||
|
||||
subsectionMore?.verifyPreference(
|
||||
keyPref = SUBSECTION_MORE,
|
||||
titlePref = context.getString(R.string.prefs_subsection_more),
|
||||
summaryPref = context.getString(R.string.prefs_subsection_more_summary),
|
||||
visible = false
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsWhatsNewSectionHidden() {
|
||||
launchTest(attachedAccount = false, whatsNewSectionVisible = false)
|
||||
|
||||
subsectionWhatsNew?.verifyPreference(
|
||||
keyPref = SUBSECTION_WHATSNEW,
|
||||
titlePref = context.getString(R.string.prefs_subsection_whatsnew),
|
||||
visible = false
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun privacyPolicyOpensPrivacyPolicyActivity() {
|
||||
launchTest(attachedAccount = false)
|
||||
|
||||
onView(withText(R.string.prefs_privacy_policy)).perform(click())
|
||||
Intents.intended(IntentMatchers.hasComponent(PrivacyPolicyActivity::class.java.name))
|
||||
}
|
||||
|
||||
@Ignore("Flaky test")
|
||||
@Test
|
||||
fun whatsnewOpensReleaseNotesActivity() {
|
||||
launchTest(attachedAccount = false)
|
||||
every { releaseNotesViewModel.getReleaseNotes() } returns releaseNotesList
|
||||
|
||||
onView(withText(R.string.prefs_subsection_whatsnew)).perform(click())
|
||||
Intents.intended(IntentMatchers.hasComponent(ReleaseNotesActivity::class.java.name))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clickOnAppVersion() {
|
||||
launchTest(attachedAccount = false)
|
||||
|
||||
onView(withText(R.string.prefs_app_version)).perform(click())
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager?
|
||||
|
||||
onView(withText(R.string.clipboard_text_copied)).check(matches(isEnabled()))
|
||||
assertEquals(version, clipboard?.primaryClip?.getItemAt(0)?.coerceToText(context))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SUBSECTION_SECURITY = "security_subsection"
|
||||
private const val SUBSECTION_LOGGING = "logging_subsection"
|
||||
private const val SUBSECTION_PICTURE_UPLOADS = "picture_uploads_subsection"
|
||||
private const val SUBSECTION_VIDEO_UPLOADS = "video_uploads_subsection"
|
||||
private const val SUBSECTION_MORE = "more_subsection"
|
||||
private const val PREFERENCE_PRIVACY_POLICY = "privacyPolicy"
|
||||
private const val PREFERENCE_ABOUT_APP = "about_app"
|
||||
private const val SUBSECTION_WHATSNEW = "whatsNew"
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David Crespo Ríos
|
||||
* Copyright (C) 2022 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.settings.advanced
|
||||
|
||||
import android.content.Context
|
||||
import androidx.fragment.app.testing.FragmentScenario
|
||||
import androidx.fragment.app.testing.launchFragmentInContainer
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.action.ViewActions.click
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.presentation.settings.advanced.SettingsAdvancedFragment
|
||||
import eu.qsfera.android.presentation.settings.advanced.SettingsAdvancedFragment.Companion.PREF_SHOW_HIDDEN_FILES
|
||||
import eu.qsfera.android.presentation.settings.advanced.SettingsAdvancedViewModel
|
||||
import eu.qsfera.android.utils.matchers.verifyPreference
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.unmockkAll
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.koin.dsl.module
|
||||
|
||||
class SettingsAdvancedFragmentTest {
|
||||
|
||||
private lateinit var fragmentScenario: FragmentScenario<SettingsAdvancedFragment>
|
||||
|
||||
private var prefShowHiddenFiles: SwitchPreferenceCompat? = null
|
||||
|
||||
private lateinit var advancedViewModel: SettingsAdvancedViewModel
|
||||
private lateinit var context: Context
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
advancedViewModel = mockk(relaxed = true)
|
||||
|
||||
stopKoin()
|
||||
|
||||
startKoin {
|
||||
context
|
||||
allowOverride(override = true)
|
||||
modules(
|
||||
module {
|
||||
viewModel {
|
||||
advancedViewModel
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
every { advancedViewModel.isHiddenFilesShown() } returns true
|
||||
|
||||
fragmentScenario = launchFragmentInContainer(themeResId = R.style.Theme_qsfera)
|
||||
|
||||
fragmentScenario.onFragment { fragment ->
|
||||
prefShowHiddenFiles = fragment.findPreference(PREF_SHOW_HIDDEN_FILES)
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advancedView() {
|
||||
assertNotNull(prefShowHiddenFiles)
|
||||
prefShowHiddenFiles?.verifyPreference(
|
||||
keyPref = PREF_SHOW_HIDDEN_FILES,
|
||||
titlePref = context.getString(R.string.prefs_show_hidden_files),
|
||||
visible = true,
|
||||
enabled = true
|
||||
)
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun disableShowHiddenFiles() {
|
||||
prefShowHiddenFiles?.isChecked = advancedViewModel.isHiddenFilesShown()
|
||||
|
||||
onView(withText(context.getString(R.string.prefs_show_hidden_files))).perform(click())
|
||||
|
||||
prefShowHiddenFiles?.isChecked?.let { assertFalse(it) }
|
||||
}
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
*
|
||||
* Copyright (C) 2021 ownCloud GmbH.
|
||||
* <p>
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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 eu.qsfera.android.settings.logs
|
||||
|
||||
import android.content.Context
|
||||
import androidx.fragment.app.testing.FragmentScenario
|
||||
import androidx.fragment.app.testing.launchFragmentInContainer
|
||||
import androidx.preference.CheckBoxPreference
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.action.ViewActions.click
|
||||
import androidx.test.espresso.intent.Intents
|
||||
import androidx.test.espresso.intent.Intents.intended
|
||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.presentation.logging.LogsListActivity
|
||||
import eu.qsfera.android.presentation.settings.logging.SettingsLogsFragment
|
||||
import eu.qsfera.android.presentation.logging.LogListViewModel
|
||||
import eu.qsfera.android.presentation.settings.logging.SettingsLogsViewModel
|
||||
import eu.qsfera.android.utils.matchers.verifyPreference
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.unmockkAll
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.koin.dsl.module
|
||||
|
||||
class SettingsLogsFragmentTest {
|
||||
|
||||
private lateinit var fragmentScenario: FragmentScenario<SettingsLogsFragment>
|
||||
|
||||
private lateinit var prefEnableLogging: SwitchPreferenceCompat
|
||||
private lateinit var prefHttpLogs: CheckBoxPreference
|
||||
private lateinit var prefLogsListActivity: Preference
|
||||
|
||||
private lateinit var logsViewModel: SettingsLogsViewModel
|
||||
private lateinit var logListViewModel: LogListViewModel
|
||||
private lateinit var context: Context
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
logsViewModel = mockk(relaxed = true)
|
||||
logListViewModel = mockk(relaxed = true)
|
||||
|
||||
stopKoin()
|
||||
|
||||
startKoin {
|
||||
context
|
||||
allowOverride(override = true)
|
||||
modules(
|
||||
module {
|
||||
viewModel {
|
||||
logsViewModel
|
||||
}
|
||||
viewModel {
|
||||
logListViewModel
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Intents.init()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Intents.release()
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit()
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
private fun launchTest(enabledLogging: Boolean) {
|
||||
every { logsViewModel.isLoggingEnabled() } returns enabledLogging
|
||||
|
||||
fragmentScenario = launchFragmentInContainer(themeResId = R.style.Theme_qsfera)
|
||||
fragmentScenario.onFragment { fragment ->
|
||||
prefEnableLogging = fragment.findPreference(SettingsLogsFragment.PREFERENCE_ENABLE_LOGGING)!!
|
||||
prefHttpLogs = fragment.findPreference(SettingsLogsFragment.PREFERENCE_LOG_HTTP)!!
|
||||
prefLogsListActivity = fragment.findPreference(SettingsLogsFragment.PREFERENCE_LOGS_LIST)!!
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun logsViewLoggingDisabled() {
|
||||
launchTest(enabledLogging = false)
|
||||
|
||||
prefEnableLogging.verifyPreference(
|
||||
keyPref = SettingsLogsFragment.PREFERENCE_ENABLE_LOGGING,
|
||||
titlePref = context.getString(R.string.prefs_enable_logging),
|
||||
summaryPref = context.getString(R.string.prefs_enable_logging_summary),
|
||||
visible = true,
|
||||
enabled = true
|
||||
)
|
||||
prefHttpLogs.verifyPreference(
|
||||
keyPref = SettingsLogsFragment.PREFERENCE_LOG_HTTP,
|
||||
titlePref = context.getString(R.string.prefs_http_logs),
|
||||
visible = true,
|
||||
enabled = false
|
||||
)
|
||||
|
||||
prefLogsListActivity.verifyPreference(
|
||||
keyPref = SettingsLogsFragment.PREFERENCE_LOGS_LIST,
|
||||
titlePref = context.getString(R.string.prefs_log_open_logs_list_view),
|
||||
visible = true,
|
||||
enabled = true,
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
fun logsViewLoggingEnabled() {
|
||||
launchTest(enabledLogging = true)
|
||||
|
||||
prefEnableLogging.verifyPreference(
|
||||
keyPref = SettingsLogsFragment.PREFERENCE_ENABLE_LOGGING,
|
||||
titlePref = context.getString(R.string.prefs_enable_logging),
|
||||
summaryPref = context.getString(R.string.prefs_enable_logging_summary),
|
||||
visible = true,
|
||||
enabled = true
|
||||
)
|
||||
prefHttpLogs.verifyPreference(
|
||||
keyPref = SettingsLogsFragment.PREFERENCE_LOG_HTTP,
|
||||
titlePref = context.getString(R.string.prefs_http_logs),
|
||||
visible = true,
|
||||
enabled = true
|
||||
)
|
||||
|
||||
prefLogsListActivity.verifyPreference(
|
||||
keyPref = SettingsLogsFragment.PREFERENCE_LOGS_LIST,
|
||||
titlePref = context.getString(R.string.prefs_log_open_logs_list_view),
|
||||
visible = true,
|
||||
enabled = true,
|
||||
)
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun enableLoggingMakesSettingsEnable() {
|
||||
launchTest(enabledLogging = false)
|
||||
|
||||
onView(withText(R.string.prefs_enable_logging)).perform(click())
|
||||
assertTrue(prefHttpLogs.isEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disableLoggingMakesSettingsDisable() {
|
||||
launchTest(enabledLogging = false)
|
||||
|
||||
onView(withText(R.string.prefs_enable_logging)).perform(click())
|
||||
onView(withText(R.string.prefs_enable_logging)).perform(click())
|
||||
assertFalse(prefHttpLogs.isEnabled)
|
||||
assertTrue(prefLogsListActivity.isEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkHttpLogs() {
|
||||
launchTest(enabledLogging = true)
|
||||
onView(withText(R.string.prefs_http_logs)).perform(click())
|
||||
assertTrue(prefHttpLogs.isChecked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disableLoggingMakesHttpLogsNotChecked() {
|
||||
launchTest(enabledLogging = false)
|
||||
|
||||
onView(withText(R.string.prefs_enable_logging)).perform(click())
|
||||
onView(withText(R.string.prefs_http_logs)).perform(click())
|
||||
onView(withText(R.string.prefs_enable_logging)).perform(click())
|
||||
assertFalse(prefHttpLogs.isChecked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loggerOpen() {
|
||||
launchTest(enabledLogging = true)
|
||||
|
||||
onView(withText(R.string.prefs_log_open_logs_list_view)).perform(click())
|
||||
intended(hasComponent(LogsListActivity::class.java.name))
|
||||
}
|
||||
}
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
*
|
||||
* Copyright (C) 2023 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.settings.more
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Intent.EXTRA_SUBJECT
|
||||
import android.content.Intent.EXTRA_TEXT
|
||||
import android.net.Uri
|
||||
import androidx.fragment.app.testing.FragmentScenario
|
||||
import androidx.fragment.app.testing.launchFragmentInContainer
|
||||
import androidx.preference.Preference
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.action.ViewActions.click
|
||||
import androidx.test.espresso.assertion.ViewAssertions
|
||||
import androidx.test.espresso.intent.Intents
|
||||
import androidx.test.espresso.intent.Intents.intended
|
||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
|
||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasData
|
||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra
|
||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasFlag
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import eu.qsfera.android.BuildConfig
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.presentation.settings.more.SettingsMoreFragment
|
||||
import eu.qsfera.android.presentation.settings.more.SettingsMoreViewModel
|
||||
import eu.qsfera.android.utils.matchers.verifyPreference
|
||||
import eu.qsfera.android.utils.mockIntent
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.unmockkAll
|
||||
import org.hamcrest.Matchers.allOf
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.koin.dsl.module
|
||||
|
||||
class SettingsMoreFragmentTest {
|
||||
|
||||
private lateinit var fragmentScenario: FragmentScenario<SettingsMoreFragment>
|
||||
|
||||
private var prefHelp: Preference? = null
|
||||
private var prefSync: Preference? = null
|
||||
private var prefAccessDocProvider: Preference? = null
|
||||
private var prefRecommend: Preference? = null
|
||||
private var prefFeedback: Preference? = null
|
||||
private var prefImprint: Preference? = null
|
||||
|
||||
private lateinit var moreViewModel: SettingsMoreViewModel
|
||||
private lateinit var context: Context
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
moreViewModel = mockk(relaxed = true)
|
||||
|
||||
stopKoin()
|
||||
|
||||
startKoin {
|
||||
context
|
||||
allowOverride(override = true)
|
||||
modules(
|
||||
module {
|
||||
viewModel {
|
||||
moreViewModel
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Intents.init()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Intents.release()
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
private fun getPreference(key: String): Preference? {
|
||||
var preference: Preference? = null
|
||||
fragmentScenario.onFragment { fragment ->
|
||||
preference = fragment.findPreference(key)
|
||||
}
|
||||
return preference
|
||||
}
|
||||
|
||||
private fun launchTest(
|
||||
helpEnabled: Boolean = true,
|
||||
syncEnabled: Boolean = true,
|
||||
docProviderAppEnabled: Boolean = true,
|
||||
recommendEnabled: Boolean = true,
|
||||
feedbackEnabled: Boolean = true,
|
||||
imprintEnabled: Boolean = true
|
||||
) {
|
||||
every { moreViewModel.isHelpEnabled() } returns helpEnabled
|
||||
every { moreViewModel.isSyncEnabled() } returns syncEnabled
|
||||
every { moreViewModel.isDocProviderAppEnabled() } returns docProviderAppEnabled
|
||||
every { moreViewModel.isRecommendEnabled() } returns recommendEnabled
|
||||
every { moreViewModel.isFeedbackEnabled() } returns feedbackEnabled
|
||||
every { moreViewModel.isImprintEnabled() } returns imprintEnabled
|
||||
|
||||
fragmentScenario = launchFragmentInContainer(themeResId = R.style.Theme_qsfera)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun moreView() {
|
||||
launchTest()
|
||||
|
||||
prefHelp = getPreference(PREFERENCE_HELP)
|
||||
assertNotNull(prefHelp)
|
||||
prefHelp?.verifyPreference(
|
||||
keyPref = PREFERENCE_HELP,
|
||||
titlePref = context.getString(R.string.prefs_help),
|
||||
visible = true,
|
||||
enabled = true
|
||||
)
|
||||
|
||||
prefSync = getPreference(PREFERENCE_SYNC_CALENDAR_CONTACTS)
|
||||
assertNotNull(prefSync)
|
||||
prefSync?.verifyPreference(
|
||||
keyPref = PREFERENCE_SYNC_CALENDAR_CONTACTS,
|
||||
titlePref = context.getString(R.string.prefs_sync_calendar_contacts),
|
||||
summaryPref = context.getString(R.string.prefs_sync_calendar_contacts_summary),
|
||||
visible = true,
|
||||
enabled = true
|
||||
)
|
||||
|
||||
prefAccessDocProvider = getPreference(PREFERENCE_ACCESS_DOCUMENT_PROVIDER)
|
||||
assertNotNull(prefAccessDocProvider)
|
||||
prefAccessDocProvider?.verifyPreference(
|
||||
keyPref = PREFERENCE_ACCESS_DOCUMENT_PROVIDER,
|
||||
titlePref = context.getString(R.string.prefs_access_document_provider),
|
||||
summaryPref = context.getString(R.string.prefs_access_document_provider_summary),
|
||||
visible = true,
|
||||
enabled = true
|
||||
)
|
||||
|
||||
prefRecommend = getPreference(PREFERENCE_RECOMMEND)
|
||||
assertNotNull(prefRecommend)
|
||||
prefRecommend?.verifyPreference(
|
||||
keyPref = PREFERENCE_RECOMMEND,
|
||||
titlePref = context.getString(R.string.prefs_recommend),
|
||||
visible = true,
|
||||
enabled = true
|
||||
)
|
||||
|
||||
prefFeedback = getPreference(PREFERENCE_FEEDBACK)
|
||||
assertNotNull(prefFeedback)
|
||||
prefFeedback?.verifyPreference(
|
||||
keyPref = PREFERENCE_FEEDBACK,
|
||||
titlePref = context.getString(R.string.prefs_send_feedback),
|
||||
visible = true,
|
||||
enabled = true
|
||||
)
|
||||
|
||||
prefImprint = getPreference(PREFERENCE_IMPRINT)
|
||||
assertNotNull(prefImprint)
|
||||
prefImprint?.verifyPreference(
|
||||
keyPref = PREFERENCE_IMPRINT,
|
||||
titlePref = context.getString(R.string.prefs_imprint),
|
||||
visible = true,
|
||||
enabled = true
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun helpNotEnabledView() {
|
||||
launchTest(helpEnabled = false)
|
||||
prefHelp = getPreference(PREFERENCE_HELP)
|
||||
|
||||
assertNull(prefHelp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncNotEnabledView() {
|
||||
launchTest(syncEnabled = false)
|
||||
prefSync = getPreference(PREFERENCE_SYNC_CALENDAR_CONTACTS)
|
||||
|
||||
assertNull(prefSync)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accessDocumentProviderNotEnabledView() {
|
||||
launchTest(docProviderAppEnabled = false)
|
||||
prefAccessDocProvider = getPreference(PREFERENCE_ACCESS_DOCUMENT_PROVIDER)
|
||||
|
||||
assertNull(prefAccessDocProvider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recommendNotEnabledView() {
|
||||
launchTest(recommendEnabled = false)
|
||||
prefRecommend = getPreference(PREFERENCE_RECOMMEND)
|
||||
|
||||
assertNull(prefRecommend)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun feedbackNotEnabledView() {
|
||||
launchTest(feedbackEnabled = false)
|
||||
prefFeedback = getPreference(PREFERENCE_FEEDBACK)
|
||||
|
||||
assertNull(prefFeedback)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun imprintNotEnabledView() {
|
||||
launchTest(imprintEnabled = false)
|
||||
prefImprint = getPreference(PREFERENCE_IMPRINT)
|
||||
|
||||
assertNull(prefImprint)
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun helpOpensNotEmptyUrl() {
|
||||
every { moreViewModel.getHelpUrl() } returns context.getString(R.string.url_help)
|
||||
|
||||
launchTest()
|
||||
|
||||
mockIntent(action = Intent.ACTION_VIEW)
|
||||
onView(withText(R.string.prefs_help)).perform(click())
|
||||
|
||||
intended(hasData(context.getString(R.string.url_help)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncOpensNotEmptyUrl() {
|
||||
every { moreViewModel.getSyncUrl() } returns context.getString(R.string.url_sync_calendar_contacts)
|
||||
|
||||
launchTest()
|
||||
|
||||
mockIntent(action = Intent.ACTION_VIEW)
|
||||
onView(withText(R.string.prefs_sync_calendar_contacts)).perform(click())
|
||||
|
||||
intended(hasData(context.getString(R.string.url_sync_calendar_contacts)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accessDocumentProviderOpensNotEmptyUrl() {
|
||||
every { moreViewModel.getDocProviderAppUrl() } returns context.getString(R.string.url_document_provider_app)
|
||||
|
||||
launchTest()
|
||||
|
||||
mockIntent(action = Intent.ACTION_VIEW)
|
||||
onView(withText(R.string.prefs_access_document_provider)).perform(click())
|
||||
|
||||
intended(hasData(context.getString(R.string.url_document_provider_app)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recommendOpensSender() {
|
||||
launchTest()
|
||||
|
||||
mockIntent(action = Intent.ACTION_SENDTO)
|
||||
|
||||
onView(withText(R.string.prefs_recommend)).perform(click())
|
||||
// Delay needed since depending on the performance of the device where tests are executed,
|
||||
// sender can interfere with the subsequent tests
|
||||
Thread.sleep(1000)
|
||||
intended(
|
||||
allOf(
|
||||
hasAction(Intent.ACTION_SENDTO), hasExtra(
|
||||
EXTRA_SUBJECT, String.format(
|
||||
context.getString(R.string.recommend_subject),
|
||||
context.getString(R.string.app_name)
|
||||
)
|
||||
),
|
||||
hasExtra(
|
||||
EXTRA_TEXT,
|
||||
String.format(
|
||||
context.getString(R.string.recommend_text),
|
||||
context.getString(R.string.app_name),
|
||||
context.getString(R.string.url_app_download)
|
||||
)
|
||||
),
|
||||
hasFlag(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun feedbackOpensSenderIfFeedbackMailExists() {
|
||||
launchTest()
|
||||
every { moreViewModel.getFeedbackMail() } returns FEEDBACK_MAIL
|
||||
mockIntent(action = Intent.ACTION_SENDTO)
|
||||
|
||||
onView(withText(R.string.prefs_send_feedback)).perform(click())
|
||||
// Delay needed since depending on the performance of the device where tests are executed,
|
||||
// sender can interfere with the subsequent tests
|
||||
Thread.sleep(1000)
|
||||
intended(
|
||||
allOf(
|
||||
hasAction(Intent.ACTION_SENDTO),
|
||||
hasExtra(
|
||||
EXTRA_SUBJECT,
|
||||
"Android v" + BuildConfig.VERSION_NAME + " - " + context.getText(R.string.prefs_feedback)
|
||||
),
|
||||
hasData(Uri.parse(FEEDBACK_MAIL)),
|
||||
hasFlag(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun feedbackOpensAlertDialogIfFeedbackMailIsEmpty() {
|
||||
launchTest()
|
||||
every { moreViewModel.getFeedbackMail() } returns ""
|
||||
|
||||
onView(withText(R.string.prefs_send_feedback)).perform(click())
|
||||
|
||||
onView(withText(R.string.drawer_feedback)).check(ViewAssertions.matches(isDisplayed()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun imprintOpensUrl() {
|
||||
every { moreViewModel.getImprintUrl() } returns "https://qsfera.eu/mobile"
|
||||
|
||||
launchTest()
|
||||
|
||||
mockIntent(action = Intent.ACTION_VIEW)
|
||||
|
||||
onView(withText(R.string.prefs_imprint)).perform(click())
|
||||
intended(hasData("https://qsfera.eu/mobile"))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFERENCE_HELP = "help"
|
||||
private const val PREFERENCE_SYNC_CALENDAR_CONTACTS = "syncCalendarContacts"
|
||||
private const val PREFERENCE_ACCESS_DOCUMENT_PROVIDER = "accessDocumentProvider"
|
||||
private const val PREFERENCE_RECOMMEND = "recommend"
|
||||
private const val PREFERENCE_FEEDBACK = "feedback"
|
||||
private const val PREFERENCE_IMPRINT = "imprint"
|
||||
private const val FEEDBACK_MAIL = "mailto:mail@qsfera.eu"
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+363
@@ -0,0 +1,363 @@
|
||||
/*
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Jesus Recio (@jesmrec)
|
||||
* @author Christian Schabesberger (@theScrabi)
|
||||
* @author Juan Carlos Garrote Gascón (@JuancaG05)
|
||||
* @author David Crespo Ríos (@davcres)
|
||||
*
|
||||
* Copyright (C) 2021 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.settings.security
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.action.ViewActions.click
|
||||
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.db.PreferenceManager
|
||||
import eu.qsfera.android.domain.utils.Event
|
||||
import eu.qsfera.android.presentation.security.passcode.PassCodeActivity
|
||||
import eu.qsfera.android.presentation.security.passcode.PasscodeAction
|
||||
import eu.qsfera.android.presentation.security.passcode.PasscodeType
|
||||
import eu.qsfera.android.presentation.security.passcode.Status
|
||||
import eu.qsfera.android.presentation.security.biometric.BiometricViewModel
|
||||
import eu.qsfera.android.presentation.security.passcode.PassCodeViewModel
|
||||
import eu.qsfera.android.testutil.security.OC_PASSCODE_4_DIGITS
|
||||
import eu.qsfera.android.utils.matchers.isDisplayed
|
||||
import eu.qsfera.android.utils.matchers.withChildCountAndId
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.koin.dsl.module
|
||||
|
||||
class PassCodeActivityTest {
|
||||
|
||||
private lateinit var activityScenario: ActivityScenario<PassCodeActivity>
|
||||
|
||||
private lateinit var context: Context
|
||||
|
||||
private lateinit var timeToUnlockLiveData: MutableLiveData<Event<String>>
|
||||
private lateinit var finishTimeToUnlockLiveData: MutableLiveData<Event<Boolean>>
|
||||
private lateinit var statusLiveData: MutableLiveData<Status>
|
||||
private lateinit var passcodeLiveData: MutableLiveData<String>
|
||||
|
||||
private lateinit var passCodeViewModel: PassCodeViewModel
|
||||
private lateinit var biometricViewModel: BiometricViewModel
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
passCodeViewModel = mockk(relaxed = true)
|
||||
biometricViewModel = mockk(relaxed = true)
|
||||
|
||||
timeToUnlockLiveData = MutableLiveData()
|
||||
finishTimeToUnlockLiveData = MutableLiveData()
|
||||
statusLiveData = MutableLiveData()
|
||||
passcodeLiveData = MutableLiveData()
|
||||
|
||||
stopKoin()
|
||||
|
||||
startKoin {
|
||||
allowOverride(override = true)
|
||||
context
|
||||
modules(
|
||||
module {
|
||||
viewModel {
|
||||
passCodeViewModel
|
||||
}
|
||||
viewModel {
|
||||
biometricViewModel
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
every { passCodeViewModel.getPassCode() } returns OC_PASSCODE_4_DIGITS
|
||||
every { passCodeViewModel.getNumberOfPassCodeDigits() } returns 4
|
||||
every { passCodeViewModel.getNumberOfAttempts() } returns 0
|
||||
every { passCodeViewModel.getTimeToUnlockLiveData } returns timeToUnlockLiveData
|
||||
every { passCodeViewModel.getFinishedTimeToUnlockLiveData } returns finishTimeToUnlockLiveData
|
||||
every { passCodeViewModel.status } returns statusLiveData
|
||||
every { passCodeViewModel.passcode } returns passcodeLiveData
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
// Clean preferences
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passcodeCheckNotLockedView() {
|
||||
// Open Activity in passcode check mode
|
||||
openPasscodeActivity(PassCodeActivity.ACTION_CHECK)
|
||||
|
||||
with(R.id.header) {
|
||||
isDisplayed(true)
|
||||
withText(R.string.pass_code_enter_pass_code)
|
||||
}
|
||||
|
||||
R.id.explanation.isDisplayed(false)
|
||||
|
||||
// Check if required amount of input fields are actually displayed
|
||||
with(R.id.layout_code) {
|
||||
isDisplayed(true)
|
||||
withChildCountAndId(passCodeViewModel.getNumberOfPassCodeDigits(), R.id.passCodeEditText)
|
||||
}
|
||||
|
||||
R.id.lock_time.isDisplayed(false)
|
||||
|
||||
R.id.numberKeyboard.isDisplayed(true)
|
||||
R.id.key0.isDisplayed(true)
|
||||
R.id.key1.isDisplayed(true)
|
||||
R.id.key2.isDisplayed(true)
|
||||
R.id.key3.isDisplayed(true)
|
||||
R.id.key4.isDisplayed(true)
|
||||
R.id.key5.isDisplayed(true)
|
||||
R.id.key6.isDisplayed(true)
|
||||
R.id.key7.isDisplayed(true)
|
||||
R.id.key8.isDisplayed(true)
|
||||
R.id.key9.isDisplayed(true)
|
||||
R.id.backspaceBtn.isDisplayed(true)
|
||||
R.id.biometricBtn.isDisplayed(false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passcodeCheckLockedView() {
|
||||
every { passCodeViewModel.getNumberOfAttempts() } returns 3
|
||||
every { passCodeViewModel.getTimeToUnlockLeft() } returns 3000
|
||||
|
||||
timeToUnlockLiveData.postValue(Event("00:03"))
|
||||
|
||||
// Open Activity in passcode check mode
|
||||
openPasscodeActivity(PassCodeActivity.ACTION_CHECK)
|
||||
|
||||
with(R.id.header) {
|
||||
isDisplayed(true)
|
||||
withText(R.string.pass_code_enter_pass_code)
|
||||
}
|
||||
|
||||
R.id.explanation.isDisplayed(false)
|
||||
|
||||
// Check if required amount of input fields are actually displayed
|
||||
with(R.id.layout_code) {
|
||||
isDisplayed(true)
|
||||
withChildCountAndId(passCodeViewModel.getNumberOfPassCodeDigits(), R.id.passCodeEditText)
|
||||
}
|
||||
|
||||
R.id.lock_time.isDisplayed(true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passcodeView() {
|
||||
// Open Activity in passcode creation mode
|
||||
openPasscodeActivity(PassCodeActivity.ACTION_CREATE)
|
||||
|
||||
with(R.id.header) {
|
||||
isDisplayed(true)
|
||||
withText(R.string.pass_code_configure_your_pass_code)
|
||||
}
|
||||
with(R.id.explanation) {
|
||||
isDisplayed(true)
|
||||
withText(R.string.pass_code_configure_your_pass_code_explanation)
|
||||
}
|
||||
|
||||
// Check if required amount of input fields are actually displayed
|
||||
with(R.id.layout_code) {
|
||||
isDisplayed(true)
|
||||
withChildCountAndId(passCodeViewModel.getNumberOfPassCodeDigits(), R.id.passCodeEditText)
|
||||
}
|
||||
|
||||
R.id.lock_time.isDisplayed(false)
|
||||
|
||||
R.id.error.isDisplayed(false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun firstTry() {
|
||||
// Open Activity in passcode creation mode
|
||||
openPasscodeActivity(PassCodeActivity.ACTION_CREATE)
|
||||
|
||||
statusLiveData.postValue(Status(PasscodeAction.CREATE, PasscodeType.NO_CONFIRM))
|
||||
|
||||
with(R.id.header) {
|
||||
isDisplayed(true)
|
||||
withText(R.string.pass_code_reenter_your_pass_code)
|
||||
}
|
||||
onView(withText(R.string.pass_code_configure_your_pass_code)).check(doesNotExist())
|
||||
|
||||
R.id.error.isDisplayed(false)
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun secondTryCorrect() {
|
||||
every { biometricViewModel.isBiometricLockAvailable() } returns true
|
||||
|
||||
// Open Activity in passcode creation mode
|
||||
openPasscodeActivity(PassCodeActivity.ACTION_CREATE)
|
||||
|
||||
statusLiveData.postValue(Status(PasscodeAction.CREATE, PasscodeType.CONFIRM))
|
||||
|
||||
// Click dialog's enable option
|
||||
onView(withText(R.string.common_yes)).perform(click())
|
||||
|
||||
// Checking that the result returned is OK
|
||||
assertEquals(activityScenario.result.resultCode, Activity.RESULT_OK)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun secondTryIncorrect() {
|
||||
// Open Activity in passcode creation mode
|
||||
openPasscodeActivity(PassCodeActivity.ACTION_CREATE)
|
||||
|
||||
statusLiveData.postValue(Status(PasscodeAction.CREATE, PasscodeType.ERROR))
|
||||
|
||||
with(R.id.header) {
|
||||
isDisplayed(true)
|
||||
withText(R.string.pass_code_configure_your_pass_code)
|
||||
}
|
||||
with(R.id.explanation) {
|
||||
isDisplayed(true)
|
||||
withText(R.string.pass_code_configure_your_pass_code_explanation)
|
||||
}
|
||||
with(R.id.error) {
|
||||
isDisplayed(true)
|
||||
withText(R.string.pass_code_mismatch)
|
||||
}
|
||||
|
||||
R.id.lock_time.isDisplayed(false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletePasscodeView() {
|
||||
// Open Activity in passcode deletion mode
|
||||
openPasscodeActivity(PassCodeActivity.ACTION_REMOVE)
|
||||
|
||||
with(R.id.header) {
|
||||
isDisplayed(true)
|
||||
withText(R.string.pass_code_remove_your_pass_code)
|
||||
}
|
||||
|
||||
R.id.explanation.isDisplayed(false)
|
||||
|
||||
R.id.error.isDisplayed(false)
|
||||
|
||||
R.id.lock_time.isDisplayed(false)
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun deletePasscodeCorrect() {
|
||||
// Open Activity in passcode deletion mode
|
||||
openPasscodeActivity(PassCodeActivity.ACTION_REMOVE)
|
||||
|
||||
statusLiveData.postValue(Status(PasscodeAction.REMOVE, PasscodeType.OK))
|
||||
|
||||
assertEquals(activityScenario.result.resultCode, Activity.RESULT_OK)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletePasscodeIncorrect() {
|
||||
// Open Activity in passcode deletion mode
|
||||
openPasscodeActivity(PassCodeActivity.ACTION_REMOVE)
|
||||
|
||||
statusLiveData.postValue(Status(PasscodeAction.REMOVE, PasscodeType.ERROR))
|
||||
|
||||
with(R.id.header) {
|
||||
isDisplayed(true)
|
||||
withText(R.string.pass_code_enter_pass_code)
|
||||
}
|
||||
with(R.id.error) {
|
||||
isDisplayed(true)
|
||||
withText(R.string.pass_code_wrong)
|
||||
}
|
||||
|
||||
R.id.explanation.isDisplayed(false)
|
||||
|
||||
R.id.lock_time.isDisplayed(false)
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun checkEnableBiometricDialogIsVisible() {
|
||||
every { biometricViewModel.isBiometricLockAvailable() } returns true
|
||||
|
||||
// Open Activity in passcode creation mode
|
||||
openPasscodeActivity(PassCodeActivity.ACTION_CREATE)
|
||||
|
||||
statusLiveData.postValue(Status(PasscodeAction.CREATE, PasscodeType.CONFIRM))
|
||||
|
||||
onView(withText(R.string.biometric_dialog_title)).check(matches(isDisplayed()))
|
||||
onView(withText(R.string.common_yes)).check(matches(isDisplayed()))
|
||||
onView(withText(R.string.common_no)).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun checkEnableBiometricDialogYesOption() {
|
||||
every { biometricViewModel.isBiometricLockAvailable() } returns true
|
||||
|
||||
// Open Activity in passcode creation mode
|
||||
openPasscodeActivity(PassCodeActivity.ACTION_CREATE)
|
||||
|
||||
statusLiveData.postValue(Status(PasscodeAction.CREATE, PasscodeType.CONFIRM))
|
||||
|
||||
onView(withText(R.string.common_yes)).perform(click())
|
||||
|
||||
// Checking that the result returned is OK
|
||||
assertEquals(activityScenario.result.resultCode, Activity.RESULT_OK)
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun checkEnableBiometricDialogNoOption() {
|
||||
every { biometricViewModel.isBiometricLockAvailable() } returns true
|
||||
|
||||
// Open Activity in passcode creation mode
|
||||
openPasscodeActivity(PassCodeActivity.ACTION_CREATE)
|
||||
|
||||
statusLiveData.postValue(Status(PasscodeAction.CREATE, PasscodeType.CONFIRM))
|
||||
|
||||
onView(withText(R.string.common_no)).perform(click())
|
||||
|
||||
// Checking that the result returned is OK
|
||||
assertEquals(activityScenario.result.resultCode, Activity.RESULT_OK)
|
||||
}
|
||||
|
||||
private fun openPasscodeActivity(mode: String) {
|
||||
val intent = Intent(context, PassCodeActivity::class.java).apply {
|
||||
action = mode
|
||||
}
|
||||
activityScenario = ActivityScenario.launch(intent)
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Jesus Recio (@jesmrec)
|
||||
* @author Juan Carlos Garrote Gascón (@JuancaG05)
|
||||
*
|
||||
* Copyright (C) 2021 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.settings.security
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.db.PreferenceManager
|
||||
import eu.qsfera.android.presentation.security.pattern.PatternActivity
|
||||
import eu.qsfera.android.presentation.security.pattern.PatternViewModel
|
||||
import eu.qsfera.android.testutil.security.OC_PATTERN
|
||||
import eu.qsfera.android.utils.matchers.isDisplayed
|
||||
import eu.qsfera.android.utils.matchers.withText
|
||||
import io.mockk.mockk
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.koin.dsl.module
|
||||
|
||||
class PatternActivityTest {
|
||||
|
||||
private lateinit var activityScenario: ActivityScenario<PatternActivity>
|
||||
|
||||
private lateinit var context: Context
|
||||
|
||||
private lateinit var patternViewModel: PatternViewModel
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
patternViewModel = mockk(relaxUnitFun = true)
|
||||
|
||||
stopKoin()
|
||||
|
||||
startKoin {
|
||||
context
|
||||
allowOverride(override = true)
|
||||
modules(
|
||||
module {
|
||||
viewModel {
|
||||
patternViewModel
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
// Clean preferences
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patternLockView() {
|
||||
// Open Activity in pattern creation mode
|
||||
openPatternActivity(PatternActivity.ACTION_REQUEST_WITH_RESULT)
|
||||
|
||||
with(R.id.header_pattern) {
|
||||
isDisplayed(true)
|
||||
withText(R.string.pattern_configure_pattern)
|
||||
}
|
||||
with(R.id.explanation_pattern) {
|
||||
isDisplayed(true)
|
||||
withText(R.string.pattern_configure_your_pattern_explanation)
|
||||
}
|
||||
R.id.pattern_lock_view.isDisplayed(true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removePatternLock() {
|
||||
// Save a pattern in Preferences
|
||||
storePattern()
|
||||
|
||||
// Open Activity in pattern deletion mode
|
||||
openPatternActivity(PatternActivity.ACTION_CHECK_WITH_RESULT)
|
||||
|
||||
with(R.id.header_pattern) {
|
||||
isDisplayed(true)
|
||||
withText(R.string.pattern_remove_pattern)
|
||||
}
|
||||
with(R.id.explanation_pattern) {
|
||||
isDisplayed(true)
|
||||
withText(R.string.pattern_no_longer_required)
|
||||
}
|
||||
}
|
||||
|
||||
private fun storePattern() {
|
||||
val appPrefs = PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
appPrefs.apply {
|
||||
putString(PatternActivity.PREFERENCE_PATTERN, OC_PATTERN)
|
||||
putBoolean(PatternActivity.PREFERENCE_SET_PATTERN, true)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun openPatternActivity(mode: String) {
|
||||
val intent = Intent(context, PatternActivity::class.java).apply {
|
||||
action = mode
|
||||
}
|
||||
activityScenario = ActivityScenario.launch(intent)
|
||||
}
|
||||
}
|
||||
+499
@@ -0,0 +1,499 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
*
|
||||
* Copyright (C) 2021 ownCloud GmbH.
|
||||
* <p>
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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 eu.qsfera.android.settings.security
|
||||
|
||||
import android.app.Activity.RESULT_OK
|
||||
import android.content.Context
|
||||
import androidx.biometric.BiometricViewModel
|
||||
import androidx.fragment.app.testing.FragmentScenario
|
||||
import androidx.fragment.app.testing.launchFragmentInContainer
|
||||
import androidx.preference.CheckBoxPreference
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.action.ViewActions.click
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.intent.Intents
|
||||
import androidx.test.espresso.intent.Intents.intended
|
||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.presentation.security.biometric.BiometricActivity
|
||||
import eu.qsfera.android.presentation.security.biometric.BiometricManager
|
||||
import eu.qsfera.android.presentation.security.PREFERENCE_LOCK_TIMEOUT
|
||||
import eu.qsfera.android.presentation.security.pattern.PatternActivity
|
||||
import eu.qsfera.android.presentation.security.passcode.PassCodeActivity
|
||||
import eu.qsfera.android.presentation.settings.security.SettingsSecurityFragment
|
||||
import eu.qsfera.android.presentation.settings.security.SettingsSecurityFragment.Companion.PREFERENCE_LOCK_ACCESS_FROM_DOCUMENT_PROVIDER
|
||||
import eu.qsfera.android.presentation.settings.security.SettingsSecurityViewModel
|
||||
import eu.qsfera.android.testutil.security.OC_PATTERN
|
||||
import eu.qsfera.android.utils.matchers.verifyPreference
|
||||
import eu.qsfera.android.utils.mockIntent
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import org.hamcrest.Matchers.not
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.koin.dsl.module
|
||||
|
||||
class SettingsSecurityFragmentTest {
|
||||
|
||||
private lateinit var fragmentScenario: FragmentScenario<SettingsSecurityFragment>
|
||||
|
||||
private lateinit var prefPasscode: CheckBoxPreference
|
||||
private lateinit var prefPattern: CheckBoxPreference
|
||||
private var prefBiometric: CheckBoxPreference? = null
|
||||
private lateinit var prefLockApplication: ListPreference
|
||||
private lateinit var prefLockAccessDocumentProvider: CheckBoxPreference
|
||||
private lateinit var prefTouchesWithOtherVisibleWindows: CheckBoxPreference
|
||||
|
||||
private lateinit var securityViewModel: SettingsSecurityViewModel
|
||||
private lateinit var biometricViewModel: BiometricViewModel
|
||||
private lateinit var context: Context
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
securityViewModel = mockk(relaxed = true)
|
||||
biometricViewModel = mockk(relaxed = true)
|
||||
mockkObject(BiometricManager)
|
||||
|
||||
stopKoin()
|
||||
|
||||
startKoin {
|
||||
context
|
||||
allowOverride(override = true)
|
||||
modules(
|
||||
module {
|
||||
viewModel {
|
||||
securityViewModel
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
every { securityViewModel.isSecurityEnforcedEnabled() } returns false
|
||||
every { securityViewModel.getBiometricsState() } returns false
|
||||
every { securityViewModel.isLockDelayEnforcedEnabled() } returns false
|
||||
|
||||
Intents.init()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Intents.release()
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit()
|
||||
}
|
||||
|
||||
private fun launchTest(withBiometrics: Boolean = true) {
|
||||
every { BiometricManager.isHardwareDetected() } returns withBiometrics
|
||||
|
||||
fragmentScenario = launchFragmentInContainer(themeResId = R.style.Theme_qsfera)
|
||||
fragmentScenario.onFragment { fragment ->
|
||||
prefPasscode = fragment.findPreference(PassCodeActivity.PREFERENCE_SET_PASSCODE)!!
|
||||
prefPattern = fragment.findPreference(PatternActivity.PREFERENCE_SET_PATTERN)!!
|
||||
prefBiometric = fragment.findPreference(BiometricActivity.PREFERENCE_SET_BIOMETRIC)
|
||||
prefLockApplication = fragment.findPreference(PREFERENCE_LOCK_TIMEOUT)!!
|
||||
prefLockAccessDocumentProvider = fragment.findPreference(PREFERENCE_LOCK_ACCESS_FROM_DOCUMENT_PROVIDER)!!
|
||||
prefTouchesWithOtherVisibleWindows =
|
||||
fragment.findPreference(SettingsSecurityFragment.PREFERENCE_TOUCHES_WITH_OTHER_VISIBLE_WINDOWS)!!
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkCommonPreferences() {
|
||||
prefPasscode.verifyPreference(
|
||||
keyPref = PassCodeActivity.PREFERENCE_SET_PASSCODE,
|
||||
titlePref = context.getString(R.string.prefs_passcode),
|
||||
visible = true,
|
||||
enabled = true
|
||||
)
|
||||
assertFalse(prefPasscode.isChecked)
|
||||
|
||||
prefPattern.verifyPreference(
|
||||
keyPref = PatternActivity.PREFERENCE_SET_PATTERN,
|
||||
titlePref = context.getString(R.string.prefs_pattern),
|
||||
visible = true,
|
||||
enabled = true
|
||||
)
|
||||
assertFalse(prefPattern.isChecked)
|
||||
|
||||
prefLockApplication.verifyPreference(
|
||||
keyPref = PREFERENCE_LOCK_TIMEOUT,
|
||||
titlePref = context.getString(R.string.prefs_lock_application),
|
||||
visible = true,
|
||||
enabled = false
|
||||
)
|
||||
|
||||
prefLockAccessDocumentProvider.verifyPreference(
|
||||
keyPref = PREFERENCE_LOCK_ACCESS_FROM_DOCUMENT_PROVIDER,
|
||||
titlePref = context.getString(R.string.prefs_lock_access_from_document_provider),
|
||||
summaryPref = context.getString(R.string.prefs_lock_access_from_document_provider_summary),
|
||||
visible = true,
|
||||
enabled = true
|
||||
)
|
||||
assertFalse(prefLockAccessDocumentProvider.isChecked)
|
||||
|
||||
prefTouchesWithOtherVisibleWindows.verifyPreference(
|
||||
keyPref = SettingsSecurityFragment.PREFERENCE_TOUCHES_WITH_OTHER_VISIBLE_WINDOWS,
|
||||
titlePref = context.getString(R.string.prefs_touches_with_other_visible_windows),
|
||||
summaryPref = context.getString(R.string.prefs_touches_with_other_visible_windows_summary),
|
||||
visible = true,
|
||||
enabled = true
|
||||
)
|
||||
assertFalse(prefTouchesWithOtherVisibleWindows.isChecked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun securityViewDeviceWithBiometrics() {
|
||||
launchTest()
|
||||
|
||||
checkCommonPreferences()
|
||||
|
||||
assertNotNull(prefBiometric)
|
||||
prefBiometric?.run {
|
||||
verifyPreference(
|
||||
keyPref = BiometricActivity.PREFERENCE_SET_BIOMETRIC,
|
||||
titlePref = context.getString(R.string.prefs_biometric),
|
||||
summaryPref = context.getString(R.string.prefs_biometric_summary),
|
||||
visible = true,
|
||||
enabled = false
|
||||
)
|
||||
assertFalse(isChecked)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun securityViewDeviceWithNoBiometrics() {
|
||||
launchTest(withBiometrics = false)
|
||||
|
||||
checkCommonPreferences()
|
||||
|
||||
assertNull(prefBiometric)
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun passcodeOpen() {
|
||||
every { securityViewModel.isPatternSet() } returns false
|
||||
|
||||
launchTest()
|
||||
|
||||
mockIntent(RESULT_OK, PassCodeActivity.ACTION_CREATE)
|
||||
onView(withText(R.string.prefs_passcode)).perform(click())
|
||||
intended(hasComponent(PassCodeActivity::class.java.name))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patternOpen() {
|
||||
every { securityViewModel.isPasscodeSet() } returns false
|
||||
|
||||
launchTest()
|
||||
|
||||
onView(withText(R.string.prefs_pattern)).perform(click())
|
||||
intended(hasComponent(PatternActivity::class.java.name))
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun passcodeLockEnabledOk() {
|
||||
every { securityViewModel.isPatternSet() } returns false
|
||||
|
||||
launchTest()
|
||||
|
||||
mockIntent(
|
||||
action = PassCodeActivity.ACTION_CREATE
|
||||
)
|
||||
onView(withText(R.string.prefs_passcode)).perform(click())
|
||||
assertTrue(prefPasscode.isChecked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patternLockEnabledOk() {
|
||||
every { securityViewModel.isPasscodeSet() } returns false
|
||||
|
||||
launchTest()
|
||||
|
||||
mockIntent(
|
||||
extras = Pair(PatternActivity.PREFERENCE_PATTERN, OC_PATTERN),
|
||||
action = PatternActivity.ACTION_REQUEST_WITH_RESULT
|
||||
)
|
||||
onView(withText(R.string.prefs_pattern)).perform(click())
|
||||
assertTrue(prefPattern.isChecked)
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun enablePasscodeEnablesBiometricLockAndLockApplication() {
|
||||
launchTest()
|
||||
|
||||
firstEnablePasscode()
|
||||
onView(withText(R.string.prefs_biometric)).check(matches(isEnabled()))
|
||||
assertTrue(prefBiometric!!.isEnabled)
|
||||
assertFalse(prefBiometric!!.isChecked)
|
||||
assertTrue(prefLockApplication.isEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enablePatternEnablesBiometricLockAndLockApplication() {
|
||||
launchTest()
|
||||
|
||||
firstEnablePattern()
|
||||
onView(withText(R.string.prefs_biometric)).check(matches(isEnabled()))
|
||||
assertTrue(prefBiometric!!.isEnabled)
|
||||
assertFalse(prefBiometric!!.isChecked)
|
||||
assertTrue(prefLockApplication.isEnabled)
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun onlyOneMethodEnabledPattern() {
|
||||
every { securityViewModel.isPatternSet() } returns true
|
||||
|
||||
launchTest()
|
||||
|
||||
firstEnablePattern()
|
||||
onView(withText(R.string.prefs_passcode)).perform(click())
|
||||
onView(withText(R.string.pattern_already_set)).check(matches(isEnabled()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyOneMethodEnabledPasscode() {
|
||||
every { securityViewModel.isPasscodeSet() } returns true
|
||||
|
||||
launchTest()
|
||||
|
||||
firstEnablePasscode()
|
||||
onView(withText(R.string.prefs_pattern)).perform(click())
|
||||
onView(withText(R.string.passcode_already_set)).check(matches(isEnabled()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disablePasscodeOk() {
|
||||
launchTest()
|
||||
|
||||
firstEnablePasscode()
|
||||
mockIntent(
|
||||
action = PassCodeActivity.ACTION_REMOVE
|
||||
)
|
||||
onView(withText(R.string.prefs_passcode)).perform(click())
|
||||
assertFalse(prefPasscode.isChecked)
|
||||
onView(withText(R.string.prefs_biometric)).check(matches(not(isEnabled())))
|
||||
assertFalse(prefBiometric!!.isEnabled)
|
||||
assertFalse(prefBiometric!!.isChecked)
|
||||
assertFalse(prefLockApplication.isEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disablePatternOk() {
|
||||
launchTest()
|
||||
|
||||
firstEnablePattern()
|
||||
mockIntent(
|
||||
action = PatternActivity.ACTION_CHECK_WITH_RESULT
|
||||
)
|
||||
onView(withText(R.string.prefs_pattern)).perform(click())
|
||||
assertFalse(prefPattern.isChecked)
|
||||
onView(withText(R.string.prefs_biometric)).check(matches(not(isEnabled())))
|
||||
assertFalse(prefBiometric!!.isEnabled)
|
||||
assertFalse(prefBiometric!!.isChecked)
|
||||
assertFalse(prefLockApplication.isEnabled)
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun enableBiometricLockWithPasscodeEnabled() {
|
||||
every { BiometricManager.hasEnrolledBiometric() } returns true
|
||||
|
||||
launchTest()
|
||||
|
||||
firstEnablePasscode()
|
||||
onView(withText(R.string.prefs_biometric)).perform(click())
|
||||
assertTrue(prefBiometric!!.isChecked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enableBiometricLockWithPatternEnabled() {
|
||||
every { BiometricManager.hasEnrolledBiometric() } returns true
|
||||
|
||||
launchTest()
|
||||
|
||||
firstEnablePattern()
|
||||
onView(withText(R.string.prefs_biometric)).perform(click())
|
||||
assertTrue(prefBiometric!!.isChecked)
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun enableBiometricLockNoEnrolledBiometric() {
|
||||
every { BiometricManager.hasEnrolledBiometric() } returns false
|
||||
|
||||
launchTest()
|
||||
|
||||
firstEnablePasscode()
|
||||
onView(withText(R.string.prefs_biometric)).perform(click())
|
||||
assertFalse(prefBiometric!!.isChecked)
|
||||
onView(withText(R.string.biometric_not_enrolled)).check(matches(isEnabled()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disableBiometricLock() {
|
||||
every { BiometricManager.hasEnrolledBiometric() } returns true
|
||||
|
||||
launchTest()
|
||||
|
||||
firstEnablePasscode()
|
||||
onView(withText(R.string.prefs_biometric)).perform(click())
|
||||
onView(withText(R.string.prefs_biometric)).perform(click())
|
||||
assertFalse(prefBiometric!!.isChecked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lockAccessFromDocumentProviderEnable() {
|
||||
launchTest()
|
||||
|
||||
onView(withText(R.string.prefs_lock_access_from_document_provider)).perform(click())
|
||||
assertTrue(prefLockAccessDocumentProvider.isChecked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lockAccessFromDocumentProviderDisable() {
|
||||
launchTest()
|
||||
|
||||
onView(withText(R.string.prefs_lock_access_from_document_provider)).perform(click())
|
||||
onView(withText(R.string.prefs_lock_access_from_document_provider)).perform(click())
|
||||
assertFalse(prefLockAccessDocumentProvider.isChecked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun touchesDialog() {
|
||||
launchTest()
|
||||
|
||||
onView(withText(R.string.prefs_touches_with_other_visible_windows)).perform(click())
|
||||
onView(withText(R.string.confirmation_touches_with_other_windows_title)).check(matches(isDisplayed()))
|
||||
onView(withText(R.string.confirmation_touches_with_other_windows_message)).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun touchesEnable() {
|
||||
launchTest()
|
||||
|
||||
onView(withText(R.string.prefs_touches_with_other_visible_windows)).perform(click())
|
||||
onView(withText(R.string.common_yes)).perform(click())
|
||||
assertTrue(prefTouchesWithOtherVisibleWindows.isChecked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun touchesRefuse() {
|
||||
launchTest()
|
||||
|
||||
onView(withText(R.string.prefs_touches_with_other_visible_windows)).perform(click())
|
||||
onView(withText(R.string.common_no)).perform(click())
|
||||
assertFalse(prefTouchesWithOtherVisibleWindows.isChecked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun touchesDisable() {
|
||||
launchTest()
|
||||
|
||||
onView(withText(R.string.prefs_touches_with_other_visible_windows)).perform(click())
|
||||
onView(withText(R.string.common_yes)).perform(click())
|
||||
onView(withText(R.string.prefs_touches_with_other_visible_windows)).perform(click())
|
||||
assertFalse(prefTouchesWithOtherVisibleWindows.isChecked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passcodeLockNotVisible() {
|
||||
every { securityViewModel.isSecurityEnforcedEnabled() } returns true
|
||||
launchTest()
|
||||
assertFalse(prefPasscode.isVisible)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patternLockNotVisible() {
|
||||
every { securityViewModel.isSecurityEnforcedEnabled() } returns true
|
||||
launchTest()
|
||||
assertFalse(prefPattern.isVisible)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passcodeLockVisible() {
|
||||
launchTest()
|
||||
assertTrue(prefPasscode.isVisible)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patternLockVisible() {
|
||||
launchTest()
|
||||
assertTrue(prefPattern.isVisible)
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun checkIfUserEnabledBiometricRecommendation() {
|
||||
every { securityViewModel.getBiometricsState() } returns true
|
||||
|
||||
launchTest()
|
||||
|
||||
firstEnablePasscode()
|
||||
|
||||
assertTrue(prefBiometric!!.isChecked)
|
||||
assertTrue(prefBiometric!!.isEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkIfUserNotEnabledBiometricRecommendation() {
|
||||
launchTest()
|
||||
|
||||
firstEnablePasscode()
|
||||
|
||||
assertFalse(prefBiometric!!.isChecked)
|
||||
}
|
||||
|
||||
private fun firstEnablePasscode() {
|
||||
every { securityViewModel.isPatternSet() } returns false
|
||||
|
||||
mockIntent(
|
||||
action = PassCodeActivity.ACTION_CREATE
|
||||
)
|
||||
onView(withText(R.string.prefs_passcode)).perform(click())
|
||||
}
|
||||
|
||||
private fun firstEnablePattern() {
|
||||
every { securityViewModel.isPasscodeSet() } returns false
|
||||
|
||||
mockIntent(
|
||||
action = PatternActivity.ACTION_REQUEST_WITH_RESULT
|
||||
)
|
||||
onView(withText(R.string.prefs_pattern)).perform(click())
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David González Verdugo
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.sharing.sharees.ui
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.matcher.ViewMatchers
|
||||
import androidx.test.espresso.matcher.ViewMatchers.hasSibling
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.domain.sharing.shares.model.OCShare
|
||||
import eu.qsfera.android.domain.sharing.shares.model.ShareType
|
||||
import eu.qsfera.android.domain.utils.Event
|
||||
import eu.qsfera.android.presentation.common.UIResult
|
||||
import eu.qsfera.android.presentation.sharing.sharees.SearchShareesFragment
|
||||
import eu.qsfera.android.presentation.sharing.ShareViewModel
|
||||
import eu.qsfera.android.sharing.shares.ui.TestShareFileActivity
|
||||
import eu.qsfera.android.testutil.OC_SHARE
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkClass
|
||||
import org.hamcrest.CoreMatchers
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.koin.dsl.module
|
||||
|
||||
class SearchShareesFragmentTest {
|
||||
private val shareViewModel = mockkClass(ShareViewModel::class, relaxed = true)
|
||||
private val sharesLiveData = MutableLiveData<Event<UIResult<List<OCShare>>>>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
every { shareViewModel.shares } returns sharesLiveData
|
||||
|
||||
stopKoin()
|
||||
|
||||
startKoin {
|
||||
androidContext(ApplicationProvider.getApplicationContext())
|
||||
allowOverride(override = true)
|
||||
modules(
|
||||
module {
|
||||
viewModel {
|
||||
shareViewModel
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
ActivityScenario.launch(TestShareFileActivity::class.java).onActivity {
|
||||
val searchShareesFragment = SearchShareesFragment()
|
||||
it.startFragment(searchShareesFragment)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showSearchBar() {
|
||||
onView(withId(R.id.search_mag_icon)).check(matches(isDisplayed()))
|
||||
onView(withId(R.id.search_plate)).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showUserShares() {
|
||||
sharesLiveData.postValue(
|
||||
Event(
|
||||
UIResult.Success(
|
||||
listOf(
|
||||
OC_SHARE.copy(sharedWithDisplayName = "Sheldon"),
|
||||
OC_SHARE.copy(sharedWithDisplayName = "Penny")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
onView(withText("Sheldon"))
|
||||
.check(matches(isDisplayed()))
|
||||
.check(matches(hasSibling(withId(R.id.unshareButton))))
|
||||
.check(matches(hasSibling(withId(R.id.editShareButton))))
|
||||
onView(withText("Penny")).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showGroupShares() {
|
||||
sharesLiveData.postValue(
|
||||
Event(
|
||||
UIResult.Success(
|
||||
listOf(
|
||||
OC_SHARE.copy(
|
||||
shareType = ShareType.GROUP,
|
||||
sharedWithDisplayName = "Friends"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
onView(withText("Friends (group)"))
|
||||
.check(matches(isDisplayed()))
|
||||
.check(matches(hasSibling(withId(R.id.icon))))
|
||||
onView(ViewMatchers.withTagValue(CoreMatchers.equalTo(R.drawable.ic_group))).check(matches(isDisplayed()))
|
||||
}
|
||||
}
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David González Verdugo
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.sharing.shares.ui
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.action.ViewActions.click
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isChecked
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isNotChecked
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.domain.sharing.shares.model.OCShare
|
||||
import eu.qsfera.android.domain.utils.Event
|
||||
import eu.qsfera.android.presentation.common.UIResult
|
||||
import eu.qsfera.android.presentation.sharing.sharees.EditPrivateShareFragment
|
||||
import eu.qsfera.android.presentation.sharing.ShareViewModel
|
||||
import eu.qsfera.android.testutil.OC_ACCOUNT
|
||||
import eu.qsfera.android.testutil.OC_FILE
|
||||
import eu.qsfera.android.testutil.OC_FOLDER
|
||||
import eu.qsfera.android.testutil.OC_SHARE
|
||||
import eu.qsfera.android.utils.Permissions
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.hamcrest.CoreMatchers.not
|
||||
import org.junit.Assert
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.koin.dsl.module
|
||||
|
||||
class EditPrivateShareFragmentTest {
|
||||
private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val defaultSharedWithDisplayName = "user"
|
||||
private val shareViewModel = mockk<ShareViewModel>(relaxed = true)
|
||||
private val privateShareAsLiveData = MutableLiveData<Event<UIResult<OCShare>>>()
|
||||
|
||||
private lateinit var activityScenario: ActivityScenario<TestShareFileActivity>
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
every { shareViewModel.privateShare } returns privateShareAsLiveData
|
||||
every { shareViewModel.isResharingAllowed() } returns true
|
||||
|
||||
stopKoin()
|
||||
|
||||
startKoin {
|
||||
androidContext(ApplicationProvider.getApplicationContext())
|
||||
allowOverride(override = true)
|
||||
modules(
|
||||
module {
|
||||
viewModel {
|
||||
shareViewModel
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showDialogTitle() {
|
||||
loadEditPrivateShareFragment()
|
||||
onView(withId(R.id.editShareTitle))
|
||||
.check(
|
||||
matches(
|
||||
withText(
|
||||
targetContext.getString(R.string.share_with_edit_title, defaultSharedWithDisplayName)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closeDialog() {
|
||||
loadEditPrivateShareFragment()
|
||||
onView(withId(R.id.closeButton)).perform(click())
|
||||
activityScenario.onActivity { Assert.assertNull(it.getTestFragment()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showToggles() {
|
||||
loadEditPrivateShareFragment()
|
||||
onView(withId(R.id.canEditSwitch)).check(matches(isDisplayed()))
|
||||
onView(withId(R.id.canShareSwitch)).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showFileShareWithNoPermissions() {
|
||||
loadEditPrivateShareFragment()
|
||||
onView(withId(R.id.canEditSwitch)).check(matches(isNotChecked()))
|
||||
onView(withId(R.id.canShareSwitch)).check(matches(isNotChecked()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showFileShareWithEditPermissions() {
|
||||
loadEditPrivateShareFragment(permissions = Permissions.EDIT_PERMISSIONS.value)
|
||||
onView(withId(R.id.canEditSwitch)).check(matches(isChecked()))
|
||||
onView(withId(R.id.canShareSwitch)).check(matches(isNotChecked()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showFileShareWithSharePermissions() {
|
||||
loadEditPrivateShareFragment(permissions = Permissions.SHARE_PERMISSIONS.value)
|
||||
onView(withId(R.id.canEditSwitch)).check(matches(isNotChecked()))
|
||||
onView(withId(R.id.canShareSwitch)).check(matches(isChecked()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showFileShareWithAllPermissions() {
|
||||
loadEditPrivateShareFragment(permissions = Permissions.ALL_PERMISSIONS.value)
|
||||
onView(withId(R.id.canEditSwitch)).check(matches(isChecked()))
|
||||
onView(withId(R.id.canShareSwitch)).check(matches(isChecked()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showFolderShareWithCreatePermissions() {
|
||||
loadEditPrivateShareFragment(true, permissions = Permissions.EDIT_CREATE_PERMISSIONS.value)
|
||||
onView(withId(R.id.canEditSwitch)).check(matches(isChecked()))
|
||||
onView(withId(R.id.canEditCreateCheckBox)).check(matches(isChecked()))
|
||||
onView(withId(R.id.canEditChangeCheckBox)).check(matches(isNotChecked()))
|
||||
onView(withId(R.id.canEditDeleteCheckBox)).check(matches(isNotChecked()))
|
||||
onView(withId(R.id.canShareSwitch)).check(matches(isNotChecked()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showFolderShareWithCreateChangePermissions() {
|
||||
loadEditPrivateShareFragment(true, permissions = Permissions.EDIT_CREATE_CHANGE_PERMISSIONS.value)
|
||||
onView(withId(R.id.canEditSwitch)).check(matches(isChecked()))
|
||||
onView(withId(R.id.canEditCreateCheckBox)).check(matches(isChecked()))
|
||||
onView(withId(R.id.canEditChangeCheckBox)).check(matches(isChecked()))
|
||||
onView(withId(R.id.canEditDeleteCheckBox)).check(matches(isNotChecked()))
|
||||
onView(withId(R.id.canShareSwitch)).check(matches(isNotChecked()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showFolderShareWithCreateDeletePermissions() {
|
||||
loadEditPrivateShareFragment(true, permissions = Permissions.EDIT_CREATE_DELETE_PERMISSIONS.value)
|
||||
onView(withId(R.id.canEditSwitch)).check(matches(isChecked()))
|
||||
onView(withId(R.id.canEditCreateCheckBox)).check(matches(isChecked()))
|
||||
onView(withId(R.id.canEditChangeCheckBox)).check(matches(isNotChecked()))
|
||||
onView(withId(R.id.canEditDeleteCheckBox)).check(matches(isChecked()))
|
||||
onView(withId(R.id.canShareSwitch)).check(matches(isNotChecked()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showFolderShareWithCreateChangeDeletePermissions() {
|
||||
loadEditPrivateShareFragment(true, permissions = Permissions.EDIT_CREATE_CHANGE_DELETE_PERMISSIONS.value)
|
||||
onView(withId(R.id.canEditSwitch)).check(matches(isChecked()))
|
||||
onView(withId(R.id.canEditCreateCheckBox)).check(matches(isChecked()))
|
||||
onView(withId(R.id.canEditChangeCheckBox)).check(matches(isChecked()))
|
||||
onView(withId(R.id.canEditDeleteCheckBox)).check(matches(isChecked()))
|
||||
onView(withId(R.id.canShareSwitch)).check(matches(isNotChecked()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showFolderShareWithChangePermissions() {
|
||||
loadEditPrivateShareFragment(true, permissions = Permissions.EDIT_CHANGE_PERMISSIONS.value)
|
||||
onView(withId(R.id.canEditSwitch)).check(matches(isChecked()))
|
||||
onView(withId(R.id.canEditCreateCheckBox)).check(matches(isNotChecked()))
|
||||
onView(withId(R.id.canEditChangeCheckBox)).check(matches(isChecked()))
|
||||
onView(withId(R.id.canEditDeleteCheckBox)).check(matches(isNotChecked()))
|
||||
onView(withId(R.id.canShareSwitch)).check(matches(isNotChecked()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showFolderShareWithChangeDeletePermissions() {
|
||||
loadEditPrivateShareFragment(true, permissions = Permissions.EDIT_CHANGE_DELETE_PERMISSIONS.value)
|
||||
onView(withId(R.id.canEditSwitch)).check(matches(isChecked()))
|
||||
onView(withId(R.id.canEditCreateCheckBox)).check(matches(isNotChecked()))
|
||||
onView(withId(R.id.canEditChangeCheckBox)).check(matches(isChecked()))
|
||||
onView(withId(R.id.canEditDeleteCheckBox)).check(matches(isChecked()))
|
||||
onView(withId(R.id.canShareSwitch)).check(matches(isNotChecked()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showFolderShareWithDeletePermissions() {
|
||||
loadEditPrivateShareFragment(true, permissions = Permissions.EDIT_DELETE_PERMISSIONS.value)
|
||||
onView(withId(R.id.canEditSwitch)).check(matches(isChecked()))
|
||||
onView(withId(R.id.canEditCreateCheckBox)).check(matches(isNotChecked()))
|
||||
onView(withId(R.id.canEditChangeCheckBox)).check(matches(isNotChecked()))
|
||||
onView(withId(R.id.canEditDeleteCheckBox)).check(matches(isChecked()))
|
||||
onView(withId(R.id.canShareSwitch)).check(matches(isNotChecked()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disableEditPermissionWithFile() {
|
||||
loadEditPrivateShareFragment(permissions = Permissions.EDIT_PERMISSIONS.value)
|
||||
onView(withId(R.id.canEditSwitch)).check(matches(isChecked()))
|
||||
onView(withId(R.id.canShareSwitch)).check(matches(isNotChecked()))
|
||||
|
||||
onView(withId(R.id.canEditSwitch)).perform(click())
|
||||
|
||||
onView(withId(R.id.canEditSwitch)).check(matches(isNotChecked())) // "Can edit" changes
|
||||
onView(withId(R.id.canEditCreateCheckBox)).check(matches(not(isDisplayed()))) // No suboptions
|
||||
onView(withId(R.id.canEditChangeCheckBox)).check(matches(not(isDisplayed())))
|
||||
onView(withId(R.id.canEditDeleteCheckBox)).check(matches(not(isDisplayed())))
|
||||
onView(withId(R.id.canShareSwitch)).check(matches(isNotChecked())) // "Can share" does not change
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disableEditPermissionWithFolder() {
|
||||
loadEditPrivateShareFragment(true, permissions = Permissions.EDIT_PERMISSIONS.value)
|
||||
onView(withId(R.id.canEditSwitch)).check(matches(isChecked()))
|
||||
onView(withId(R.id.canEditCreateCheckBox)).check(matches(isDisplayed())) // Suboptions appear
|
||||
onView(withId(R.id.canEditChangeCheckBox)).check(matches(isDisplayed()))
|
||||
onView(withId(R.id.canEditDeleteCheckBox)).check(matches(isDisplayed()))
|
||||
onView(withId(R.id.canShareSwitch)).check(matches(isNotChecked()))
|
||||
|
||||
onView(withId(R.id.canEditSwitch)).perform(click())
|
||||
|
||||
onView(withId(R.id.canEditSwitch)).check(matches(isNotChecked())) // "Can edit" changes
|
||||
onView(withId(R.id.canEditCreateCheckBox)).check(matches(not(isDisplayed()))) // Suboptions hidden
|
||||
onView(withId(R.id.canEditChangeCheckBox)).check(matches(not(isDisplayed())))
|
||||
onView(withId(R.id.canEditDeleteCheckBox)).check(matches(not(isDisplayed())))
|
||||
onView(withId(R.id.canShareSwitch)).check(matches(isNotChecked())) // "Can share" does not change
|
||||
}
|
||||
|
||||
private fun loadEditPrivateShareFragment(
|
||||
isFolder: Boolean = false,
|
||||
permissions: Int = Permissions.READ_PERMISSIONS.value
|
||||
) {
|
||||
val shareToEdit = OC_SHARE.copy(
|
||||
sharedWithDisplayName = defaultSharedWithDisplayName,
|
||||
permissions = permissions
|
||||
)
|
||||
|
||||
val sharedFile = if (isFolder) OC_FOLDER else OC_FILE
|
||||
|
||||
val editPrivateShareFragment = EditPrivateShareFragment.newInstance(
|
||||
shareToEdit,
|
||||
sharedFile,
|
||||
OC_ACCOUNT
|
||||
)
|
||||
|
||||
activityScenario = ActivityScenario.launch(TestShareFileActivity::class.java).onActivity {
|
||||
it.startFragment(editPrivateShareFragment)
|
||||
}
|
||||
|
||||
privateShareAsLiveData.postValue(
|
||||
Event(
|
||||
UIResult.Success(
|
||||
OC_SHARE.copy(
|
||||
shareWith = "user",
|
||||
sharedWithDisplayName = "User",
|
||||
path = "/Videos",
|
||||
isFolder = isFolder,
|
||||
permissions = permissions
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
+493
@@ -0,0 +1,493 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David González Verdugo
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.sharing.shares.ui
|
||||
|
||||
import android.text.InputType.TYPE_CLASS_TEXT
|
||||
import android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.action.ViewActions.click
|
||||
import androidx.test.espresso.action.ViewActions.scrollTo
|
||||
import androidx.test.espresso.action.ViewActions.typeText
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.matcher.ViewMatchers
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withInputType
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.domain.capabilities.model.CapabilityBooleanType
|
||||
import eu.qsfera.android.domain.capabilities.model.OCCapability
|
||||
import eu.qsfera.android.domain.utils.Event
|
||||
import eu.qsfera.android.presentation.common.UIResult
|
||||
import eu.qsfera.android.presentation.sharing.shares.PublicShareDialogFragment
|
||||
import eu.qsfera.android.presentation.capabilities.CapabilityViewModel
|
||||
import eu.qsfera.android.presentation.sharing.ShareViewModel
|
||||
import eu.qsfera.android.testutil.OC_ACCOUNT
|
||||
import eu.qsfera.android.testutil.OC_CAPABILITY
|
||||
import eu.qsfera.android.testutil.OC_FILE
|
||||
import eu.qsfera.android.testutil.OC_FOLDER
|
||||
import eu.qsfera.android.utils.DateUtils
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.hamcrest.CoreMatchers.not
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.koin.dsl.module
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
|
||||
class PublicShareCreationDialogFragmentTest {
|
||||
private val capabilityViewModel = mockk<CapabilityViewModel>(relaxed = true)
|
||||
private val capabilitiesLiveData = MutableLiveData<Event<UIResult<OCCapability>>>()
|
||||
private val shareViewModel = mockk<ShareViewModel>(relaxed = true)
|
||||
private val publicShareCreationStatus = MutableLiveData<Event<UIResult<Unit>>>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
every { capabilityViewModel.capabilities } returns capabilitiesLiveData
|
||||
every { shareViewModel.publicShareCreationStatus } returns publicShareCreationStatus
|
||||
|
||||
stopKoin()
|
||||
|
||||
startKoin {
|
||||
androidContext(ApplicationProvider.getApplicationContext())
|
||||
allowOverride(override = true)
|
||||
modules(
|
||||
module {
|
||||
viewModel {
|
||||
capabilityViewModel
|
||||
}
|
||||
viewModel {
|
||||
shareViewModel
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showDialogTitle() {
|
||||
loadPublicShareDialogFragment()
|
||||
onView(withId(R.id.publicShareDialogTitle)).check(matches(withText(R.string.share_via_link_create_title)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showMandatoryFields() {
|
||||
loadPublicShareDialogFragment()
|
||||
onView(withId(R.id.shareViaLinkNameSection)).check(matches(isDisplayed()))
|
||||
onView(withId(R.id.shareViaLinkPasswordSection)).check(matches(isDisplayed()))
|
||||
onView(withId(R.id.shareViaLinkExpirationSection)).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showDialogButtons() {
|
||||
loadPublicShareDialogFragment()
|
||||
onView(withId(R.id.cancelButton)).check(matches(isDisplayed()))
|
||||
onView(withId(R.id.saveButton)).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showFolderAdditionalFields() {
|
||||
loadPublicShareDialogFragment(
|
||||
isFolder = true,
|
||||
capabilities = OC_CAPABILITY.copy(
|
||||
versionString = "10.0.1",
|
||||
filesSharingPublicUpload = CapabilityBooleanType.TRUE,
|
||||
filesSharingPublicSupportsUploadOnly = CapabilityBooleanType.TRUE
|
||||
)
|
||||
)
|
||||
onView(withId(R.id.shareViaLinkEditPermissionGroup)).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showDefaultLinkName() {
|
||||
loadPublicShareDialogFragment()
|
||||
onView(withId(R.id.shareViaLinkNameValue)).check(matches(withText("DOC_12112018.jpg link")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enablePasswordSwitch() {
|
||||
loadPublicShareDialogFragment()
|
||||
onView(withId(R.id.shareViaLinkPasswordSwitch)).perform(click())
|
||||
onView(withId(R.id.shareViaLinkPasswordValue)).check(matches(isDisplayed()))
|
||||
onView(withId(R.id.saveButton)).check(matches(not(isEnabled())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkPasswordNotVisible() {
|
||||
loadPublicShareDialogFragment()
|
||||
onView(withId(R.id.shareViaLinkPasswordSwitch)).perform(click())
|
||||
onView(withId(R.id.shareViaLinkPasswordValue)).perform(typeText("supersecure"))
|
||||
onView(withId(R.id.shareViaLinkPasswordValue)).check(
|
||||
matches(
|
||||
withInputType(
|
||||
TYPE_CLASS_TEXT or TYPE_TEXT_VARIATION_PASSWORD
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkPasswordEnforced() {
|
||||
loadPublicShareDialogFragment(
|
||||
capabilities = OC_CAPABILITY.copy(
|
||||
filesSharingPublicPasswordEnforced = CapabilityBooleanType.TRUE
|
||||
)
|
||||
)
|
||||
onView(withId(R.id.shareViaLinkPasswordLabel)).check(
|
||||
matches(withText(R.string.share_via_link_password_enforced_label))
|
||||
)
|
||||
onView(withId(R.id.shareViaLinkPasswordSwitch))
|
||||
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
|
||||
onView(withId(R.id.shareViaLinkPasswordValue))
|
||||
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
|
||||
onView(withId(R.id.saveButton)).check(matches(not(isEnabled())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkExpireDateEnforced() {
|
||||
loadPublicShareDialogFragment(
|
||||
capabilities = OC_CAPABILITY.copy(
|
||||
filesSharingPublicExpireDateEnforced = CapabilityBooleanType.TRUE
|
||||
)
|
||||
)
|
||||
onView(withId(R.id.shareViaLinkExpirationLabel))
|
||||
.check(matches(withText(R.string.share_via_link_expiration_date_enforced_label)))
|
||||
onView(withId(R.id.shareViaLinkExpirationSwitch))
|
||||
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
|
||||
onView(withId(R.id.shareViaLinkExpirationExplanationLabel))
|
||||
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkExpireDateNotEnforced() {
|
||||
loadPublicShareDialogFragment(
|
||||
capabilities = OC_CAPABILITY.copy(
|
||||
filesSharingPublicExpireDateEnforced = CapabilityBooleanType.FALSE
|
||||
)
|
||||
)
|
||||
onView(withId(R.id.shareViaLinkExpirationLabel))
|
||||
.check(matches(withText(R.string.share_via_link_expiration_date_label)))
|
||||
onView(withId(R.id.shareViaLinkExpirationSwitch))
|
||||
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
|
||||
onView(withId(R.id.shareViaLinkExpirationExplanationLabel))
|
||||
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enableExpirationSwitch() {
|
||||
loadPublicShareDialogFragment()
|
||||
onView(withId(R.id.shareViaLinkExpirationSwitch)).perform(click())
|
||||
onView(withId(android.R.id.button1)).perform(click())
|
||||
onView(withId(R.id.shareViaLinkExpirationValue))
|
||||
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
|
||||
//TODO: check the date form the picker
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancelExpirationSwitch() {
|
||||
loadPublicShareDialogFragment()
|
||||
onView(withId(R.id.shareViaLinkExpirationSwitch)).perform(click())
|
||||
onView(withId(android.R.id.button2)).perform(click())
|
||||
onView(withId(R.id.shareViaLinkExpirationValue))
|
||||
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.INVISIBLE)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showError() {
|
||||
loadPublicShareDialogFragment()
|
||||
|
||||
onView(withId(R.id.saveButton)).perform(click())
|
||||
|
||||
publicShareCreationStatus.postValue(
|
||||
Event(
|
||||
UIResult.Error(
|
||||
error = Throwable("It was not possible to share this file or folder")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
onView(withId(R.id.public_link_error_message)).check(matches(isDisplayed()))
|
||||
onView(withId(R.id.public_link_error_message)).check(
|
||||
matches(
|
||||
withText(R.string.share_link_file_error)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uploadPermissionsWithFolderDisplayed() {
|
||||
loadPublicShareDialogFragment(
|
||||
isFolder = true,
|
||||
capabilities = OC_CAPABILITY.copy(
|
||||
versionString = "10.1.1",
|
||||
filesSharingPublicSupportsUploadOnly = CapabilityBooleanType.TRUE,
|
||||
filesSharingPublicUpload = CapabilityBooleanType.TRUE
|
||||
)
|
||||
)
|
||||
|
||||
onView(withId(R.id.shareViaLinkEditPermissionGroup)).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uploadPermissionsWithFolderNotDisplayed() {
|
||||
loadPublicShareDialogFragment(
|
||||
isFolder = true,
|
||||
capabilities = OC_CAPABILITY.copy(
|
||||
versionString = "10.1.1",
|
||||
filesSharingPublicSupportsUploadOnly = CapabilityBooleanType.TRUE,
|
||||
filesSharingPublicUpload = CapabilityBooleanType.FALSE
|
||||
)
|
||||
)
|
||||
|
||||
onView(withId(R.id.shareViaLinkEditPermissionGroup)).check(matches(not(isDisplayed())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expirationDateDays() {
|
||||
val daysToTest = 15
|
||||
loadPublicShareDialogFragment(
|
||||
capabilities = OC_CAPABILITY.copy(
|
||||
versionString = "10.1.1",
|
||||
filesSharingPublicExpireDateDays = daysToTest
|
||||
)
|
||||
)
|
||||
val formattedDate = SimpleDateFormat.getDateInstance().format(
|
||||
DateUtils.addDaysToDate(
|
||||
Date(),
|
||||
daysToTest
|
||||
)
|
||||
)
|
||||
onView(withId(R.id.shareViaLinkExpirationSwitch))
|
||||
.check(matches(isEnabled()))
|
||||
onView(withId(R.id.shareViaLinkExpirationValue))
|
||||
.check(matches(withText(formattedDate)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passwordNotEnforced() {
|
||||
loadPublicShareDialogFragment(
|
||||
capabilities = OC_CAPABILITY.copy(
|
||||
versionString = "10.1.1",
|
||||
filesSharingPublicPasswordEnforced = CapabilityBooleanType.FALSE
|
||||
)
|
||||
)
|
||||
onView(withId(R.id.shareViaLinkPasswordLabel))
|
||||
.check(matches(withText(R.string.share_via_link_password_label)))
|
||||
onView(withId(R.id.saveButton)).check(matches(isEnabled()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passwordEnforced() {
|
||||
loadPublicShareDialogFragment(
|
||||
capabilities = OC_CAPABILITY.copy(
|
||||
versionString = "10.1.1",
|
||||
filesSharingPublicPasswordEnforced = CapabilityBooleanType.TRUE
|
||||
)
|
||||
)
|
||||
onView(withId(R.id.shareViaLinkPasswordLabel))
|
||||
.check(matches(withText(R.string.share_via_link_password_enforced_label)))
|
||||
onView(withId(R.id.saveButton)).check(matches(not(isEnabled())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passwordEnforcedReadOnlyFolders() {
|
||||
loadPublicShareDialogFragment(
|
||||
isFolder = true,
|
||||
capabilities = OC_CAPABILITY.copy(
|
||||
versionString = "10.1.1",
|
||||
filesSharingPublicSupportsUploadOnly = CapabilityBooleanType.TRUE,
|
||||
filesSharingPublicUpload = CapabilityBooleanType.TRUE,
|
||||
filesSharingPublicPasswordEnforcedReadOnly = CapabilityBooleanType.TRUE,
|
||||
filesSharingPublicPasswordEnforced = CapabilityBooleanType.TRUE
|
||||
)
|
||||
)
|
||||
|
||||
onView(withId(R.id.shareViaLinkEditPermissionReadOnly)).perform(scrollTo())
|
||||
onView(withId(R.id.shareViaLinkEditPermissionReadOnly)).check(matches(isDisplayed()))
|
||||
onView(withId(R.id.shareViaLinkEditPermissionReadOnly)).perform(click())
|
||||
onView(withId(R.id.shareViaLinkPasswordLabel))
|
||||
.check(matches(withText(R.string.share_via_link_password_enforced_label)))
|
||||
onView(withId(R.id.saveButton)).check(matches(not(isEnabled())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passwordNotEnforcedReadOnlyFolders() {
|
||||
loadPublicShareDialogFragment(
|
||||
isFolder = true,
|
||||
capabilities = OC_CAPABILITY.copy(
|
||||
versionString = "10.1.1",
|
||||
filesSharingPublicSupportsUploadOnly = CapabilityBooleanType.TRUE,
|
||||
filesSharingPublicUpload = CapabilityBooleanType.TRUE,
|
||||
filesSharingPublicPasswordEnforcedReadOnly = CapabilityBooleanType.FALSE,
|
||||
filesSharingPublicPasswordEnforced = CapabilityBooleanType.FALSE
|
||||
)
|
||||
)
|
||||
onView(withId(R.id.shareViaLinkEditPermissionReadOnly)).check(matches(isDisplayed()))
|
||||
onView(withId(R.id.shareViaLinkEditPermissionReadOnly)).perform(click())
|
||||
onView(withId(R.id.shareViaLinkPasswordLabel))
|
||||
.check(matches(withText(R.string.share_via_link_password_label)))
|
||||
onView(withId(R.id.saveButton)).check(matches(isEnabled()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passwordEnforcedReadWriteFolders() {
|
||||
loadPublicShareDialogFragment(
|
||||
isFolder = true,
|
||||
capabilities = OC_CAPABILITY.copy(
|
||||
versionString = "10.1.1",
|
||||
filesSharingPublicSupportsUploadOnly = CapabilityBooleanType.TRUE,
|
||||
filesSharingPublicUpload = CapabilityBooleanType.TRUE,
|
||||
filesSharingPublicPasswordEnforcedReadWrite = CapabilityBooleanType.TRUE,
|
||||
filesSharingPublicPasswordEnforced = CapabilityBooleanType.TRUE
|
||||
)
|
||||
)
|
||||
onView(withId(R.id.shareViaLinkEditPermissionReadAndWrite)).check(matches(isDisplayed()))
|
||||
onView(withId(R.id.shareViaLinkEditPermissionReadAndWrite)).perform(click())
|
||||
onView(withId(R.id.shareViaLinkPasswordLabel))
|
||||
.check(matches(withText(R.string.share_via_link_password_enforced_label)))
|
||||
onView(withId(R.id.saveButton)).check(matches(not(isEnabled())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passwordNotEnforcedReadWriteFolders() {
|
||||
loadPublicShareDialogFragment(
|
||||
isFolder = true,
|
||||
capabilities = OC_CAPABILITY.copy(
|
||||
versionString = "10.1.1",
|
||||
filesSharingPublicSupportsUploadOnly = CapabilityBooleanType.TRUE,
|
||||
filesSharingPublicUpload = CapabilityBooleanType.TRUE,
|
||||
filesSharingPublicPasswordEnforcedReadWrite = CapabilityBooleanType.FALSE,
|
||||
filesSharingPublicPasswordEnforced = CapabilityBooleanType.FALSE
|
||||
)
|
||||
)
|
||||
onView(withId(R.id.shareViaLinkEditPermissionReadAndWrite)).check(matches(isDisplayed()))
|
||||
onView(withId(R.id.shareViaLinkEditPermissionReadAndWrite)).perform(click())
|
||||
onView(withId(R.id.shareViaLinkPasswordLabel))
|
||||
.check(matches(withText(R.string.share_via_link_password_label)))
|
||||
onView(withId(R.id.saveButton)).check(matches(isEnabled()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passwordEnforcedUploadOnlyFolders() {
|
||||
loadPublicShareDialogFragment(
|
||||
isFolder = true,
|
||||
capabilities = OC_CAPABILITY.copy(
|
||||
versionString = "10.1.1",
|
||||
filesSharingPublicSupportsUploadOnly = CapabilityBooleanType.TRUE,
|
||||
filesSharingPublicUpload = CapabilityBooleanType.TRUE,
|
||||
filesSharingPublicPasswordEnforcedUploadOnly = CapabilityBooleanType.TRUE,
|
||||
filesSharingPublicPasswordEnforced = CapabilityBooleanType.FALSE
|
||||
)
|
||||
)
|
||||
onView(withId(R.id.shareViaLinkEditPermissionUploadFiles)).check(matches(isDisplayed()))
|
||||
onView(withId(R.id.shareViaLinkEditPermissionUploadFiles)).perform(click())
|
||||
onView(withId(R.id.shareViaLinkPasswordLabel))
|
||||
.check(matches(withText(R.string.share_via_link_password_enforced_label)))
|
||||
onView(withId(R.id.saveButton)).check(matches(not(isEnabled())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passwordNotEnforcedUploadOnlyFolders() {
|
||||
loadPublicShareDialogFragment(
|
||||
isFolder = true,
|
||||
capabilities = OC_CAPABILITY.copy(
|
||||
versionString = "10.1.1",
|
||||
filesSharingPublicSupportsUploadOnly = CapabilityBooleanType.TRUE,
|
||||
filesSharingPublicUpload = CapabilityBooleanType.TRUE,
|
||||
filesSharingPublicPasswordEnforcedUploadOnly = CapabilityBooleanType.FALSE,
|
||||
filesSharingPublicPasswordEnforced = CapabilityBooleanType.FALSE
|
||||
)
|
||||
)
|
||||
onView(withId(R.id.shareViaLinkEditPermissionUploadFiles)).check(matches(isDisplayed()))
|
||||
onView(withId(R.id.shareViaLinkEditPermissionUploadFiles)).perform(click())
|
||||
onView(withId(R.id.shareViaLinkPasswordLabel))
|
||||
.check(matches(withText(R.string.share_via_link_password_label)))
|
||||
onView(withId(R.id.saveButton)).check(matches(isEnabled()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passwordEnforcedClearErrorMessageIfSwitchesToNotEnforced() {
|
||||
val commonError = "Common error"
|
||||
|
||||
//One permission with password enforced. Error is cleaned after switching permission
|
||||
//to a non-forced one
|
||||
loadPublicShareDialogFragment(
|
||||
isFolder = true,
|
||||
capabilities = OC_CAPABILITY.copy(
|
||||
versionString = "10.1.1",
|
||||
filesSharingPublicSupportsUploadOnly = CapabilityBooleanType.TRUE,
|
||||
filesSharingPublicUpload = CapabilityBooleanType.TRUE,
|
||||
filesSharingPublicPasswordEnforcedUploadOnly = CapabilityBooleanType.FALSE,
|
||||
filesSharingPublicPasswordEnforcedReadOnly = CapabilityBooleanType.FALSE,
|
||||
filesSharingPublicPasswordEnforced = CapabilityBooleanType.TRUE
|
||||
)
|
||||
)
|
||||
|
||||
onView(withId(R.id.saveButton)).perform(scrollTo())
|
||||
onView(withId(R.id.saveButton)).perform(click())
|
||||
|
||||
publicShareCreationStatus.postValue(
|
||||
Event(
|
||||
UIResult.Error(
|
||||
error = Throwable(commonError)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
onView(withId(R.id.public_link_error_message)).perform(scrollTo())
|
||||
onView(withText(commonError)).check(matches(isDisplayed()))
|
||||
|
||||
onView(withId(R.id.shareViaLinkEditPermissionUploadFiles)).perform(scrollTo(), click())
|
||||
|
||||
onView(withText(commonError)).check(matches(not(isDisplayed())))
|
||||
onView(withId(R.id.saveButton)).check(matches(not(isEnabled())))
|
||||
}
|
||||
|
||||
private fun loadPublicShareDialogFragment(
|
||||
isFolder: Boolean = false,
|
||||
capabilities: OCCapability = OC_CAPABILITY
|
||||
) {
|
||||
val file = if (isFolder) OC_FOLDER else OC_FILE
|
||||
|
||||
val publicShareDialogFragment = PublicShareDialogFragment.newInstanceToCreate(
|
||||
file,
|
||||
OC_ACCOUNT,
|
||||
"DOC_12112018.jpg link"
|
||||
)
|
||||
|
||||
ActivityScenario.launch(TestShareFileActivity::class.java).onActivity {
|
||||
it.startFragment(publicShareDialogFragment)
|
||||
}
|
||||
|
||||
capabilitiesLiveData.postValue(
|
||||
Event(UIResult.Success(capabilities))
|
||||
)
|
||||
}
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David González Verdugo
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.sharing.shares.ui
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.matcher.ViewMatchers
|
||||
import androidx.test.espresso.matcher.ViewMatchers.Visibility.VISIBLE
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isChecked
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withHint
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.domain.capabilities.model.OCCapability
|
||||
import eu.qsfera.android.domain.sharing.shares.model.ShareType
|
||||
import eu.qsfera.android.domain.utils.Event
|
||||
import eu.qsfera.android.lib.resources.shares.RemoteShare
|
||||
import eu.qsfera.android.presentation.common.UIResult
|
||||
import eu.qsfera.android.presentation.sharing.shares.PublicShareDialogFragment
|
||||
import eu.qsfera.android.presentation.capabilities.CapabilityViewModel
|
||||
import eu.qsfera.android.presentation.sharing.ShareViewModel
|
||||
import eu.qsfera.android.testutil.OC_ACCOUNT
|
||||
import eu.qsfera.android.testutil.OC_FILE
|
||||
import eu.qsfera.android.testutil.OC_SHARE
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.koin.dsl.module
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.GregorianCalendar
|
||||
import java.util.TimeZone
|
||||
|
||||
class PublicShareEditionDialogFragmentTest {
|
||||
private val capabilityViewModel = mockk<CapabilityViewModel>(relaxed = true)
|
||||
private val capabilitiesLiveData = MutableLiveData<Event<UIResult<OCCapability>>>()
|
||||
private val shareViewModel = mockk<ShareViewModel>(relaxed = true)
|
||||
|
||||
private val expirationDate = 1556575200000 // GMT: Monday, April 29, 2019 10:00:00 PM
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
every { capabilityViewModel.capabilities } returns capabilitiesLiveData
|
||||
|
||||
stopKoin()
|
||||
|
||||
startKoin {
|
||||
androidContext(ApplicationProvider.getApplicationContext())
|
||||
allowOverride(override = true)
|
||||
modules(
|
||||
module {
|
||||
viewModel {
|
||||
capabilityViewModel
|
||||
}
|
||||
viewModel {
|
||||
shareViewModel
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val publicShareDialogFragment = PublicShareDialogFragment.newInstanceToUpdate(
|
||||
OC_FILE,
|
||||
OC_ACCOUNT,
|
||||
OC_SHARE.copy(
|
||||
shareType = ShareType.PUBLIC_LINK,
|
||||
shareWith = "user",
|
||||
name = "Docs link",
|
||||
permissions = RemoteShare.CREATE_PERMISSION_FLAG,
|
||||
expirationDate = expirationDate,
|
||||
isFolder = true
|
||||
)
|
||||
)
|
||||
|
||||
ActivityScenario.launch(TestShareFileActivity::class.java).onActivity {
|
||||
it.startFragment(publicShareDialogFragment)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showEditionDialogTitle() {
|
||||
onView(withId(R.id.publicShareDialogTitle)).check(matches(withText(R.string.share_via_link_edit_title)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkLinkNameSet() {
|
||||
onView(withText(R.string.share_via_link_name_label)).check(matches(ViewMatchers.isDisplayed()))
|
||||
onView(withId(R.id.shareViaLinkNameValue)).check(matches(withText("Docs link")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkUploadOnly() {
|
||||
onView(withId(R.id.shareViaLinkEditPermissionUploadFiles)).check(matches(isChecked()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkPasswordSet() {
|
||||
onView(withId(R.id.shareViaLinkPasswordLabel)).check(matches(withText(R.string.share_via_link_password_label)))
|
||||
onView(withId(R.id.shareViaLinkPasswordSwitch)).check(matches(withEffectiveVisibility(VISIBLE)))
|
||||
onView(withId(R.id.shareViaLinkPasswordValue)).check(matches(withEffectiveVisibility(VISIBLE)))
|
||||
onView(withId(R.id.shareViaLinkPasswordValue)).check(matches(withHint(R.string.share_via_link_default_password)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkExpirationDateSet() {
|
||||
val calendar = GregorianCalendar()
|
||||
calendar.timeInMillis = expirationDate
|
||||
|
||||
val formatter: DateFormat = SimpleDateFormat.getDateInstance()
|
||||
formatter.timeZone = TimeZone.getDefault()
|
||||
|
||||
val time = formatter.format(calendar.time)
|
||||
|
||||
onView(withId(R.id.shareViaLinkExpirationLabel)).check(matches(withText(R.string.share_via_link_expiration_date_label)))
|
||||
onView(withId(R.id.shareViaLinkExpirationSwitch)).check(matches(withEffectiveVisibility(VISIBLE)))
|
||||
onView(withId(R.id.shareViaLinkExpirationValue)).check(matches(withEffectiveVisibility(VISIBLE)))
|
||||
onView(withId(R.id.shareViaLinkExpirationValue)).check(matches(withText(time)))
|
||||
}
|
||||
}
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David González Verdugo
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.sharing.shares.ui
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.matcher.ViewMatchers
|
||||
import androidx.test.espresso.matcher.ViewMatchers.hasSibling
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withTagValue
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.domain.capabilities.model.CapabilityBooleanType
|
||||
import eu.qsfera.android.domain.capabilities.model.OCCapability
|
||||
import eu.qsfera.android.domain.sharing.shares.model.OCShare
|
||||
import eu.qsfera.android.domain.sharing.shares.model.ShareType
|
||||
import eu.qsfera.android.domain.utils.Event
|
||||
import eu.qsfera.android.presentation.common.UIResult
|
||||
import eu.qsfera.android.presentation.sharing.ShareFileFragment
|
||||
import eu.qsfera.android.presentation.capabilities.CapabilityViewModel
|
||||
import eu.qsfera.android.presentation.sharing.ShareViewModel
|
||||
import eu.qsfera.android.testutil.OC_ACCOUNT
|
||||
import eu.qsfera.android.testutil.OC_CAPABILITY
|
||||
import eu.qsfera.android.testutil.OC_FILE
|
||||
import eu.qsfera.android.testutil.OC_SHARE
|
||||
import eu.qsfera.android.utils.matchers.assertVisibility
|
||||
import eu.qsfera.android.utils.matchers.isDisplayed
|
||||
import eu.qsfera.android.utils.matchers.withText
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.hamcrest.CoreMatchers
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.koin.dsl.module
|
||||
|
||||
@Ignore
|
||||
class ShareFileFragmentTest {
|
||||
private val capabilityViewModel = mockk<CapabilityViewModel>(relaxed = true)
|
||||
private val capabilitiesLiveData = MutableLiveData<Event<UIResult<OCCapability>>>()
|
||||
private val shareViewModel = mockk<ShareViewModel>(relaxed = true)
|
||||
private val sharesLiveData = MutableLiveData<Event<UIResult<List<OCShare>>>>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
every { capabilityViewModel.capabilities } returns capabilitiesLiveData
|
||||
every { shareViewModel.shares } returns sharesLiveData
|
||||
|
||||
stopKoin()
|
||||
|
||||
startKoin {
|
||||
androidContext(ApplicationProvider.getApplicationContext())
|
||||
allowOverride(override = true)
|
||||
modules(
|
||||
module {
|
||||
viewModel {
|
||||
capabilityViewModel
|
||||
}
|
||||
viewModel {
|
||||
shareViewModel
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showHeader() {
|
||||
loadShareFileFragment()
|
||||
onView(withId(R.id.shareFileName)).check(matches(withText(OC_FILE.fileName)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fileSizeVisible() {
|
||||
loadShareFileFragment()
|
||||
R.id.shareFileSize.isDisplayed(displayed = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showPrivateLink() {
|
||||
loadShareFileFragment()
|
||||
R.id.getPrivateLinkButton.isDisplayed(displayed = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hidePrivateLink() {
|
||||
loadShareFileFragment(capabilities = OC_CAPABILITY.copy(filesPrivateLinks = CapabilityBooleanType.FALSE))
|
||||
R.id.getPrivateLinkButton.isDisplayed(displayed = false)
|
||||
}
|
||||
|
||||
/******************************************************************************************************
|
||||
******************************************* PRIVATE SHARES *******************************************
|
||||
******************************************************************************************************/
|
||||
|
||||
private var userSharesList = listOf(
|
||||
OC_SHARE.copy(sharedWithDisplayName = "Batman"),
|
||||
OC_SHARE.copy(sharedWithDisplayName = "Joker")
|
||||
)
|
||||
|
||||
private var groupSharesList = listOf(
|
||||
OC_SHARE.copy(
|
||||
shareType = ShareType.GROUP,
|
||||
sharedWithDisplayName = "Suicide Squad"
|
||||
),
|
||||
OC_SHARE.copy(
|
||||
shareType = ShareType.GROUP,
|
||||
sharedWithDisplayName = "Avengers"
|
||||
)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun showUsersAndGroupsSectionTitle() {
|
||||
loadShareFileFragment(shares = userSharesList)
|
||||
onView(withText(R.string.share_with_user_section_title)).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showNoPrivateShares() {
|
||||
loadShareFileFragment(shares = listOf())
|
||||
onView(withText(R.string.share_no_users)).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showUserShares() {
|
||||
loadShareFileFragment(shares = userSharesList)
|
||||
onView(withText("Batman")).check(matches(isDisplayed()))
|
||||
onView(withText("Batman")).check(matches(hasSibling(withId(R.id.unshareButton))))
|
||||
.check(matches(isDisplayed()))
|
||||
onView(withText("Batman")).check(matches(hasSibling(withId(R.id.editShareButton))))
|
||||
.check(matches(isDisplayed()))
|
||||
onView(withText("Joker")).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showGroupShares() {
|
||||
loadShareFileFragment(shares = listOf(groupSharesList.first()))
|
||||
onView(withText("Suicide Squad (group)")).check(matches(isDisplayed()))
|
||||
onView(withText("Suicide Squad (group)")).check(matches(hasSibling(withId(R.id.icon))))
|
||||
.check(matches(isDisplayed()))
|
||||
onView(withTagValue(CoreMatchers.equalTo(R.drawable.ic_group))).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
/******************************************************************************************************
|
||||
******************************************* PUBLIC SHARES ********************************************
|
||||
******************************************************************************************************/
|
||||
|
||||
private var publicShareList = listOf(
|
||||
OC_SHARE.copy(
|
||||
shareType = ShareType.PUBLIC_LINK,
|
||||
path = "/Photos/image.jpg",
|
||||
isFolder = false,
|
||||
name = "Image link",
|
||||
shareLink = "http://server:port/s/1"
|
||||
),
|
||||
OC_SHARE.copy(
|
||||
shareType = ShareType.PUBLIC_LINK,
|
||||
path = "/Photos/image.jpg",
|
||||
isFolder = false,
|
||||
name = "Image link 2",
|
||||
shareLink = "http://server:port/s/2"
|
||||
),
|
||||
OC_SHARE.copy(
|
||||
shareType = ShareType.PUBLIC_LINK,
|
||||
path = "/Photos/image.jpg",
|
||||
isFolder = false,
|
||||
name = "Image link 3",
|
||||
shareLink = "http://server:port/s/3"
|
||||
)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun showNoPublicShares() {
|
||||
loadShareFileFragment(shares = listOf())
|
||||
onView(withText(R.string.share_no_public_links)).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showPublicShares() {
|
||||
loadShareFileFragment(shares = publicShareList)
|
||||
onView(withText("Image link")).check(matches(isDisplayed()))
|
||||
onView(withText("Image link")).check(matches(hasSibling(withId(R.id.getPublicLinkButton))))
|
||||
.check(matches(isDisplayed()))
|
||||
onView(withText("Image link")).check(matches(hasSibling(withId(R.id.deletePublicLinkButton))))
|
||||
.check(matches(isDisplayed()))
|
||||
onView(withText("Image link")).check(matches(hasSibling(withId(R.id.editPublicLinkButton))))
|
||||
.check(matches(isDisplayed()))
|
||||
onView(withText("Image link 2")).check(matches(isDisplayed()))
|
||||
onView(withText("Image link 3")).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showPublicSharesSharingEnabled() {
|
||||
loadShareFileFragment(
|
||||
capabilities = OC_CAPABILITY.copy(filesSharingPublicEnabled = CapabilityBooleanType.TRUE),
|
||||
shares = publicShareList
|
||||
)
|
||||
|
||||
onView(withText("Image link")).check(matches(isDisplayed()))
|
||||
onView(withText("Image link 2")).check(matches(isDisplayed()))
|
||||
onView(withText("Image link 3")).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hidePublicSharesSharingDisabled() {
|
||||
loadShareFileFragment(
|
||||
capabilities = OC_CAPABILITY.copy(filesSharingPublicEnabled = CapabilityBooleanType.FALSE),
|
||||
shares = publicShareList
|
||||
)
|
||||
|
||||
R.id.shareViaLinkSection.assertVisibility(ViewMatchers.Visibility.GONE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createPublicShareMultipleCapability() {
|
||||
loadShareFileFragment(
|
||||
capabilities = OC_CAPABILITY.copy(
|
||||
versionString = "10.1.1",
|
||||
filesSharingPublicMultiple = CapabilityBooleanType.TRUE
|
||||
),
|
||||
shares = listOf(publicShareList[0])
|
||||
)
|
||||
|
||||
R.id.addPublicLinkButton.assertVisibility(ViewMatchers.Visibility.VISIBLE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cannotCreatePublicShareMultipleCapability() {
|
||||
loadShareFileFragment(
|
||||
capabilities = OC_CAPABILITY.copy(
|
||||
versionString = "10.1.1",
|
||||
filesSharingPublicMultiple = CapabilityBooleanType.FALSE
|
||||
),
|
||||
shares = listOf(publicShareList[0])
|
||||
)
|
||||
|
||||
R.id.addPublicLinkButton.assertVisibility(ViewMatchers.Visibility.INVISIBLE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cannotCreatePublicShareServerCapability() {
|
||||
loadShareFileFragment(
|
||||
capabilities = OC_CAPABILITY.copy(
|
||||
versionString = "9.3.1"
|
||||
),
|
||||
shares = listOf(publicShareList[0])
|
||||
)
|
||||
|
||||
R.id.addPublicLinkButton.assertVisibility(ViewMatchers.Visibility.INVISIBLE)
|
||||
}
|
||||
|
||||
/******************************************************************************************************
|
||||
*********************************************** COMMON ***********************************************
|
||||
******************************************************************************************************/
|
||||
|
||||
@Test
|
||||
fun hideSharesSharingApiDisabled() {
|
||||
loadShareFileFragment(
|
||||
capabilities = OC_CAPABILITY.copy(
|
||||
filesSharingApiEnabled = CapabilityBooleanType.FALSE
|
||||
)
|
||||
)
|
||||
R.id.shareWithUsersSection.assertVisibility(ViewMatchers.Visibility.GONE)
|
||||
|
||||
R.id.shareViaLinkSection.assertVisibility(ViewMatchers.Visibility.GONE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showError() {
|
||||
loadShareFileFragment(
|
||||
sharesUIResult = UIResult.Error(
|
||||
error = Throwable("It was not possible to retrieve the shares from the server")
|
||||
)
|
||||
)
|
||||
com.google.android.material.R.id.snackbar_text.withText(R.string.get_shares_error)
|
||||
}
|
||||
|
||||
private fun loadShareFileFragment(
|
||||
capabilities: OCCapability = OC_CAPABILITY,
|
||||
capabilitiesEvent: Event<UIResult<OCCapability>> = Event(UIResult.Success(capabilities)),
|
||||
shares: List<OCShare> = listOf(OC_SHARE),
|
||||
sharesUIResult: UIResult<List<OCShare>> = UIResult.Success(shares)
|
||||
) {
|
||||
val shareFileFragment = ShareFileFragment.newInstance(
|
||||
OC_FILE,
|
||||
OC_ACCOUNT
|
||||
)
|
||||
|
||||
ActivityScenario.launch(TestShareFileActivity::class.java).onActivity {
|
||||
it.startFragment(shareFileFragment)
|
||||
}
|
||||
|
||||
capabilitiesLiveData.postValue(capabilitiesEvent)
|
||||
sharesLiveData.postValue(Event(sharesUIResult))
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David González Verdugo
|
||||
* @author Jesus Recio Rincon
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.sharing.shares.ui
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.domain.capabilities.model.OCCapability
|
||||
import eu.qsfera.android.domain.sharing.shares.model.OCShare
|
||||
import eu.qsfera.android.domain.utils.Event
|
||||
import eu.qsfera.android.presentation.common.UIResult
|
||||
import eu.qsfera.android.presentation.sharing.ShareFileFragment
|
||||
import eu.qsfera.android.presentation.capabilities.CapabilityViewModel
|
||||
import eu.qsfera.android.presentation.sharing.ShareViewModel
|
||||
import eu.qsfera.android.testutil.OC_ACCOUNT
|
||||
import eu.qsfera.android.testutil.OC_CAPABILITY
|
||||
import eu.qsfera.android.testutil.OC_FOLDER
|
||||
import eu.qsfera.android.testutil.OC_SHARE
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.hamcrest.CoreMatchers.not
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.koin.dsl.module
|
||||
|
||||
class ShareFolderFragmentTest {
|
||||
private val capabilityViewModel = mockk<CapabilityViewModel>(relaxed = true)
|
||||
private val capabilitiesLiveData = MutableLiveData<Event<UIResult<OCCapability>>>()
|
||||
private val shareViewModel = mockk<ShareViewModel>(relaxed = true)
|
||||
private val sharesLiveData = MutableLiveData<Event<UIResult<List<OCShare>>>>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
every { capabilityViewModel.capabilities } returns capabilitiesLiveData
|
||||
every { shareViewModel.shares } returns sharesLiveData
|
||||
|
||||
stopKoin()
|
||||
|
||||
startKoin {
|
||||
androidContext(ApplicationProvider.getApplicationContext())
|
||||
allowOverride(override = true)
|
||||
modules(
|
||||
module {
|
||||
viewModel {
|
||||
capabilityViewModel
|
||||
}
|
||||
viewModel {
|
||||
shareViewModel
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val shareFileFragment = ShareFileFragment.newInstance(
|
||||
OC_FOLDER.copy(privateLink = null),
|
||||
OC_ACCOUNT
|
||||
)
|
||||
|
||||
ActivityScenario.launch(TestShareFileActivity::class.java).onActivity {
|
||||
it.startFragment(shareFileFragment)
|
||||
}
|
||||
|
||||
capabilitiesLiveData.postValue(Event(UIResult.Success(OC_CAPABILITY)))
|
||||
|
||||
sharesLiveData.postValue(Event(UIResult.Success(listOf(OC_SHARE))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun folderSizeVisible() {
|
||||
onView(withId(R.id.shareFileSize)).check(matches(not(isDisplayed())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hidePrivateLink() {
|
||||
onView(withId(R.id.getPrivateLinkButton)).check(matches(not(isDisplayed())))
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David González Verdugo
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.sharing.shares.ui
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.commit
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.domain.files.model.OCFile
|
||||
import eu.qsfera.android.domain.sharing.shares.model.OCShare
|
||||
import eu.qsfera.android.presentation.sharing.ShareFragmentListener
|
||||
import eu.qsfera.android.services.OperationsService
|
||||
import eu.qsfera.android.testing.SingleFragmentActivity
|
||||
import eu.qsfera.android.ui.fragment.FileFragment.ContainerActivity
|
||||
import eu.qsfera.android.ui.helpers.FileOperationsHelper
|
||||
|
||||
class TestShareFileActivity : SingleFragmentActivity(), ShareFragmentListener, ContainerActivity {
|
||||
fun startFragment(fragment: Fragment) {
|
||||
supportFragmentManager.commit(allowStateLoss = true) {
|
||||
add(R.id.container, fragment, TEST_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
fun getTestFragment(): Fragment? = supportFragmentManager.findFragmentByTag(TEST_FRAGMENT_TAG)
|
||||
|
||||
override fun copyOrSendPrivateLink(file: OCFile) {
|
||||
}
|
||||
|
||||
override fun deleteShare(remoteId: String) {
|
||||
}
|
||||
|
||||
override fun showLoading() {
|
||||
}
|
||||
|
||||
override fun dismissLoading() {
|
||||
}
|
||||
|
||||
override fun showAddPublicShare(defaultLinkName: String) {
|
||||
}
|
||||
|
||||
override fun showEditPublicShare(share: OCShare) {
|
||||
}
|
||||
|
||||
override fun showRemoveShare(share: OCShare) {
|
||||
}
|
||||
|
||||
override fun copyOrSendPublicLink(share: OCShare) {
|
||||
}
|
||||
|
||||
override fun showSearchUsersAndGroups() {
|
||||
}
|
||||
|
||||
override fun showEditPrivateShare(share: OCShare) {
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TEST_FRAGMENT_TAG = "TEST FRAGMENT"
|
||||
}
|
||||
|
||||
override fun getOperationsServiceBinder(): OperationsService.OperationsServiceBinder {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun getFileOperationsHelper(): FileOperationsHelper {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun showDetails(file: OCFile?) {
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David Crespo Ríos
|
||||
* Copyright (C) 2022 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.ui.activity
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.presentation.releasenotes.ReleaseNotesActivity
|
||||
import eu.qsfera.android.presentation.releasenotes.ReleaseNotesViewModel
|
||||
import eu.qsfera.android.utils.click
|
||||
import eu.qsfera.android.utils.matchers.assertChildCount
|
||||
import eu.qsfera.android.utils.matchers.isDisplayed
|
||||
import eu.qsfera.android.utils.matchers.withText
|
||||
import eu.qsfera.android.utils.releaseNotesList
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.koin.dsl.module
|
||||
|
||||
class ReleaseNotesActivityTest {
|
||||
private lateinit var activityScenario: ActivityScenario<ReleaseNotesActivity>
|
||||
private lateinit var context: Context
|
||||
|
||||
private lateinit var releaseNotesViewModel: ReleaseNotesViewModel
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
releaseNotesViewModel = mockk(relaxed = true)
|
||||
|
||||
stopKoin()
|
||||
|
||||
startKoin {
|
||||
context
|
||||
allowOverride(override = true)
|
||||
modules(
|
||||
module {
|
||||
viewModel {
|
||||
releaseNotesViewModel
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
every { releaseNotesViewModel.getReleaseNotes() } returns releaseNotesList
|
||||
|
||||
val intent = Intent(context, ReleaseNotesActivity::class.java)
|
||||
activityScenario = ActivityScenario.launch(intent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun releaseNotesView() {
|
||||
val header = String.format(
|
||||
context.getString(R.string.release_notes_header),
|
||||
context.getString(R.string.app_name)
|
||||
)
|
||||
|
||||
val footer = String.format(
|
||||
context.getString(R.string.release_notes_footer),
|
||||
context.getString(R.string.app_name)
|
||||
)
|
||||
|
||||
with(R.id.txtHeader) {
|
||||
isDisplayed(true)
|
||||
withText(header)
|
||||
}
|
||||
|
||||
R.id.releaseNotes.isDisplayed(true)
|
||||
|
||||
with(R.id.txtFooter) {
|
||||
isDisplayed(true)
|
||||
withText(footer)
|
||||
}
|
||||
|
||||
R.id.btnProceed.isDisplayed(true)
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun releaseNotesProceedButton() {
|
||||
R.id.btnProceed.click()
|
||||
|
||||
assertEquals(activityScenario.result.resultCode, Activity.RESULT_OK)
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun test_childCount() {
|
||||
R.id.releaseNotes.assertChildCount(3)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Abel García de Prada
|
||||
*
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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 eu.qsfera.android.utils
|
||||
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.action.ViewActions
|
||||
import androidx.test.espresso.action.ViewActions.scrollTo
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
|
||||
fun Int.typeText(text: String) {
|
||||
onView(withId(this)).perform(scrollTo(), ViewActions.typeText(text))
|
||||
}
|
||||
|
||||
fun Int.replaceText(text: String) {
|
||||
onView(withId(this)).perform(scrollTo(), ViewActions.replaceText(text))
|
||||
}
|
||||
|
||||
fun Int.scrollAndClick() {
|
||||
onView(withId(this)).perform(scrollTo(), ViewActions.click())
|
||||
}
|
||||
|
||||
fun Int.click() {
|
||||
onView(withId(this)).perform(ViewActions.click())
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
*
|
||||
* Copyright (C) 2021 ownCloud GmbH.
|
||||
* <p>
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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 eu.qsfera.android.utils
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Instrumentation
|
||||
import android.content.Intent
|
||||
import androidx.test.espresso.intent.Intents.intending
|
||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
|
||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
|
||||
|
||||
fun mockIntent(
|
||||
extras: Pair<String, String>,
|
||||
resultCode: Int = Activity.RESULT_OK,
|
||||
action: String
|
||||
) {
|
||||
val result = Intent()
|
||||
result.putExtra(extras.first, extras.second)
|
||||
val intentResult = Instrumentation.ActivityResult(resultCode, result)
|
||||
intending(hasAction(action)).respondWith(intentResult)
|
||||
}
|
||||
|
||||
@JvmName("mockIntentNoExtras")
|
||||
fun mockIntent(
|
||||
resultCode: Int = Activity.RESULT_OK,
|
||||
action: String
|
||||
) {
|
||||
val result = Intent()
|
||||
val intentResult = Instrumentation.ActivityResult(resultCode, result)
|
||||
intending(hasAction(action)).respondWith(intentResult)
|
||||
}
|
||||
|
||||
fun mockIntentToComponent(
|
||||
resultCode: Int = Activity.RESULT_OK,
|
||||
packageName: String
|
||||
) {
|
||||
val result = Intent()
|
||||
val intentResult = Instrumentation.ActivityResult(resultCode, result)
|
||||
intending(hasComponent(packageName)).respondWith(intentResult)
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* Copyright (C) 2022 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.utils
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.test.runner.AndroidJUnitRunner
|
||||
import com.github.tmurakami.dexopener.DexOpener
|
||||
|
||||
/**
|
||||
* We need to use DexOpener for executing instrumented tests on <P Android devices,
|
||||
* as Mockk documentation suggests https://mockk.io/ANDROID.html
|
||||
*/
|
||||
class OCTestAndroidJUnitRunner : AndroidJUnitRunner() {
|
||||
override fun newApplication(cl: ClassLoader, className: String, context: Context): Application {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||
DexOpener.install(this)
|
||||
}
|
||||
return super.newApplication(cl, className, context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David González Verdugo
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.utils
|
||||
|
||||
enum class Permissions(val value: Int) {
|
||||
READ_PERMISSIONS(1),
|
||||
EDIT_PERMISSIONS(3),
|
||||
SHARE_PERMISSIONS(17),
|
||||
ALL_PERMISSIONS(19),
|
||||
|
||||
// FOLDERS
|
||||
EDIT_CREATE_PERMISSIONS(5),
|
||||
EDIT_CREATE_CHANGE_PERMISSIONS(7),
|
||||
EDIT_CREATE_DELETE_PERMISSIONS(13),
|
||||
EDIT_CREATE_CHANGE_DELETE_PERMISSIONS(15),
|
||||
EDIT_CHANGE_PERMISSIONS(3),
|
||||
EDIT_CHANGE_DELETE_PERMISSIONS(11),
|
||||
EDIT_DELETE_PERMISSIONS(9),
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David Crespo Ríos
|
||||
* Copyright (C) 2022 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.utils
|
||||
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.presentation.releasenotes.ReleaseNote
|
||||
import eu.qsfera.android.presentation.releasenotes.ReleaseNoteType
|
||||
|
||||
val releaseNotesList = listOf(
|
||||
ReleaseNote(
|
||||
title = R.string.release_notes_header,
|
||||
subtitle = R.string.release_notes_footer,
|
||||
type = ReleaseNoteType.BUGFIX
|
||||
),
|
||||
ReleaseNote(
|
||||
title = R.string.release_notes_header,
|
||||
subtitle = R.string.release_notes_footer,
|
||||
type = ReleaseNoteType.BUGFIX
|
||||
),
|
||||
ReleaseNote(
|
||||
title = R.string.release_notes_header,
|
||||
subtitle = R.string.release_notes_footer,
|
||||
type = ReleaseNoteType.ENHANCEMENT
|
||||
)
|
||||
)
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Abel García de Prada
|
||||
*
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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 eu.qsfera.android.utils.matchers
|
||||
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.test.espresso.Espresso
|
||||
import androidx.test.espresso.assertion.ViewAssertions
|
||||
import androidx.test.espresso.matcher.BoundedMatcher
|
||||
import androidx.test.espresso.matcher.RootMatchers
|
||||
import androidx.test.espresso.matcher.ViewMatchers
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.presentation.common.BottomSheetFragmentItemView
|
||||
import org.hamcrest.Description
|
||||
import org.hamcrest.Matcher
|
||||
|
||||
fun Int.bsfItemWithTitle(@StringRes title: Int, @ColorRes tintColor: Int?) {
|
||||
Espresso.onView(ViewMatchers.withId(this)).inRoot(RootMatchers.isDialog())
|
||||
.check(ViewAssertions.matches(withTitle(title, tintColor)))
|
||||
}
|
||||
|
||||
fun Int.bsfItemWithIcon(@DrawableRes drawable: Int, @ColorRes tintColor: Int?) {
|
||||
Espresso.onView(ViewMatchers.withId(this)).inRoot(RootMatchers.isDialog())
|
||||
.check(ViewAssertions.matches(withIcon(drawable, tintColor)))
|
||||
}
|
||||
|
||||
private fun withTitle(@StringRes title: Int, @ColorRes tintColor: Int?): Matcher<View> =
|
||||
object : BoundedMatcher<View, BottomSheetFragmentItemView>(BottomSheetFragmentItemView::class.java) {
|
||||
|
||||
override fun describeTo(description: Description) {
|
||||
description.appendText("BottomSheetFragmentItemView with text: $title")
|
||||
tintColor?.let { description.appendText(" and tint color id: $tintColor") }
|
||||
}
|
||||
|
||||
override fun matchesSafely(item: BottomSheetFragmentItemView): Boolean {
|
||||
val itemTitleView = item.findViewById<TextView>(R.id.item_title)
|
||||
val textMatches = withText(title).matches(itemTitleView)
|
||||
val textColorMatches = tintColor?.let { withTextColor(tintColor).matches(itemTitleView) } ?: true
|
||||
return textMatches && textColorMatches
|
||||
}
|
||||
}
|
||||
|
||||
private fun withIcon(@DrawableRes drawable: Int, @ColorRes tintColor: Int?): Matcher<View> =
|
||||
object : BoundedMatcher<View, BottomSheetFragmentItemView>(BottomSheetFragmentItemView::class.java) {
|
||||
|
||||
override fun describeTo(description: Description) {
|
||||
description.appendText("BottomSheetFragmentItemView with icon: $drawable")
|
||||
tintColor?.let { description.appendText(" and tint color id: $tintColor") }
|
||||
}
|
||||
|
||||
override fun matchesSafely(item: BottomSheetFragmentItemView): Boolean {
|
||||
val itemIconView = item.findViewById<ImageView>(R.id.item_icon)
|
||||
return withDrawable(drawable, tintColor).matches(itemIconView)
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Christian Schabesberger
|
||||
* Copyright (C) 2021 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.utils.matchers
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.test.espresso.matcher.BoundedMatcher
|
||||
import org.hamcrest.Matcher
|
||||
import org.hamcrest.TypeSafeMatcher
|
||||
import org.hamcrest.Description
|
||||
|
||||
fun withChildViewCount(count: Int, childMatcher: Matcher<View>): Matcher<View> {
|
||||
return object : BoundedMatcher<View, ViewGroup>(ViewGroup::class.java) {
|
||||
override fun matchesSafely(viewGroup: ViewGroup): Boolean {
|
||||
var matchCount = 0
|
||||
for (i in 0 until viewGroup.childCount) {
|
||||
if (childMatcher.matches(viewGroup.getChildAt(i))) {
|
||||
matchCount++
|
||||
}
|
||||
}
|
||||
return matchCount == count
|
||||
}
|
||||
|
||||
override fun describeTo(description: Description?) {
|
||||
description?.appendText("ViewGroup with child-count=$count and")
|
||||
childMatcher.describeTo(description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun nthChildOf(parentMatcher: Matcher<View>, childPosition: Int): Matcher<View> {
|
||||
return object : TypeSafeMatcher<View>() {
|
||||
override fun matchesSafely(view: View): Boolean {
|
||||
if (view.parent !is ViewGroup) {
|
||||
return parentMatcher.matches(view.parent)
|
||||
}
|
||||
val group = view.parent as ViewGroup
|
||||
return parentMatcher.matches(view.parent) && group.getChildAt(childPosition) == view
|
||||
}
|
||||
|
||||
override fun describeTo(description: Description) {
|
||||
description.appendText("with $childPosition child view of type parentMatcher")
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Abel García de Prada
|
||||
*
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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 eu.qsfera.android.utils.matchers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import org.hamcrest.Description
|
||||
import org.hamcrest.TypeSafeMatcher
|
||||
|
||||
fun withDrawable(
|
||||
@DrawableRes id: Int,
|
||||
@ColorRes tint: Int? = null,
|
||||
tintMode: PorterDuff.Mode = PorterDuff.Mode.SRC_IN
|
||||
) = object : TypeSafeMatcher<View>() {
|
||||
override fun describeTo(description: Description) {
|
||||
description.appendText("ImageView with drawable same as drawable with id $id")
|
||||
tint?.let { description.appendText(", tint color id: $tint, mode: $tintMode") }
|
||||
}
|
||||
|
||||
override fun matchesSafely(view: View): Boolean {
|
||||
val context = view.context
|
||||
val tintColor = tint?.toColor(context)
|
||||
val expectedBitmap = context.getDrawable(id)?.tinted(tintColor, tintMode)?.toBitmap()
|
||||
|
||||
return view is ImageView && view.drawable.toBitmap().sameAs(expectedBitmap)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int.toColor(context: Context) = ContextCompat.getColor(context, this)
|
||||
|
||||
private fun Drawable.tinted(@ColorInt tintColor: Int? = null, tintMode: PorterDuff.Mode = PorterDuff.Mode.SRC_IN) =
|
||||
apply {
|
||||
setTintList(tintColor?.toColorStateList())
|
||||
setTintMode(tintMode)
|
||||
}
|
||||
|
||||
private fun Int.toColorStateList() = ColorStateList.valueOf(this)
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Abel García de Prada
|
||||
*
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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 eu.qsfera.android.utils.matchers
|
||||
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.matcher.ViewMatchers
|
||||
import androidx.test.espresso.matcher.ViewMatchers.hasChildCount
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import org.hamcrest.CoreMatchers
|
||||
|
||||
fun Int.isDisplayed(displayed: Boolean) {
|
||||
val displayMatcher = if (displayed) ViewMatchers.isDisplayed() else CoreMatchers.not(ViewMatchers.isDisplayed())
|
||||
|
||||
onView(withId(this))
|
||||
.check(matches(displayMatcher))
|
||||
}
|
||||
|
||||
fun Int.isEnabled(enabled: Boolean) {
|
||||
val enableMatcher = if (enabled) ViewMatchers.isEnabled() else CoreMatchers.not(ViewMatchers.isEnabled())
|
||||
|
||||
onView(withId(this))
|
||||
.check(matches(enableMatcher))
|
||||
}
|
||||
|
||||
fun Int.isFocusable(focusable: Boolean) {
|
||||
val focusableMatcher = if (focusable) ViewMatchers.isFocusable() else CoreMatchers.not(ViewMatchers.isFocusable())
|
||||
|
||||
onView(withId(this))
|
||||
.check(matches(focusableMatcher))
|
||||
}
|
||||
|
||||
fun Int.withText(text: String) {
|
||||
onView(withId(this))
|
||||
.check(matches(ViewMatchers.withText(text)))
|
||||
}
|
||||
|
||||
fun Int.withText(resourceId: Int) {
|
||||
onView(withId(this))
|
||||
.check(matches(ViewMatchers.withText(resourceId)))
|
||||
}
|
||||
|
||||
fun Int.withChildCountAndId(count: Int, resourceId: Int) {
|
||||
onView(withId(this))
|
||||
.check(matches(withChildViewCount(count, withId(resourceId))))
|
||||
}
|
||||
|
||||
fun Int.assertVisibility(visibility: ViewMatchers.Visibility) {
|
||||
onView(withId(this))
|
||||
.check(matches(ViewMatchers.withEffectiveVisibility(visibility)))
|
||||
}
|
||||
|
||||
fun Int.assertChildCount(childs: Int) {
|
||||
onView(withId(this))
|
||||
.check(matches(hasChildCount(childs)))
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
*
|
||||
* Copyright (C) 2021 ownCloud GmbH.
|
||||
* <p>
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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 eu.qsfera.android.utils.matchers
|
||||
|
||||
import androidx.preference.Preference
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import org.junit.Assert.assertEquals
|
||||
|
||||
fun Preference.verifyPreference(
|
||||
keyPref: String,
|
||||
titlePref: String,
|
||||
summaryPref: String? = null,
|
||||
visible: Boolean,
|
||||
enabled: Boolean? = null
|
||||
) {
|
||||
if (visible) onView(withText(titlePref)).check(matches(isDisplayed()))
|
||||
summaryPref?.let {
|
||||
if (visible) onView(withText(it)).check(matches(isDisplayed()))
|
||||
assertEquals(it, summary)
|
||||
}
|
||||
assertEquals(keyPref, key)
|
||||
assertEquals(titlePref, title)
|
||||
assertEquals(visible, isVisible)
|
||||
enabled?.let {
|
||||
assertEquals(enabled, isEnabled)
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Abel García de Prada
|
||||
*
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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 eu.qsfera.android.utils.matchers
|
||||
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.test.espresso.matcher.BoundedMatcher
|
||||
import org.hamcrest.Description
|
||||
import org.hamcrest.Matcher
|
||||
|
||||
fun withTextColor(
|
||||
@ColorRes textColor: Int
|
||||
): Matcher<View> =
|
||||
object : BoundedMatcher<View, TextView>(TextView::class.java) {
|
||||
override fun describeTo(description: Description) {
|
||||
description.appendText("TextView with text color: $textColor")
|
||||
}
|
||||
|
||||
override fun matchesSafely(view: TextView): Boolean {
|
||||
val expectedColor = ContextCompat.getColor(view.context, textColor)
|
||||
val actualColor = view.currentTextColor
|
||||
return actualColor == expectedColor
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<!--
|
||||
~ Copyright (C) 2017 The Android Open Source Project
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<application>
|
||||
<activity android:name="eu.qsfera.android.sharing.shares.ui.TestShareFileActivity" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (C) 2017 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package eu.qsfera.android.testing
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.ui.activity.BaseActivity
|
||||
|
||||
/**
|
||||
* Used for testing fragments inside a fake activity.
|
||||
*/
|
||||
open class SingleFragmentActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val content = FrameLayout(this).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
id = R.id.container
|
||||
}
|
||||
setContentView(content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Christian Schabesberger
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.StrictMode
|
||||
import com.facebook.stetho.Stetho
|
||||
|
||||
object DebugInjector {
|
||||
open fun injectDebugTools(context: Context) {
|
||||
Stetho.initializeWithDefaults(context)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
StrictMode.setVmPolicy(
|
||||
StrictMode.VmPolicy.Builder()
|
||||
.penaltyLog()
|
||||
.detectNonSdkApiUsage()
|
||||
.detectUnsafeIntentLaunch()
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package eu.qsfera.android
|
||||
|
||||
import androidx.test.ext.junit.rules.ActivityScenarioRule
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import com.kaspersky.kaspresso.kaspresso.Kaspresso
|
||||
import com.kaspersky.kaspresso.params.FlakySafetyParams
|
||||
import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
|
||||
import eu.qsfera.android.ui.activity.SplashActivity
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import screens.LoginScreen
|
||||
import screens.MainScreen
|
||||
import screens.ManageAccountsDialog
|
||||
import screens.StartScreen
|
||||
import screens.TrustCertificate
|
||||
|
||||
class LoginScreenTest : TestCase(
|
||||
kaspressoBuilder = Kaspresso.Builder.advanced {
|
||||
flakySafetyParams = FlakySafetyParams.custom(
|
||||
timeoutMs = 20_000L,
|
||||
intervalMs = 100L
|
||||
)
|
||||
}
|
||||
) {
|
||||
@get:Rule
|
||||
val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(
|
||||
android.Manifest.permission.POST_NOTIFICATIONS
|
||||
)
|
||||
|
||||
@get:Rule
|
||||
val activityRule = ActivityScenarioRule(SplashActivity::class.java)
|
||||
|
||||
|
||||
@Test
|
||||
fun loginApp() {
|
||||
before {
|
||||
adbServer.performCmd("adb", listOf("reverse", "tcp:9200", "tcp:9200"))
|
||||
}.after {
|
||||
adbServer.performCmd("adb", listOf("shell", "am", "force-stop", "com.android.chrome"))
|
||||
adbServer.performCmd("adb", listOf("reverse", "--remove", "tcp:9200"))
|
||||
}.run {
|
||||
step("set qsfera url") {
|
||||
StartScreen {
|
||||
hostUrlInput {
|
||||
isVisible()
|
||||
typeText("https://localhost:9200")
|
||||
}
|
||||
checkServerButton {
|
||||
isVisible()
|
||||
isClickable()
|
||||
click()
|
||||
}
|
||||
}
|
||||
}
|
||||
step("trust certificate") {
|
||||
TrustCertificate {
|
||||
yesBtn {
|
||||
isVisible()
|
||||
isClickable()
|
||||
click()
|
||||
}
|
||||
}
|
||||
}
|
||||
step("login") {
|
||||
LoginScreen {
|
||||
username.isDisplayed()
|
||||
password.isDisplayed()
|
||||
loginButton.isDisplayed()
|
||||
username.typeText("alan")
|
||||
password.typeText("demo")
|
||||
loginButton.click()
|
||||
keepAccessForeverBtn {
|
||||
isDisplayed()
|
||||
isClickable()
|
||||
click()
|
||||
}
|
||||
}
|
||||
}
|
||||
step("check personal space") {
|
||||
MainScreen {
|
||||
avatarButton.isVisible()
|
||||
avatarButton.isClickable()
|
||||
avatarButton.click()
|
||||
}
|
||||
}
|
||||
step("remove account") {
|
||||
ManageAccountsDialog {
|
||||
removeBtn {
|
||||
isVisible()
|
||||
click()
|
||||
}
|
||||
message.isVisible()
|
||||
confirmBtn.click()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package screens
|
||||
|
||||
import com.kaspersky.components.kautomator.component.edit.UiEditText
|
||||
import com.kaspersky.components.kautomator.component.text.UiButton
|
||||
import com.kaspersky.components.kautomator.screen.UiScreen
|
||||
|
||||
object LoginScreen : UiScreen<LoginScreen>() {
|
||||
override val packageName: String = "com.android.chrome"
|
||||
|
||||
// can't find it using withId("com.android.chrome", "username") so using withResourceName()
|
||||
val username = UiEditText { withResourceName("oc-login-username") }
|
||||
val password = UiEditText { withResourceName("oc-login-password") }
|
||||
val loginButton = UiButton { withText("Log in") }
|
||||
|
||||
val keepAccessForeverBtn = UiButton { withText("Allow") }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package screens
|
||||
|
||||
import com.kaspersky.kaspresso.screens.KScreen
|
||||
import eu.qsfera.android.R
|
||||
import io.github.kakaocup.kakao.text.KButton
|
||||
|
||||
object MainScreen : KScreen<MainScreen>() {
|
||||
override val layoutId: Int? = R.layout.activity_main
|
||||
override val viewClass: Class<*>? = null
|
||||
|
||||
val avatarButton = KButton { withId(R.id.root_toolbar_avatar) }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package screens
|
||||
|
||||
import com.kaspersky.kaspresso.screens.KScreen
|
||||
import eu.qsfera.android.R
|
||||
import io.github.kakaocup.kakao.text.KButton
|
||||
import io.github.kakaocup.kakao.text.KTextView
|
||||
|
||||
object ManageAccountsDialog : KScreen<ManageAccountsDialog>() {
|
||||
override val layoutId: Int = R.layout.manage_accounts_dialog
|
||||
override val viewClass: Class<*>? = null
|
||||
|
||||
val removeBtn = KButton { withId(R.id.removeButton) }
|
||||
val message = KTextView {
|
||||
containsText("Do you really want to remove the account")
|
||||
}
|
||||
val confirmBtn = KButton { withText(R.string.common_yes) }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package screens
|
||||
|
||||
import com.kaspersky.kaspresso.screens.KScreen
|
||||
import eu.qsfera.android.R
|
||||
import io.github.kakaocup.kakao.edit.KEditText
|
||||
import io.github.kakaocup.kakao.text.KButton
|
||||
|
||||
object StartScreen : KScreen<StartScreen>() {
|
||||
override val layoutId: Int? = R.layout.account_setup
|
||||
override val viewClass: Class<*>? = null
|
||||
|
||||
val hostUrlInput = KEditText { withId(R.id.hostUrlInput) }
|
||||
val checkServerButton = KButton { withId(R.id.embeddedCheckServerButton) }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package screens
|
||||
|
||||
import com.kaspersky.kaspresso.screens.KScreen
|
||||
import eu.qsfera.android.R
|
||||
import io.github.kakaocup.kakao.text.KButton
|
||||
|
||||
object TrustCertificate: KScreen<TrustCertificate>() {
|
||||
override val layoutId: Int? = R.layout.ssl_untrusted_cert_layout
|
||||
override val viewClass: Class<*>? = null
|
||||
|
||||
val yesBtn = KButton { withId(R.id.ok) }
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
qsfera Android client application
|
||||
|
||||
Copyright (C) 2012 Bartek Przybylski
|
||||
Copyright (C) 2023 ownCloud GmbH.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License version 2,
|
||||
as published by the Free Software Foundation.
|
||||
|
||||
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/>.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<!--
|
||||
WRITE_EXTERNAL_STORAGE may be enabled or disabled by the user after installation in
|
||||
API >= 23; the app needs to handle this
|
||||
-->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<!--
|
||||
Notifications are off by default since API 33;
|
||||
See note in https://developer.android.com/develop/ui/views/notifications/notification-permission
|
||||
-->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!--
|
||||
Next permissions are always approved in installation time,
|
||||
the apps needs to do nothing special in runtime
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.READ_SYNC_STATS" />
|
||||
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.BROADCAST_STICKY" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.REORDER_TASKS" />
|
||||
|
||||
<application
|
||||
android:name=".MainApp"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/icon"
|
||||
android:label="@string/app_name"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:preserveLegacyExternalStorage="true"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:resizeableActivity="true"
|
||||
android:supportsPictureInPicture="false"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/Theme.qsfera.Toolbar">
|
||||
<activity
|
||||
android:name=".ui.preview.PreviewVideoActivity"
|
||||
android:label="@string/video_preview_label"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|uiMode"
|
||||
android:theme="@style/Theme.qsfera.Video"
|
||||
android:exported="false" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.content.APP_RESTRICTIONS"
|
||||
android:resource="@xml/managed_configurations" />
|
||||
|
||||
<activity
|
||||
android:name=".presentation.releasenotes.ReleaseNotesActivity"
|
||||
android:label="@string/release_notes_label"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".presentation.settings.privacypolicy.PrivacyPolicyActivity"
|
||||
android:label="@string/actionbar_privacy_policy" />
|
||||
<activity android:name=".presentation.settings.SettingsActivity" />
|
||||
<activity android:name=".presentation.migration.StorageMigrationActivity"
|
||||
android:theme="@style/Theme.qsfera.Toolbar.Fill" />
|
||||
<activity
|
||||
android:name=".ui.activity.SplashActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.qsfera.Splash">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.activity.FileDisplayActivity"
|
||||
android:configChanges="orientation|screenSize"
|
||||
android:theme="@style/Theme.qsfera.Toolbar.Drawer"
|
||||
android:windowSoftInputMode="adjustPan" />
|
||||
<activity
|
||||
android:name=".ui.activity.ReceiveExternalFilesActivity"
|
||||
android:configChanges="orientation|screenSize"
|
||||
android:label="@string/receive_external_files_label"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:taskAffinity="">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.preview.PreviewImageActivity"
|
||||
android:label="@string/image_preview_label"
|
||||
android:theme="@style/Theme.qsfera.Overlay" />
|
||||
|
||||
<service
|
||||
android:name=".presentation.authentication.AccountAuthenticatorService"
|
||||
android:exported="true">
|
||||
<intent-filter android:priority="100">
|
||||
<action android:name="android.accounts.AccountAuthenticator" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.accounts.AccountAuthenticator"
|
||||
android:resource="@xml/authenticator" />
|
||||
</service>
|
||||
<service
|
||||
android:name=".syncadapter.FileSyncService"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.content.SyncAdapter"
|
||||
android:resource="@xml/syncadapter_files" />
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name=".providers.FileContentProvider"
|
||||
android:authorities="@string/authority"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:label="@string/sync_string_files"
|
||||
android:syncable="true" />
|
||||
<provider
|
||||
android:name=".presentation.sharing.sharees.UsersAndGroupsSearchProvider"
|
||||
android:authorities="@string/search_suggest_authority"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:label="@string/search_users_and_groups_hint" />
|
||||
<provider
|
||||
android:name=".presentation.documentsprovider.DocumentsStorageProvider"
|
||||
android:authorities="@string/document_provider_authority"
|
||||
android:exported="true"
|
||||
android:grantUriPermissions="true"
|
||||
android:permission="android.permission.MANAGE_DOCUMENTS">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
||||
</intent-filter>
|
||||
</provider>
|
||||
<!-- new provider used to generate URIs without file:// scheme (forbidden from Android 7) -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="@string/file_provider_authority"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true"
|
||||
tools:replace="android:authorities">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/exposed_filepaths"
|
||||
tools:replace="android:resource" />
|
||||
</provider>
|
||||
|
||||
<service android:name=".services.OperationsService" />
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:exported="false"
|
||||
tools:replace="android:foregroundServiceType" />
|
||||
<service
|
||||
android:name=".media.MediaService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="false">
|
||||
</service>
|
||||
|
||||
<activity
|
||||
android:name=".presentation.security.passcode.PassCodeActivity"
|
||||
android:label="@string/passcode_label"
|
||||
android:screenOrientation="portrait" />
|
||||
<activity
|
||||
android:name=".presentation.conflicts.ConflictsResolveActivity" />
|
||||
<activity
|
||||
android:name=".presentation.logging.LogsListActivity"
|
||||
android:label="@string/prefs_log_open_logs_list_view"
|
||||
android:configChanges="orientation|screenSize" />
|
||||
<activity android:name=".ui.errorhandling.ErrorShowActivity" />
|
||||
<activity
|
||||
android:name=".ui.activity.UploadListActivity"
|
||||
android:label="@string/bottom_nav_uploads"
|
||||
android:theme="@style/Theme.qsfera.Toolbar.Drawer" />
|
||||
<activity
|
||||
android:name=".ui.activity.WhatsNewActivity"
|
||||
android:label="@string/whats_new_label"
|
||||
android:configChanges="keyboardHidden|orientation|screenSize" />
|
||||
<activity
|
||||
android:name=".ui.activity.CopyToClipboardActivity"
|
||||
android:icon="@drawable/copy_link"
|
||||
android:label="@string/copy_link" />
|
||||
<activity android:name=".ui.activity.FolderPickerActivity"
|
||||
android:label="@string/folder_picker_label"/>
|
||||
<activity
|
||||
android:name=".presentation.sharing.ShareActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/share_dialog_title"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/Theme.qsfera.Toolbar"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEARCH" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.searchable"
|
||||
android:resource="@xml/users_and_groups_searchable" />
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".presentation.security.pattern.PatternActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:label="@string/pattern_label" />
|
||||
<activity android:name=".presentation.security.biometric.BiometricActivity" />
|
||||
<!-- Own taskAffinity + singleTask so LoginActivity always runs in its own task.
|
||||
During re-auth, startActivityForResult overrides singleTask, so the first
|
||||
instance still lands in the main task — but the OAuth redirect instance gets
|
||||
its own task and finishes the orphaned one via a static reference.
|
||||
autoRemoveFromRecents cleans the login task from recents once it finishes. -->
|
||||
<activity
|
||||
android:name=".presentation.authentication.LoginActivity"
|
||||
android:autoRemoveFromRecents="true"
|
||||
android:exported="true"
|
||||
android:label="@string/login_label"
|
||||
android:launchMode="singleTask"
|
||||
android:taskAffinity="eu.qsfera.android.login"
|
||||
android:theme="@style/Theme.qsfera.Toolbar">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="@string/oauth2_redirect_uri_host"
|
||||
android:scheme="@string/oauth2_redirect_uri_scheme" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="@string/deep_link_uri_schemes" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
* <p>
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
* <p>
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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 eu.qsfera.android;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.fragment.app.FragmentTransaction;
|
||||
import eu.qsfera.android.ui.dialog.RateMeDialog;
|
||||
import timber.log.Timber;
|
||||
|
||||
public class AppRater {
|
||||
private static final String DIALOG_RATE_ME_TAG = "DIALOG_RATE_ME";
|
||||
|
||||
private final static int DAYS_UNTIL_PROMPT = 2;
|
||||
private final static int LAUNCHES_UNTIL_PROMPT = 2;
|
||||
private final static int DAYS_UNTIL_NEUTRAL_CLICK = 1;
|
||||
|
||||
public static final String APP_RATER_PREF_TITLE = "app_rater";
|
||||
public static final String APP_RATER_PREF_DONT_SHOW = "don't_show_again";
|
||||
private static final String APP_RATER_PREF_LAUNCH_COUNT = "launch_count";
|
||||
private static final String APP_RATER_PREF_DATE_FIRST_LAUNCH = "date_first_launch";
|
||||
public static final String APP_RATER_PREF_DATE_NEUTRAL = "date_neutral";
|
||||
|
||||
public static void appLaunched(Context mContext, String packageName) {
|
||||
SharedPreferences prefs = mContext.getSharedPreferences(APP_RATER_PREF_TITLE, 0);
|
||||
if (prefs.getBoolean(APP_RATER_PREF_DONT_SHOW, false)) {
|
||||
Timber.d("Do not show the rate dialog again as the user decided");
|
||||
return;
|
||||
}
|
||||
|
||||
SharedPreferences.Editor editor = prefs.edit();
|
||||
|
||||
/// Increment launch counter
|
||||
long launchCount = prefs.getLong(APP_RATER_PREF_LAUNCH_COUNT, 0) + 1;
|
||||
Timber.d("The app has been launched " + launchCount + " times");
|
||||
editor.putLong(APP_RATER_PREF_LAUNCH_COUNT, launchCount);
|
||||
|
||||
/// Get date of first launch
|
||||
long dateFirstLaunch = prefs.getLong(APP_RATER_PREF_DATE_FIRST_LAUNCH, 0);
|
||||
if (dateFirstLaunch == 0) {
|
||||
dateFirstLaunch = System.currentTimeMillis();
|
||||
Timber.d("The app has been launched in " + dateFirstLaunch + " for the first time");
|
||||
editor.putLong(APP_RATER_PREF_DATE_FIRST_LAUNCH, dateFirstLaunch);
|
||||
}
|
||||
|
||||
/// Get date of neutral click
|
||||
long dateNeutralClick = prefs.getLong(APP_RATER_PREF_DATE_NEUTRAL, 0);
|
||||
|
||||
/// Wait at least n days before opening
|
||||
if (launchCount >= LAUNCHES_UNTIL_PROMPT) {
|
||||
Timber.d("The number of launches already exceed " + LAUNCHES_UNTIL_PROMPT +
|
||||
", the default number of launches, so let's check some dates");
|
||||
Timber.d("Current moment is %s", System.currentTimeMillis());
|
||||
Timber.d("The date of the first launch + days until prompt is " + dateFirstLaunch +
|
||||
daysToMilliseconds(DAYS_UNTIL_PROMPT));
|
||||
Timber.d("The date of the neutral click + days until neutral click is " + dateNeutralClick +
|
||||
daysToMilliseconds(DAYS_UNTIL_NEUTRAL_CLICK));
|
||||
if (System.currentTimeMillis() >= Math.max(dateFirstLaunch
|
||||
+ daysToMilliseconds(DAYS_UNTIL_PROMPT), dateNeutralClick
|
||||
+ daysToMilliseconds(DAYS_UNTIL_NEUTRAL_CLICK))) {
|
||||
Timber.d("The current moment is later than any of the days set, so let's show the rate dialog");
|
||||
showRateDialog(mContext, packageName);
|
||||
}
|
||||
}
|
||||
|
||||
editor.apply();
|
||||
}
|
||||
|
||||
private static int daysToMilliseconds(int days) {
|
||||
return days * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
private static void showRateDialog(Context mContext, String packageName) {
|
||||
RateMeDialog rateMeDialog = RateMeDialog.newInstance(packageName, false);
|
||||
FragmentManager fm = ((FragmentActivity) mContext).getSupportFragmentManager();
|
||||
FragmentTransaction ft = fm.beginTransaction();
|
||||
rateMeDialog.show(ft, DIALOG_RATE_ME_TAG);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author masensio
|
||||
* @author David A. Velasco
|
||||
* @author David González Verdugo
|
||||
* @author Christian Schabesberger
|
||||
* @author David Crespo Ríos
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
* @author Aitor Ballesteros Pavón
|
||||
*
|
||||
* Copyright (C) 2024 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.app.NotificationManager.IMPORTANCE_LOW
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import android.widget.CheckBox
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import eu.qsfera.android.data.providers.implementation.OCSharedPreferencesProvider
|
||||
|
||||
|
||||
import eu.qsfera.android.db.PreferenceManager
|
||||
import eu.qsfera.android.dependecyinjection.commonModule
|
||||
import eu.qsfera.android.dependecyinjection.localDataSourceModule
|
||||
import eu.qsfera.android.dependecyinjection.remoteDataSourceModule
|
||||
import eu.qsfera.android.dependecyinjection.repositoryModule
|
||||
import eu.qsfera.android.dependecyinjection.useCaseModule
|
||||
import eu.qsfera.android.dependecyinjection.viewModelModule
|
||||
import eu.qsfera.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase
|
||||
import eu.qsfera.android.domain.spaces.model.OCSpace
|
||||
import eu.qsfera.android.domain.spaces.usecases.GetPersonalSpaceForAccountUseCase
|
||||
import eu.qsfera.android.domain.user.usecases.GetStoredQuotaUseCase
|
||||
import eu.qsfera.android.extensions.createNotificationChannel
|
||||
import eu.qsfera.android.lib.common.SingleSessionManager
|
||||
import eu.qsfera.android.presentation.authentication.AccountUtils
|
||||
import eu.qsfera.android.presentation.migration.StorageMigrationActivity
|
||||
import eu.qsfera.android.presentation.releasenotes.ReleaseNotesActivity
|
||||
import eu.qsfera.android.presentation.security.biometric.BiometricActivity
|
||||
import eu.qsfera.android.presentation.security.biometric.BiometricManager
|
||||
import eu.qsfera.android.presentation.security.passcode.PassCodeActivity
|
||||
import eu.qsfera.android.presentation.security.passcode.PassCodeManager
|
||||
import eu.qsfera.android.presentation.security.pattern.PatternActivity
|
||||
import eu.qsfera.android.presentation.security.pattern.PatternManager
|
||||
import eu.qsfera.android.presentation.settings.logging.SettingsLogsFragment.Companion.PREFERENCE_ENABLE_LOGGING
|
||||
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
|
||||
import eu.qsfera.android.providers.LogsProvider
|
||||
import eu.qsfera.android.providers.MdmProvider
|
||||
import eu.qsfera.android.providers.WorkManagerProvider
|
||||
import eu.qsfera.android.ui.activity.FileDisplayActivity
|
||||
import eu.qsfera.android.ui.activity.FileDisplayActivity.Companion.PREFERENCE_CLEAR_DATA_ALREADY_TRIGGERED
|
||||
import eu.qsfera.android.ui.activity.WhatsNewActivity
|
||||
import eu.qsfera.android.utils.CONFIGURATION_ALLOW_SCREENSHOTS
|
||||
import eu.qsfera.android.utils.DOWNLOAD_NOTIFICATION_CHANNEL_ID
|
||||
import eu.qsfera.android.utils.DebugInjector
|
||||
import eu.qsfera.android.utils.FILE_SYNC_CONFLICT_NOTIFICATION_CHANNEL_ID
|
||||
import eu.qsfera.android.utils.FILE_SYNC_NOTIFICATION_CHANNEL_ID
|
||||
import eu.qsfera.android.utils.MEDIA_SERVICE_NOTIFICATION_CHANNEL_ID
|
||||
import eu.qsfera.android.utils.UPLOAD_NOTIFICATION_CHANNEL_ID
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Main Application of the project
|
||||
*
|
||||
*
|
||||
* Contains methods to build the "static" strings. These strings were before constants in different
|
||||
* classes
|
||||
*/
|
||||
class MainApp : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
appContext = applicationContext
|
||||
|
||||
// Ensure Logcat shows Timber logs in debug builds
|
||||
if (BuildConfig.DEBUG) {
|
||||
try {
|
||||
Timber.plant(Timber.DebugTree())
|
||||
} catch (_: Throwable) {
|
||||
// ignore if already planted
|
||||
}
|
||||
}
|
||||
|
||||
startLogsIfEnabled()
|
||||
|
||||
DebugInjector.injectDebugTools(appContext)
|
||||
|
||||
createNotificationChannels()
|
||||
|
||||
SingleSessionManager.setUserAgent(userAgent)
|
||||
|
||||
initDependencyInjection()
|
||||
|
||||
val workManagerProvider: WorkManagerProvider by inject()
|
||||
var startedActivities = 0
|
||||
|
||||
// register global protection with pass code, pattern lock and biometric lock
|
||||
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
||||
Timber.d("${activity.javaClass.simpleName} onCreate(Bundle) starting")
|
||||
|
||||
// To prevent taking screenshots in MDM
|
||||
if (!areScreenshotsAllowed()) {
|
||||
activity.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
|
||||
// If there's any lock protection, don't show wizard at this point, show it when lock activities
|
||||
// have finished
|
||||
if (activity !is PassCodeActivity &&
|
||||
activity !is PatternActivity &&
|
||||
activity !is BiometricActivity
|
||||
) {
|
||||
StorageMigrationActivity.runIfNeeded(activity)
|
||||
if (isFirstRun()) {
|
||||
WhatsNewActivity.runIfNeeded(activity)
|
||||
|
||||
} else {
|
||||
ReleaseNotesActivity.runIfNeeded(activity)
|
||||
|
||||
val pref = PreferenceManager.getDefaultSharedPreferences(appContext)
|
||||
val clearDataAlreadyTriggered = pref.contains(PREFERENCE_CLEAR_DATA_ALREADY_TRIGGERED)
|
||||
if (clearDataAlreadyTriggered || isNewVersionCode()) {
|
||||
val dontShowAgainDialogPref = pref.getBoolean(PREFERENCE_KEY_DONT_SHOW_SERVER_ACCOUNT_WARNING_DIALOG, false)
|
||||
if (!dontShowAgainDialogPref && shouldShowDialog(activity)) {
|
||||
val checkboxDialog = activity.layoutInflater.inflate(R.layout.checkbox_dialog, null)
|
||||
val checkbox = checkboxDialog.findViewById<CheckBox>(R.id.checkbox_dialog)
|
||||
checkbox.setText(R.string.server_accounts_warning_checkbox_message)
|
||||
val builder = AlertDialog.Builder(activity).apply {
|
||||
setView(checkboxDialog)
|
||||
setTitle(R.string.server_accounts_warning_title)
|
||||
setMessage(R.string.server_accounts_warning_message)
|
||||
setCancelable(false)
|
||||
setPositiveButton(R.string.server_accounts_warning_button) { _, _ ->
|
||||
if (checkbox.isChecked) {
|
||||
pref.edit().putBoolean(PREFERENCE_KEY_DONT_SHOW_SERVER_ACCOUNT_WARNING_DIALOG, true).apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
val alertDialog = builder.create()
|
||||
alertDialog.show()
|
||||
}
|
||||
} else { // "Clear data" button is pressed from the app settings in the device settings.
|
||||
AccountUtils.deleteAccounts(appContext)
|
||||
WhatsNewActivity.runIfNeeded(activity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PreferenceManager.migrateFingerprintToBiometricKey(applicationContext)
|
||||
PreferenceManager.deleteOldSettingsPreferences(applicationContext)
|
||||
}
|
||||
|
||||
private fun shouldShowDialog(activity: Activity) =
|
||||
runBlocking(CoroutinesDispatcherProvider().io) {
|
||||
if (activity !is FileDisplayActivity) return@runBlocking false
|
||||
val account = AccountUtils.getCurrentQSferaAccount(appContext) ?: return@runBlocking false
|
||||
|
||||
val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase by inject()
|
||||
val capabilities = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
|
||||
getStoredCapabilitiesUseCase(
|
||||
GetStoredCapabilitiesUseCase.Params(
|
||||
accountName = account.name
|
||||
)
|
||||
)
|
||||
}
|
||||
val spacesAllowed = capabilities != null && capabilities.isSpacesAllowed()
|
||||
|
||||
var personalSpace: OCSpace? = null
|
||||
if (spacesAllowed) {
|
||||
val getPersonalSpaceForAccountUseCase: GetPersonalSpaceForAccountUseCase by inject()
|
||||
personalSpace = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
|
||||
getPersonalSpaceForAccountUseCase(
|
||||
GetPersonalSpaceForAccountUseCase.Params(
|
||||
accountName = account.name
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val getStoredQuotaUseCase: GetStoredQuotaUseCase by inject()
|
||||
val quota = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
|
||||
getStoredQuotaUseCase(
|
||||
GetStoredQuotaUseCase.Params(
|
||||
accountName = account.name
|
||||
)
|
||||
)
|
||||
}
|
||||
val isLightUser = quota.getDataOrNull()?.available == -4L
|
||||
|
||||
spacesAllowed && personalSpace == null && !isLightUser
|
||||
}
|
||||
|
||||
override fun onActivityStarted(activity: Activity) {
|
||||
Timber.v("${activity.javaClass.simpleName} onStart() starting")
|
||||
if (startedActivities == 0) {
|
||||
// App entered foreground — ensure the periodic worker is registered
|
||||
// (recovers if the chain was dropped) and trigger an immediate scan
|
||||
// so the user doesn't have to wait up to 15 min.
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
workManagerProvider.enqueueAutomaticUploadsWorker()
|
||||
workManagerProvider.enqueueMediaStoreAutomaticUploadsWorker()
|
||||
workManagerProvider.enqueueImmediateAutomaticUploadsWorker()
|
||||
}
|
||||
}
|
||||
startedActivities++
|
||||
PassCodeManager.onActivityStarted(activity)
|
||||
PatternManager.onActivityStarted(activity)
|
||||
BiometricManager.onActivityStarted(activity)
|
||||
}
|
||||
|
||||
override fun onActivityResumed(activity: Activity) {
|
||||
Timber.v("${activity.javaClass.simpleName} onResume() starting")
|
||||
}
|
||||
|
||||
override fun onActivityPaused(activity: Activity) {
|
||||
Timber.v("${activity.javaClass.simpleName} onPause() ending")
|
||||
}
|
||||
|
||||
override fun onActivityStopped(activity: Activity) {
|
||||
startedActivities--
|
||||
Timber.v("${activity.javaClass.simpleName} onStop() ending")
|
||||
PassCodeManager.onActivityStopped(activity)
|
||||
PatternManager.onActivityStopped(activity)
|
||||
BiometricManager.onActivityStopped(activity)
|
||||
}
|
||||
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {
|
||||
Timber.v("${activity.javaClass.simpleName} onSaveInstanceState(Bundle) starting")
|
||||
}
|
||||
|
||||
override fun onActivityDestroyed(activity: Activity) {
|
||||
Timber.v("${activity.javaClass.simpleName} onDestroy() ending")
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
private fun startLogsIfEnabled() {
|
||||
val preferenceProvider = OCSharedPreferencesProvider(applicationContext)
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
val alreadySet = preferenceProvider.containsPreference(PREFERENCE_ENABLE_LOGGING)
|
||||
if (!alreadySet) {
|
||||
preferenceProvider.putBoolean(PREFERENCE_ENABLE_LOGGING, true)
|
||||
}
|
||||
}
|
||||
|
||||
enabledLogging = preferenceProvider.getBoolean(PREFERENCE_ENABLE_LOGGING, false)
|
||||
|
||||
if (enabledLogging) {
|
||||
val mdmProvider = MdmProvider(applicationContext)
|
||||
LogsProvider(applicationContext, mdmProvider).startLogging()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Screenshots allowed in debug mode. Devs and tests <3
|
||||
* Otherwise, depends on branding.
|
||||
*/
|
||||
private fun areScreenshotsAllowed(): Boolean {
|
||||
if (BuildConfig.DEBUG) return true
|
||||
|
||||
val mdmProvider = MdmProvider(applicationContext)
|
||||
return mdmProvider.getBrandingBoolean(CONFIGURATION_ALLOW_SCREENSHOTS, R.bool.allow_screenshots)
|
||||
}
|
||||
|
||||
private fun createNotificationChannels() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return
|
||||
}
|
||||
|
||||
createNotificationChannel(
|
||||
id = DOWNLOAD_NOTIFICATION_CHANNEL_ID,
|
||||
name = getString(R.string.download_notification_channel_name),
|
||||
description = getString(R.string.download_notification_channel_description),
|
||||
importance = IMPORTANCE_LOW
|
||||
)
|
||||
|
||||
createNotificationChannel(
|
||||
id = UPLOAD_NOTIFICATION_CHANNEL_ID,
|
||||
name = getString(R.string.upload_notification_channel_name),
|
||||
description = getString(R.string.upload_notification_channel_description),
|
||||
importance = IMPORTANCE_LOW
|
||||
)
|
||||
|
||||
createNotificationChannel(
|
||||
id = MEDIA_SERVICE_NOTIFICATION_CHANNEL_ID,
|
||||
name = getString(R.string.media_service_notification_channel_name),
|
||||
description = getString(R.string.media_service_notification_channel_description),
|
||||
importance = IMPORTANCE_LOW
|
||||
)
|
||||
|
||||
createNotificationChannel(
|
||||
id = FILE_SYNC_CONFLICT_NOTIFICATION_CHANNEL_ID,
|
||||
name = getString(R.string.file_sync_conflict_notification_channel_name),
|
||||
description = getString(R.string.file_sync_conflict_notification_channel_description),
|
||||
importance = IMPORTANCE_LOW
|
||||
)
|
||||
|
||||
createNotificationChannel(
|
||||
id = FILE_SYNC_NOTIFICATION_CHANNEL_ID,
|
||||
name = getString(R.string.file_sync_notification_channel_name),
|
||||
description = getString(R.string.file_sync_notification_channel_description),
|
||||
importance = IMPORTANCE_LOW
|
||||
)
|
||||
}
|
||||
|
||||
private fun isFirstRun(): Boolean {
|
||||
if (getLastSeenVersionCode() != 0) {
|
||||
return false
|
||||
}
|
||||
return AccountUtils.getCurrentQSferaAccount(appContext) == null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MDM_FLAVOR = "mdm"
|
||||
|
||||
lateinit var appContext: Context
|
||||
private set
|
||||
var enabledLogging: Boolean = false
|
||||
private set
|
||||
|
||||
const val PREFERENCE_KEY_LAST_SEEN_VERSION_CODE = "lastSeenVersionCode"
|
||||
|
||||
const val PREFERENCE_KEY_DONT_SHOW_SERVER_ACCOUNT_WARNING_DIALOG = "PREFERENCE_KEY_DONT_SHOW_SERVER_ACCOUNT_WARNING_DIALOG"
|
||||
|
||||
/**
|
||||
* Next methods give access in code to some constants that need to be defined in string resources to be referred
|
||||
* in AndroidManifest.xml file or other xml resource files; or that need to be easy to modify in build time.
|
||||
*/
|
||||
|
||||
val accountType: String
|
||||
get() = appContext.resources.getString(R.string.account_type)
|
||||
|
||||
val versionCode: Int
|
||||
get() =
|
||||
try {
|
||||
val pInfo: PackageInfo = appContext.packageManager.getPackageInfo(appContext.packageName, 0)
|
||||
val longVersionCode: Long = PackageInfoCompat.getLongVersionCode(pInfo)
|
||||
longVersionCode.toInt()
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
Timber.w(e, "Version code not found, using 0 as fallback")
|
||||
0
|
||||
}
|
||||
|
||||
val authority: String
|
||||
get() = appContext.resources.getString(R.string.authority)
|
||||
|
||||
val authTokenType: String
|
||||
get() = appContext.resources.getString(R.string.authority)
|
||||
|
||||
val dataFolder: String
|
||||
get() = appContext.resources.getString(R.string.data_folder)
|
||||
|
||||
// user agent
|
||||
// Mozilla/5.0 (Android) qsfera-android/1.7.0
|
||||
val userAgent: String
|
||||
get() {
|
||||
val appString = appContext.resources.getString(R.string.user_agent)
|
||||
val packageName = appContext.packageName
|
||||
var version: String? = ""
|
||||
|
||||
val pInfo: PackageInfo?
|
||||
try {
|
||||
pInfo = appContext.packageManager.getPackageInfo(packageName, 0)
|
||||
if (pInfo != null) {
|
||||
version = pInfo.versionName
|
||||
}
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
Timber.e(e, "Trying to get packageName")
|
||||
}
|
||||
|
||||
return String.format(appString, version)
|
||||
}
|
||||
|
||||
fun initDependencyInjection() {
|
||||
stopKoin()
|
||||
startKoin {
|
||||
androidContext(appContext)
|
||||
modules(
|
||||
listOf(
|
||||
commonModule,
|
||||
viewModelModule,
|
||||
useCaseModule,
|
||||
repositoryModule,
|
||||
localDataSourceModule,
|
||||
remoteDataSourceModule
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getLastSeenVersionCode(): Int {
|
||||
val pref = PreferenceManager.getDefaultSharedPreferences(appContext)
|
||||
return pref.getInt(PREFERENCE_KEY_LAST_SEEN_VERSION_CODE, 0)
|
||||
}
|
||||
|
||||
private fun isNewVersionCode(): Boolean {
|
||||
val lastSeenVersionCode = getLastSeenVersionCode()
|
||||
if (lastSeenVersionCode == 0) { // The preferences have been deleted, so we can delete the accounts and navigate to login
|
||||
return false
|
||||
}
|
||||
return lastSeenVersionCode != versionCode // The version has changed and the accounts must not be deleted
|
||||
}
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Bartek Przybylski
|
||||
* @author Christian Schabesberger
|
||||
* @author David González Verdugo
|
||||
* @author Abel García de Prada
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
*
|
||||
* Copyright (C) 2012 Bartek Przybylski
|
||||
* Copyright (C) 2023 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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:></http:>//www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package eu.qsfera.android.datamodel
|
||||
|
||||
import android.accounts.Account
|
||||
import eu.qsfera.android.domain.files.model.OCFile
|
||||
import eu.qsfera.android.domain.files.model.OCFile.Companion.ROOT_PATH
|
||||
import eu.qsfera.android.domain.files.usecases.GetFileByIdUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.GetFileByRemotePathUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.GetFolderContentUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.GetFolderImagesUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.GetPersonalRootFolderForAccountUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.GetSharesRootFolderForAccount
|
||||
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
class FileDataStorageManager(
|
||||
val account: Account,
|
||||
) : KoinComponent {
|
||||
|
||||
fun getFileByPath(remotePath: String, spaceId: String? = null): OCFile? =
|
||||
if (remotePath == ROOT_PATH && spaceId == null) {
|
||||
getRootPersonalFolder()
|
||||
} else {
|
||||
getFileByPathAndAccount(remotePath, account.name, spaceId)
|
||||
}
|
||||
|
||||
private fun getFileByPathAndAccount(remotePath: String, accountName: String, spaceId: String? = null): OCFile? =
|
||||
runBlocking(CoroutinesDispatcherProvider().io) {
|
||||
val getFileByRemotePathUseCase: GetFileByRemotePathUseCase by inject()
|
||||
|
||||
val result = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
|
||||
getFileByRemotePathUseCase(GetFileByRemotePathUseCase.Params(accountName, remotePath, spaceId))
|
||||
}.getDataOrNull()
|
||||
result
|
||||
}
|
||||
|
||||
fun getRootPersonalFolder() = runBlocking(CoroutinesDispatcherProvider().io) {
|
||||
val getPersonalRootFolderForAccountUseCase: GetPersonalRootFolderForAccountUseCase by inject()
|
||||
|
||||
val result = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
|
||||
getPersonalRootFolderForAccountUseCase(GetPersonalRootFolderForAccountUseCase.Params(account.name))
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fun getRootSharesFolder() = runBlocking(CoroutinesDispatcherProvider().io) {
|
||||
val getSharesRootFolderForAccount: GetSharesRootFolderForAccount by inject()
|
||||
|
||||
val result = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
|
||||
getSharesRootFolderForAccount(GetSharesRootFolderForAccount.Params(account.name))
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// To do: New_arch. Remove this and call usecase inside FilesViewModel
|
||||
fun getFileById(id: Long): OCFile? = runBlocking(CoroutinesDispatcherProvider().io) {
|
||||
val getFileByIdUseCase: GetFileByIdUseCase by inject()
|
||||
|
||||
val result = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
|
||||
getFileByIdUseCase(GetFileByIdUseCase.Params(id))
|
||||
}.getDataOrNull()
|
||||
result
|
||||
}
|
||||
|
||||
fun fileExists(path: String): Boolean = getFileByPath(path) != null
|
||||
|
||||
fun getFolderContent(f: OCFile?): List<OCFile> =
|
||||
if (f != null && f.isFolder && f.id != -1L) {
|
||||
// To do: Remove !!
|
||||
getFolderContent(f.id!!)
|
||||
} else {
|
||||
listOf()
|
||||
}
|
||||
|
||||
// To do: New_arch. Remove this and call usecase inside FilesViewModel
|
||||
fun getFolderImages(folder: OCFile?): List<OCFile> = runBlocking(CoroutinesDispatcherProvider().io) {
|
||||
val getFolderImagesUseCase: GetFolderImagesUseCase by inject()
|
||||
|
||||
val result = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
|
||||
// To do: Remove !!
|
||||
getFolderImagesUseCase(GetFolderImagesUseCase.Params(folderId = folder!!.id!!))
|
||||
}.getDataOrNull()
|
||||
result ?: listOf()
|
||||
}
|
||||
|
||||
// To do: New_arch. Remove this and call usecase inside FilesViewModel
|
||||
private fun getFolderContent(parentId: Long): List<OCFile> = runBlocking(CoroutinesDispatcherProvider().io) {
|
||||
val getFolderContentUseCase: GetFolderContentUseCase by inject()
|
||||
|
||||
val result = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
|
||||
getFolderContentUseCase(GetFolderContentUseCase.Params(parentId))
|
||||
}.getDataOrNull()
|
||||
result ?: listOf()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David A. Velasco
|
||||
* @author David González Verdugo
|
||||
* @author Shashvat Kedia
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
*
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
* <p>
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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 eu.qsfera.android.db;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import eu.qsfera.android.presentation.security.biometric.BiometricActivity;
|
||||
import eu.qsfera.android.utils.FileStorageUtils;
|
||||
|
||||
/**
|
||||
* Helper to simplify reading of Preferences all around the app
|
||||
*/
|
||||
public abstract class PreferenceManager {
|
||||
// Legacy preferences - done in version 2.18
|
||||
public static final String PREF__LEGACY_CLICK_DEV_MENU = "clickDeveloperMenu";
|
||||
public static final String PREF__LEGACY_CAMERA_PICTURE_UPLOADS_ENABLED = "camera_picture_uploads";
|
||||
public static final String PREF__LEGACY_CAMERA_VIDEO_UPLOADS_ENABLED = "camera_video_uploads";
|
||||
public static final String PREF__LEGACY_CAMERA_PICTURE_UPLOADS_WIFI_ONLY = "camera_picture_uploads_on_wifi";
|
||||
public static final String PREF__LEGACY_CAMERA_VIDEO_UPLOADS_WIFI_ONLY = "camera_video_uploads_on_wifi";
|
||||
public static final String PREF__LEGACY_CAMERA_PICTURE_UPLOADS_PATH = "camera_picture_uploads_path";
|
||||
public static final String PREF__LEGACY_CAMERA_VIDEO_UPLOADS_PATH = "camera_video_uploads_path";
|
||||
public static final String PREF__LEGACY_CAMERA_UPLOADS_BEHAVIOUR = "camera_uploads_behaviour";
|
||||
public static final String PREF__LEGACY_CAMERA_UPLOADS_SOURCE = "camera_uploads_source_path";
|
||||
public static final String PREF__LEGACY_CAMERA_UPLOADS_ACCOUNT_NAME = "camera_uploads_account_name";
|
||||
public static final String PREF__CAMERA_PICTURE_UPLOADS_ENABLED = "enable_picture_uploads";
|
||||
public static final String PREF__CAMERA_VIDEO_UPLOADS_ENABLED = "enable_video_uploads";
|
||||
public static final String PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY = "picture_uploads_on_wifi";
|
||||
public static final String PREF__CAMERA_PICTURE_UPLOADS_CHARGING_ONLY = "picture_uploads_on_charging";
|
||||
public static final String PREF__CAMERA_VIDEO_UPLOADS_WIFI_ONLY = "video_uploads_on_wifi";
|
||||
public static final String PREF__CAMERA_VIDEO_UPLOADS_CHARGING_ONLY = "video_uploads_on_charging";
|
||||
public static final String PREF__CAMERA_PICTURE_UPLOADS_PATH = "picture_uploads_path";
|
||||
public static final String PREF__CAMERA_VIDEO_UPLOADS_PATH = "video_uploads_path";
|
||||
public static final String PREF__CAMERA_PICTURE_UPLOADS_BEHAVIOUR = "picture_uploads_behaviour";
|
||||
public static final String PREF__CAMERA_PICTURE_UPLOADS_SOURCE = "picture_uploads_source_path";
|
||||
public static final String PREF__CAMERA_VIDEO_UPLOADS_BEHAVIOUR = "video_uploads_behaviour";
|
||||
public static final String PREF__CAMERA_VIDEO_UPLOADS_SOURCE = "video_uploads_source_path";
|
||||
public static final String PREF__CAMERA_PICTURE_UPLOADS_ACCOUNT_NAME = "picture_uploads_account_name";
|
||||
public static final String PREF__CAMERA_VIDEO_UPLOADS_ACCOUNT_NAME = "video_uploads_account_name";
|
||||
public static final String PREF__CAMERA_PICTURE_UPLOADS_LAST_SYNC = "picture_uploads_last_sync";
|
||||
public static final String PREF__CAMERA_VIDEO_UPLOADS_LAST_SYNC = "video_uploads_last_sync";
|
||||
public static final String PREF__CAMERA_UPLOADS_DEFAULT_PATH = "/CameraUpload";
|
||||
public static final String PREF__LEGACY_FINGERPRINT = "set_fingerprint";
|
||||
/**
|
||||
* Constant to access value of last path selected by the user to upload a file shared from other app.
|
||||
* Value handled by the app without direct access in the UI.
|
||||
*/
|
||||
private static final String AUTO_PREF__LAST_UPLOAD_PATH = "last_upload_path";
|
||||
private static final String AUTO_PREF__SORT_ORDER_FILE_DISP = "sortOrderFileDisp";
|
||||
private static final String AUTO_PREF__SORT_ASCENDING_FILE_DISP = "sortAscendingFileDisp";
|
||||
private static final String AUTO_PREF__SORT_ORDER_UPLOAD = "sortOrderUpload";
|
||||
private static final String AUTO_PREF__SORT_ASCENDING_UPLOAD = "sortAscendingUpload";
|
||||
|
||||
public static void migrateFingerprintToBiometricKey(Context context) {
|
||||
SharedPreferences sharedPref = getDefaultSharedPreferences(context);
|
||||
|
||||
// Check if legacy fingerprint key exists, delete it and migrate its value to the new key
|
||||
if (sharedPref.contains(PREF__LEGACY_FINGERPRINT)) {
|
||||
boolean currentFingerprintValue = sharedPref.getBoolean(PREF__LEGACY_FINGERPRINT, false);
|
||||
SharedPreferences.Editor editor = sharedPref.edit();
|
||||
editor.remove(PREF__LEGACY_FINGERPRINT);
|
||||
editor.putBoolean(BiometricActivity.PREFERENCE_SET_BIOMETRIC, currentFingerprintValue);
|
||||
editor.apply();
|
||||
}
|
||||
}
|
||||
|
||||
public static void deleteOldSettingsPreferences(Context context) {
|
||||
SharedPreferences sharedPref = getDefaultSharedPreferences(context);
|
||||
SharedPreferences.Editor editor = sharedPref.edit();
|
||||
if (sharedPref.contains(PREF__LEGACY_CLICK_DEV_MENU)) {
|
||||
editor.remove(PREF__LEGACY_CLICK_DEV_MENU);
|
||||
}
|
||||
if (sharedPref.contains(PREF__LEGACY_CAMERA_PICTURE_UPLOADS_ENABLED)) {
|
||||
editor.remove(PREF__LEGACY_CAMERA_PICTURE_UPLOADS_ENABLED);
|
||||
}
|
||||
if (sharedPref.contains(PREF__LEGACY_CAMERA_VIDEO_UPLOADS_ENABLED)) {
|
||||
editor.remove(PREF__LEGACY_CAMERA_VIDEO_UPLOADS_ENABLED);
|
||||
}
|
||||
if (sharedPref.contains(PREF__LEGACY_CAMERA_PICTURE_UPLOADS_WIFI_ONLY)) {
|
||||
editor.remove(PREF__LEGACY_CAMERA_PICTURE_UPLOADS_WIFI_ONLY);
|
||||
}
|
||||
if (sharedPref.contains(PREF__LEGACY_CAMERA_VIDEO_UPLOADS_WIFI_ONLY)) {
|
||||
editor.remove(PREF__LEGACY_CAMERA_VIDEO_UPLOADS_WIFI_ONLY);
|
||||
}
|
||||
if (sharedPref.contains(PREF__LEGACY_CAMERA_PICTURE_UPLOADS_PATH)) {
|
||||
editor.remove(PREF__LEGACY_CAMERA_PICTURE_UPLOADS_PATH);
|
||||
}
|
||||
if (sharedPref.contains(PREF__LEGACY_CAMERA_VIDEO_UPLOADS_PATH)) {
|
||||
editor.remove(PREF__LEGACY_CAMERA_VIDEO_UPLOADS_PATH);
|
||||
}
|
||||
if (sharedPref.contains(PREF__LEGACY_CAMERA_UPLOADS_BEHAVIOUR)) {
|
||||
editor.remove(PREF__LEGACY_CAMERA_UPLOADS_BEHAVIOUR);
|
||||
}
|
||||
if (sharedPref.contains(PREF__LEGACY_CAMERA_UPLOADS_SOURCE)) {
|
||||
editor.remove(PREF__LEGACY_CAMERA_UPLOADS_SOURCE);
|
||||
}
|
||||
if (sharedPref.contains(PREF__LEGACY_CAMERA_UPLOADS_ACCOUNT_NAME)) {
|
||||
editor.remove(PREF__LEGACY_CAMERA_UPLOADS_ACCOUNT_NAME);
|
||||
}
|
||||
editor.apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path where the user selected to do the last upload of a file shared from other app.
|
||||
*
|
||||
* @param context Caller {@link Context}, used to access to shared preferences manager.
|
||||
* @return path Absolute path to a folder, as previously stored by {@link #setLastUploadPath(String, Context)},
|
||||
* or empty String if never saved before.
|
||||
*/
|
||||
public static String getLastUploadPath(Context context) {
|
||||
return getDefaultSharedPreferences(context).getString(AUTO_PREF__LAST_UPLOAD_PATH, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the path where the user selected to do the last upload of a file shared from other app.
|
||||
*
|
||||
* @param path Absolute path to a folder.
|
||||
* @param context Caller {@link Context}, used to access to shared preferences manager.
|
||||
*/
|
||||
public static void setLastUploadPath(String path, Context context) {
|
||||
saveStringPreference(AUTO_PREF__LAST_UPLOAD_PATH, path, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the sort order which the user has set last.
|
||||
*
|
||||
* @param context Caller {@link Context}, used to access to shared preferences manager.
|
||||
* @return sort order the sort order, default is {@link FileStorageUtils#SORT_NAME} (sort by name)
|
||||
*/
|
||||
public static int getSortOrder(Context context, int flag) {
|
||||
if (flag == FileStorageUtils.FILE_DISPLAY_SORT) {
|
||||
return getDefaultSharedPreferences(context)
|
||||
.getInt(AUTO_PREF__SORT_ORDER_FILE_DISP, FileStorageUtils.SORT_NAME);
|
||||
} else {
|
||||
return getDefaultSharedPreferences(context)
|
||||
.getInt(AUTO_PREF__SORT_ORDER_UPLOAD, FileStorageUtils.SORT_DATE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the sort order which the user has set last.
|
||||
*
|
||||
* @param order the sort order
|
||||
* @param context Caller {@link Context}, used to access to shared preferences manager.
|
||||
*/
|
||||
public static void setSortOrder(int order, Context context, int flag) {
|
||||
if (flag == FileStorageUtils.FILE_DISPLAY_SORT) {
|
||||
saveIntPreference(AUTO_PREF__SORT_ORDER_FILE_DISP, order, context);
|
||||
} else {
|
||||
saveIntPreference(AUTO_PREF__SORT_ORDER_UPLOAD, order, context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the ascending order flag which the user has set last.
|
||||
*
|
||||
* @param context Caller {@link Context}, used to access to shared preferences manager.
|
||||
* @return ascending order the ascending order, default is true
|
||||
*/
|
||||
public static boolean getSortAscending(Context context, int flag) {
|
||||
if (flag == FileStorageUtils.FILE_DISPLAY_SORT) {
|
||||
return getDefaultSharedPreferences(context)
|
||||
.getBoolean(AUTO_PREF__SORT_ASCENDING_FILE_DISP, true);
|
||||
} else {
|
||||
return getDefaultSharedPreferences(context)
|
||||
.getBoolean(AUTO_PREF__SORT_ASCENDING_UPLOAD, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the ascending order flag which the user has set last.
|
||||
*
|
||||
* @param ascending flag if sorting is ascending or descending
|
||||
* @param context Caller {@link Context}, used to access to shared preferences manager.
|
||||
*/
|
||||
public static void setSortAscending(boolean ascending, Context context, int flag) {
|
||||
if (flag == FileStorageUtils.FILE_DISPLAY_SORT) {
|
||||
saveBooleanPreference(AUTO_PREF__SORT_ASCENDING_FILE_DISP, ascending, context);
|
||||
} else {
|
||||
saveBooleanPreference(AUTO_PREF__SORT_ASCENDING_UPLOAD, ascending, context);
|
||||
}
|
||||
}
|
||||
|
||||
private static void saveBooleanPreference(String key, boolean value, Context context) {
|
||||
SharedPreferences.Editor appPreferences = getDefaultSharedPreferences(context.getApplicationContext()).edit();
|
||||
appPreferences.putBoolean(key, value);
|
||||
appPreferences.apply();
|
||||
}
|
||||
|
||||
private static void saveStringPreference(String key, String value, Context context) {
|
||||
SharedPreferences.Editor appPreferences = getDefaultSharedPreferences(context.getApplicationContext()).edit();
|
||||
appPreferences.putString(key, value);
|
||||
appPreferences.apply();
|
||||
}
|
||||
|
||||
private static void saveIntPreference(String key, int value, Context context) {
|
||||
SharedPreferences.Editor appPreferences = getDefaultSharedPreferences(context.getApplicationContext()).edit();
|
||||
appPreferences.putInt(key, value);
|
||||
appPreferences.apply();
|
||||
}
|
||||
|
||||
public static SharedPreferences getDefaultSharedPreferences(Context context) {
|
||||
return android.preference.PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Bartek Przybylski
|
||||
* @author David A. Velasco
|
||||
* @author masensio
|
||||
* @author David González Verdugo
|
||||
* @author Abel García de Prada
|
||||
* Copyright (C) 2011 Bartek Przybylski
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.db;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.provider.BaseColumns;
|
||||
|
||||
import eu.qsfera.android.MainApp;
|
||||
|
||||
/**
|
||||
* Meta-Class that holds various static field information
|
||||
* This is only used in FileContentProvider for legacy DB
|
||||
*/
|
||||
@Deprecated
|
||||
public class ProviderMeta {
|
||||
|
||||
private ProviderMeta() {
|
||||
}
|
||||
|
||||
static public class ProviderTableMeta implements BaseColumns {
|
||||
public static final String FILE_TABLE_NAME = "filelist";
|
||||
public static final String OCSHARES_TABLE_NAME = "ocshares";
|
||||
public static final String CAPABILITIES_TABLE_NAME = "capabilities";
|
||||
public static final String UPLOADS_TABLE_NAME = "list_of_uploads";
|
||||
public static final String USER_AVATARS__TABLE_NAME = "user_avatars";
|
||||
public static final String CAMERA_UPLOADS_SYNC_TABLE_NAME = "camera_uploads_sync";
|
||||
public static final String USER_QUOTAS_TABLE_NAME = "user_quotas";
|
||||
|
||||
public static final Uri CONTENT_URI = Uri.parse("content://"
|
||||
+ MainApp.Companion.getAuthority() + "/");
|
||||
public static final Uri CONTENT_URI_FILE = Uri.parse("content://"
|
||||
+ MainApp.Companion.getAuthority() + "/file");
|
||||
public static final Uri CONTENT_URI_DIR = Uri.parse("content://"
|
||||
+ MainApp.Companion.getAuthority() + "/dir");
|
||||
public static final Uri CONTENT_URI_SHARE = Uri.parse("content://"
|
||||
+ MainApp.Companion.getAuthority() + "/shares");
|
||||
public static final Uri CONTENT_URI_CAPABILITIES = Uri.parse("content://"
|
||||
+ MainApp.Companion.getAuthority() + "/capabilities");
|
||||
public static final Uri CONTENT_URI_UPLOADS = Uri.parse("content://"
|
||||
+ MainApp.Companion.getAuthority() + "/uploads");
|
||||
public static final Uri CONTENT_URI_CAMERA_UPLOADS_SYNC = Uri.parse("content://"
|
||||
+ MainApp.Companion.getAuthority() + "/cameraUploadsSync");
|
||||
public static final Uri CONTENT_URI_QUOTAS = Uri.parse("content://"
|
||||
+ MainApp.Companion.getAuthority() + "/quotas");
|
||||
|
||||
public static final String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.qsfera.file";
|
||||
public static final String CONTENT_TYPE_ITEM = "vnd.android.cursor.item/vnd.qsfera.file";
|
||||
|
||||
public static final String ID = "id";
|
||||
|
||||
// Columns of filelist table
|
||||
public static final String FILE_PARENT = "parent";
|
||||
public static final String FILE_NAME = "filename";
|
||||
public static final String FILE_CREATION = "created";
|
||||
public static final String FILE_MODIFIED = "modified";
|
||||
public static final String FILE_MODIFIED_AT_LAST_SYNC_FOR_DATA = "modified_at_last_sync_for_data";
|
||||
public static final String FILE_CONTENT_LENGTH = "content_length";
|
||||
public static final String FILE_CONTENT_TYPE = "content_type";
|
||||
public static final String FILE_STORAGE_PATH = "media_path";
|
||||
public static final String FILE_PATH = "path";
|
||||
public static final String FILE_ACCOUNT_OWNER = "file_owner";
|
||||
public static final String FILE_LAST_SYNC_DATE = "last_sync_date";// _for_properties, but let's keep it as it is
|
||||
public static final String FILE_LAST_SYNC_DATE_FOR_DATA = "last_sync_date_for_data";
|
||||
public static final String FILE_KEEP_IN_SYNC = "keep_in_sync";
|
||||
public static final String FILE_ETAG = "etag";
|
||||
public static final String FILE_TREE_ETAG = "tree_etag";
|
||||
public static final String FILE_SHARED_VIA_LINK = "share_by_link";
|
||||
public static final String FILE_SHARED_WITH_SHAREE = "shared_via_users";
|
||||
public static final String FILE_PERMISSIONS = "permissions";
|
||||
public static final String FILE_REMOTE_ID = "remote_id";
|
||||
public static final String FILE_UPDATE_THUMBNAIL = "update_thumbnail";
|
||||
public static final String FILE_IS_DOWNLOADING = "is_downloading";
|
||||
public static final String FILE_ETAG_IN_CONFLICT = "etag_in_conflict";
|
||||
public static final String FILE_PRIVATE_LINK = "private_link";
|
||||
|
||||
public static final String FILE_DEFAULT_SORT_ORDER = FILE_NAME
|
||||
+ " collate nocase asc";
|
||||
|
||||
// @deprecated
|
||||
public static final String FILE_PUBLIC_LINK = "public_link";
|
||||
|
||||
// Columns of ocshares table
|
||||
public static final String OCSHARES_SHARE_TYPE = "share_type";
|
||||
public static final String OCSHARES_SHARE_WITH = "share_with";
|
||||
public static final String OCSHARES_PATH = "path";
|
||||
public static final String OCSHARES_PERMISSIONS = "permissions";
|
||||
public static final String OCSHARES_SHARED_DATE = "shared_date";
|
||||
public static final String OCSHARES_EXPIRATION_DATE = "expiration_date";
|
||||
public static final String OCSHARES_TOKEN = "token";
|
||||
public static final String OCSHARES_SHARE_WITH_DISPLAY_NAME = "shared_with_display_name";
|
||||
public static final String OCSHARES_SHARE_WITH_ADDITIONAL_INFO = "share_with_additional_info";
|
||||
public static final String OCSHARES_IS_DIRECTORY = "is_directory";
|
||||
public static final String OCSHARES_ID_REMOTE_SHARED = "id_remote_shared";
|
||||
public static final String OCSHARES_ACCOUNT_OWNER = "owner_share";
|
||||
public static final String OCSHARES_NAME = "name";
|
||||
public static final String OCSHARES_URL = "url";
|
||||
|
||||
public static final String OCSHARES_DEFAULT_SORT_ORDER = OCSHARES_ID_REMOTE_SHARED
|
||||
+ " collate nocase asc";
|
||||
|
||||
// Columns of capabilities table
|
||||
public static final String CAPABILITIES_ACCOUNT_NAME = "account";
|
||||
public static final String CAPABILITIES_VERSION_MAYOR = "version_mayor";
|
||||
public static final String CAPABILITIES_VERSION_MINOR = "version_minor";
|
||||
public static final String CAPABILITIES_VERSION_MICRO = "version_micro";
|
||||
public static final String CAPABILITIES_VERSION_STRING = "version_string";
|
||||
public static final String CAPABILITIES_VERSION_EDITION = "version_edition";
|
||||
public static final String CAPABILITIES_CORE_POLLINTERVAL = "core_pollinterval";
|
||||
public static final String CAPABILITIES_DAV_CHUNKING_VERSION = "dav_chunking_version";
|
||||
public static final String CAPABILITIES_SHARING_API_ENABLED = "sharing_api_enabled";
|
||||
public static final String CAPABILITIES_SHARING_PUBLIC_ENABLED = "sharing_public_enabled";
|
||||
public static final String CAPABILITIES_SHARING_PUBLIC_PASSWORD_ENFORCED = "sharing_public_password_enforced";
|
||||
public static final String CAPABILITIES_SHARING_PUBLIC_PASSWORD_ENFORCED_READ_ONLY =
|
||||
"sharing_public_password_enforced_read_only";
|
||||
public static final String CAPABILITIES_SHARING_PUBLIC_PASSWORD_ENFORCED_READ_WRITE =
|
||||
"sharing_public_password_enforced_read_write";
|
||||
public static final String CAPABILITIES_SHARING_PUBLIC_PASSWORD_ENFORCED_UPLOAD_ONLY =
|
||||
"sharing_public_password_enforced_public_only";
|
||||
public static final String CAPABILITIES_SHARING_PUBLIC_EXPIRE_DATE_ENABLED =
|
||||
"sharing_public_expire_date_enabled";
|
||||
public static final String CAPABILITIES_SHARING_PUBLIC_EXPIRE_DATE_DAYS =
|
||||
"sharing_public_expire_date_days";
|
||||
public static final String CAPABILITIES_SHARING_PUBLIC_EXPIRE_DATE_ENFORCED =
|
||||
"sharing_public_expire_date_enforced";
|
||||
public static final String CAPABILITIES_SHARING_PUBLIC_UPLOAD = "sharing_public_upload";
|
||||
public static final String CAPABILITIES_SHARING_PUBLIC_MULTIPLE = "sharing_public_multiple";
|
||||
public static final String CAPABILITIES_SHARING_PUBLIC_SUPPORTS_UPLOAD_ONLY = "supports_upload_only";
|
||||
public static final String CAPABILITIES_SHARING_RESHARING = "sharing_resharing";
|
||||
public static final String CAPABILITIES_SHARING_FEDERATION_OUTGOING = "sharing_federation_outgoing";
|
||||
public static final String CAPABILITIES_SHARING_FEDERATION_INCOMING = "sharing_federation_incoming";
|
||||
public static final String CAPABILITIES_FILES_BIGFILECHUNKING = "files_bigfilechunking";
|
||||
public static final String CAPABILITIES_FILES_UNDELETE = "files_undelete";
|
||||
public static final String CAPABILITIES_FILES_VERSIONING = "files_versioning";
|
||||
|
||||
public static final String CAPABILITIES_DEFAULT_SORT_ORDER = CAPABILITIES_ACCOUNT_NAME
|
||||
+ " collate nocase asc";
|
||||
|
||||
//Columns of Uploads table
|
||||
public static final String UPLOADS_LOCAL_PATH = "local_path";
|
||||
public static final String UPLOADS_REMOTE_PATH = "remote_path";
|
||||
public static final String UPLOADS_ACCOUNT_NAME = "account_name";
|
||||
public static final String UPLOADS_FILE_SIZE = "file_size";
|
||||
public static final String UPLOADS_STATUS = "status";
|
||||
public static final String UPLOADS_LOCAL_BEHAVIOUR = "local_behaviour";
|
||||
public static final String UPLOADS_UPLOAD_TIME = "upload_time";
|
||||
public static final String UPLOADS_FORCE_OVERWRITE = "force_overwrite";
|
||||
public static final String UPLOADS_IS_CREATE_REMOTE_FOLDER = "is_create_remote_folder";
|
||||
public static final String UPLOADS_UPLOAD_END_TIMESTAMP = "upload_end_timestamp";
|
||||
public static final String UPLOADS_LAST_RESULT = "last_result";
|
||||
public static final String UPLOADS_CREATED_BY = "created_by";
|
||||
public static final String UPLOADS_TRANSFER_ID = "transfer_id";
|
||||
|
||||
public static final String UPLOADS_DEFAULT_SORT_ORDER =
|
||||
ProviderTableMeta._ID + " collate nocase desc";
|
||||
|
||||
// Columns of user_avatars table
|
||||
public static final String USER_AVATARS__ACCOUNT_NAME = "account_name";
|
||||
public static final String USER_AVATARS__CACHE_KEY = "cache_key";
|
||||
public static final String USER_AVATARS__ETAG = "etag";
|
||||
public static final String USER_AVATARS__MIME_TYPE = "mime_type";
|
||||
|
||||
// Columns of camera upload synchronization table
|
||||
public static final String PICTURES_LAST_SYNC_TIMESTAMP = "pictures_last_sync_date";
|
||||
public static final String VIDEOS_LAST_SYNC_TIMESTAMP = "videos_last_sync_date";
|
||||
public static final String CAMERA_UPLOADS_SYNC_DEFAULT_SORT_ORDER =
|
||||
ProviderTableMeta._ID + " collate nocase asc";
|
||||
|
||||
// Columns of user_quotas table
|
||||
public static final String USER_QUOTAS__ACCOUNT_NAME = "account_name";
|
||||
public static final String USER_QUOTAS__FREE = "free";
|
||||
public static final String USER_QUOTAS__RELATIVE = "relative";
|
||||
public static final String USER_QUOTAS__TOTAL = "total";
|
||||
public static final String USER_QUOTAS__USED = "used";
|
||||
public static final String USER_QUOTAS_DEFAULT_SORT_ORDER =
|
||||
ProviderTableMeta._ID + " collate nocase asc";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David González Verdugo
|
||||
* @author Abel García de Prada
|
||||
* Copyright (C) 2021 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.dependecyinjection
|
||||
|
||||
import androidx.work.WorkManager
|
||||
|
||||
import eu.qsfera.android.providers.AccountProvider
|
||||
import eu.qsfera.android.providers.ContextProvider
|
||||
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
|
||||
import eu.qsfera.android.providers.LogsProvider
|
||||
import eu.qsfera.android.providers.MdmProvider
|
||||
import eu.qsfera.android.providers.WorkManagerProvider
|
||||
import eu.qsfera.android.providers.implementation.OCContextProvider
|
||||
import org.koin.android.ext.koin.androidApplication
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
|
||||
val commonModule = module {
|
||||
|
||||
|
||||
single { CoroutinesDispatcherProvider() }
|
||||
factory<ContextProvider> { OCContextProvider(androidContext()) }
|
||||
single { LogsProvider(get(), get()) }
|
||||
single { MdmProvider(androidContext()) }
|
||||
single { WorkManagerProvider(androidContext()) }
|
||||
single { AccountProvider(androidContext()) }
|
||||
single { WorkManager.getInstance(androidApplication()) }
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David González Verdugo
|
||||
* @author Abel García de Prada
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
*
|
||||
* Copyright (C) 2023 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.dependecyinjection
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import eu.qsfera.android.MainApp.Companion.accountType
|
||||
import eu.qsfera.android.MainApp.Companion.dataFolder
|
||||
import eu.qsfera.android.data.QSferaDatabase
|
||||
import eu.qsfera.android.data.appregistry.datasources.LocalAppRegistryDataSource
|
||||
import eu.qsfera.android.data.appregistry.datasources.implementation.OCLocalAppRegistryDataSource
|
||||
import eu.qsfera.android.data.authentication.datasources.LocalAuthenticationDataSource
|
||||
import eu.qsfera.android.data.authentication.datasources.implementation.OCLocalAuthenticationDataSource
|
||||
import eu.qsfera.android.data.capabilities.datasources.LocalCapabilitiesDataSource
|
||||
import eu.qsfera.android.data.capabilities.datasources.implementation.OCLocalCapabilitiesDataSource
|
||||
import eu.qsfera.android.data.files.datasources.LocalFileDataSource
|
||||
import eu.qsfera.android.data.files.datasources.implementation.OCLocalFileDataSource
|
||||
import eu.qsfera.android.data.folderbackup.datasources.LocalFolderBackupDataSource
|
||||
import eu.qsfera.android.data.folderbackup.datasources.implementation.OCLocalFolderBackupDataSource
|
||||
import eu.qsfera.android.data.providers.SharedPreferencesProvider
|
||||
import eu.qsfera.android.data.providers.implementation.OCSharedPreferencesProvider
|
||||
import eu.qsfera.android.data.sharing.shares.datasources.LocalShareDataSource
|
||||
import eu.qsfera.android.data.sharing.shares.datasources.implementation.OCLocalShareDataSource
|
||||
import eu.qsfera.android.data.spaces.datasources.LocalSpacesDataSource
|
||||
import eu.qsfera.android.data.spaces.datasources.implementation.OCLocalSpacesDataSource
|
||||
import eu.qsfera.android.data.providers.LocalStorageProvider
|
||||
import eu.qsfera.android.data.providers.ScopedStorageProvider
|
||||
import eu.qsfera.android.data.transfers.datasources.LocalTransferDataSource
|
||||
import eu.qsfera.android.data.transfers.datasources.implementation.OCLocalTransferDataSource
|
||||
import eu.qsfera.android.data.user.datasources.LocalUserDataSource
|
||||
import eu.qsfera.android.data.user.datasources.implementation.OCLocalUserDataSource
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.module.dsl.factoryOf
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
|
||||
val localDataSourceModule = module {
|
||||
single { AccountManager.get(androidContext()) }
|
||||
|
||||
single { QSferaDatabase.getDatabase(androidContext()).appRegistryDao() }
|
||||
single { QSferaDatabase.getDatabase(androidContext()).capabilityDao() }
|
||||
single { QSferaDatabase.getDatabase(androidContext()).fileDao() }
|
||||
single { QSferaDatabase.getDatabase(androidContext()).folderBackUpDao() }
|
||||
single { QSferaDatabase.getDatabase(androidContext()).shareDao() }
|
||||
single { QSferaDatabase.getDatabase(androidContext()).spacesDao() }
|
||||
single { QSferaDatabase.getDatabase(androidContext()).transferDao() }
|
||||
single { QSferaDatabase.getDatabase(androidContext()).userDao() }
|
||||
|
||||
singleOf(::OCSharedPreferencesProvider) bind SharedPreferencesProvider::class
|
||||
single<LocalStorageProvider> { ScopedStorageProvider(dataFolder, androidContext()) }
|
||||
|
||||
factory<LocalAuthenticationDataSource> { OCLocalAuthenticationDataSource(androidContext(), get(), get(), accountType) }
|
||||
factoryOf(::OCLocalFolderBackupDataSource) bind LocalFolderBackupDataSource::class
|
||||
factoryOf(::OCLocalAppRegistryDataSource) bind LocalAppRegistryDataSource::class
|
||||
factoryOf(::OCLocalCapabilitiesDataSource) bind LocalCapabilitiesDataSource::class
|
||||
factoryOf(::OCLocalFileDataSource) bind LocalFileDataSource::class
|
||||
factoryOf(::OCLocalShareDataSource) bind LocalShareDataSource::class
|
||||
factoryOf(::OCLocalSpacesDataSource) bind LocalSpacesDataSource::class
|
||||
factoryOf(::OCLocalTransferDataSource) bind LocalTransferDataSource::class
|
||||
factoryOf(::OCLocalUserDataSource) bind LocalUserDataSource::class
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David González Verdugo
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.dependecyinjection
|
||||
|
||||
import eu.qsfera.android.MainApp
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.data.ClientManager
|
||||
import eu.qsfera.android.data.appregistry.datasources.RemoteAppRegistryDataSource
|
||||
import eu.qsfera.android.data.appregistry.datasources.implementation.OCRemoteAppRegistryDataSource
|
||||
import eu.qsfera.android.data.authentication.datasources.RemoteAuthenticationDataSource
|
||||
import eu.qsfera.android.data.authentication.datasources.implementation.OCRemoteAuthenticationDataSource
|
||||
import eu.qsfera.android.data.capabilities.datasources.RemoteCapabilitiesDataSource
|
||||
import eu.qsfera.android.data.capabilities.datasources.implementation.OCRemoteCapabilitiesDataSource
|
||||
import eu.qsfera.android.data.capabilities.datasources.mapper.RemoteCapabilityMapper
|
||||
import eu.qsfera.android.data.files.datasources.RemoteFileDataSource
|
||||
import eu.qsfera.android.data.files.datasources.implementation.OCRemoteFileDataSource
|
||||
import eu.qsfera.android.data.oauth.datasources.RemoteOAuthDataSource
|
||||
import eu.qsfera.android.data.oauth.datasources.implementation.OCRemoteOAuthDataSource
|
||||
import eu.qsfera.android.data.server.datasources.RemoteServerInfoDataSource
|
||||
import eu.qsfera.android.data.server.datasources.implementation.OCRemoteServerInfoDataSource
|
||||
import eu.qsfera.android.data.sharing.sharees.datasources.RemoteShareeDataSource
|
||||
import eu.qsfera.android.data.sharing.sharees.datasources.implementation.OCRemoteShareeDataSource
|
||||
import eu.qsfera.android.data.sharing.sharees.datasources.mapper.RemoteShareeMapper
|
||||
import eu.qsfera.android.data.sharing.shares.datasources.RemoteShareDataSource
|
||||
import eu.qsfera.android.data.sharing.shares.datasources.implementation.OCRemoteShareDataSource
|
||||
import eu.qsfera.android.data.sharing.shares.datasources.mapper.RemoteShareMapper
|
||||
import eu.qsfera.android.data.spaces.datasources.RemoteSpacesDataSource
|
||||
import eu.qsfera.android.data.spaces.datasources.implementation.OCRemoteSpacesDataSource
|
||||
import eu.qsfera.android.data.user.datasources.RemoteUserDataSource
|
||||
import eu.qsfera.android.data.user.datasources.implementation.OCRemoteUserDataSource
|
||||
import eu.qsfera.android.data.webfinger.datasources.RemoteWebFingerDataSource
|
||||
import eu.qsfera.android.data.webfinger.datasources.implementation.OCRemoteWebFingerDataSource
|
||||
import eu.qsfera.android.lib.common.ConnectionValidator
|
||||
import eu.qsfera.android.lib.resources.oauth.services.OIDCService
|
||||
import eu.qsfera.android.lib.resources.oauth.services.implementation.OCOIDCService
|
||||
import eu.qsfera.android.lib.resources.status.services.ServerInfoService
|
||||
import eu.qsfera.android.lib.resources.status.services.implementation.OCServerInfoService
|
||||
import eu.qsfera.android.lib.resources.webfinger.services.WebFingerService
|
||||
import eu.qsfera.android.lib.resources.webfinger.services.implementation.OCWebFingerService
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.module.dsl.factoryOf
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
|
||||
val remoteDataSourceModule = module {
|
||||
single { ConnectionValidator(androidContext(), androidContext().resources.getBoolean(R.bool.clear_cookies_on_validation)) }
|
||||
single { ClientManager(get(), get(), androidContext(), MainApp.accountType, get()) }
|
||||
|
||||
singleOf(::OCServerInfoService) bind ServerInfoService::class
|
||||
singleOf(::OCOIDCService) bind OIDCService::class
|
||||
singleOf(::OCWebFingerService) bind WebFingerService::class
|
||||
|
||||
singleOf(::OCRemoteAppRegistryDataSource) bind RemoteAppRegistryDataSource::class
|
||||
singleOf(::OCRemoteAuthenticationDataSource) bind RemoteAuthenticationDataSource::class
|
||||
singleOf(::OCRemoteCapabilitiesDataSource) bind RemoteCapabilitiesDataSource::class
|
||||
singleOf(::OCRemoteFileDataSource) bind RemoteFileDataSource::class
|
||||
singleOf(::OCRemoteOAuthDataSource) bind RemoteOAuthDataSource::class
|
||||
singleOf(::OCRemoteServerInfoDataSource) bind RemoteServerInfoDataSource::class
|
||||
singleOf(::OCRemoteShareDataSource) bind RemoteShareDataSource::class
|
||||
singleOf(::OCRemoteShareeDataSource) bind RemoteShareeDataSource::class
|
||||
singleOf(::OCRemoteSpacesDataSource) bind RemoteSpacesDataSource::class
|
||||
singleOf(::OCRemoteWebFingerDataSource) bind RemoteWebFingerDataSource::class
|
||||
single<RemoteUserDataSource> { OCRemoteUserDataSource(get(), androidContext().resources.getDimension(R.dimen.file_avatar_size).toInt()) }
|
||||
|
||||
factoryOf(::RemoteCapabilityMapper)
|
||||
factoryOf(::RemoteShareMapper)
|
||||
factoryOf(::RemoteShareeMapper)
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David González Verdugo
|
||||
* @author Abel García de Prada
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
*
|
||||
* Copyright (C) 2022 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.dependecyinjection
|
||||
|
||||
import eu.qsfera.android.data.appregistry.repository.OCAppRegistryRepository
|
||||
import eu.qsfera.android.data.authentication.repository.OCAuthenticationRepository
|
||||
import eu.qsfera.android.data.capabilities.repository.OCCapabilityRepository
|
||||
import eu.qsfera.android.data.files.repository.OCFileRepository
|
||||
import eu.qsfera.android.data.folderbackup.repository.OCFolderBackupRepository
|
||||
import eu.qsfera.android.data.oauth.repository.OCOAuthRepository
|
||||
import eu.qsfera.android.data.server.repository.OCServerInfoRepository
|
||||
import eu.qsfera.android.data.sharing.sharees.repository.OCShareeRepository
|
||||
import eu.qsfera.android.data.sharing.shares.repository.OCShareRepository
|
||||
import eu.qsfera.android.data.spaces.repository.OCSpacesRepository
|
||||
import eu.qsfera.android.data.transfers.repository.OCTransferRepository
|
||||
import eu.qsfera.android.data.user.repository.OCUserRepository
|
||||
import eu.qsfera.android.data.webfinger.repository.OCWebFingerRepository
|
||||
import eu.qsfera.android.domain.appregistry.AppRegistryRepository
|
||||
import eu.qsfera.android.domain.authentication.AuthenticationRepository
|
||||
import eu.qsfera.android.domain.authentication.oauth.OAuthRepository
|
||||
import eu.qsfera.android.domain.automaticuploads.FolderBackupRepository
|
||||
import eu.qsfera.android.domain.capabilities.CapabilityRepository
|
||||
import eu.qsfera.android.domain.files.FileRepository
|
||||
import eu.qsfera.android.domain.server.ServerInfoRepository
|
||||
import eu.qsfera.android.domain.sharing.sharees.ShareeRepository
|
||||
import eu.qsfera.android.domain.sharing.shares.ShareRepository
|
||||
import eu.qsfera.android.domain.spaces.SpacesRepository
|
||||
import eu.qsfera.android.domain.transfers.TransferRepository
|
||||
import eu.qsfera.android.domain.user.UserRepository
|
||||
import eu.qsfera.android.domain.webfinger.WebFingerRepository
|
||||
import org.koin.core.module.dsl.factoryOf
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
|
||||
val repositoryModule = module {
|
||||
factoryOf(::OCAppRegistryRepository) bind AppRegistryRepository::class
|
||||
factoryOf(::OCAuthenticationRepository) bind AuthenticationRepository::class
|
||||
factoryOf(::OCCapabilityRepository) bind CapabilityRepository::class
|
||||
factoryOf(::OCFileRepository) bind FileRepository::class
|
||||
factoryOf(::OCFolderBackupRepository) bind FolderBackupRepository::class
|
||||
factoryOf(::OCOAuthRepository) bind OAuthRepository::class
|
||||
factoryOf(::OCServerInfoRepository) bind ServerInfoRepository::class
|
||||
factoryOf(::OCShareRepository) bind ShareRepository::class
|
||||
factoryOf(::OCShareeRepository) bind ShareeRepository::class
|
||||
factoryOf(::OCSpacesRepository) bind SpacesRepository::class
|
||||
factoryOf(::OCTransferRepository) bind TransferRepository::class
|
||||
factoryOf(::OCUserRepository) bind UserRepository::class
|
||||
factoryOf(::OCWebFingerRepository) bind WebFingerRepository::class
|
||||
}
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David González Verdugo
|
||||
* @author Abel García de Prada
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
* @author Aitor Ballesteros Pavón
|
||||
* @author Jorge Aguado Recio
|
||||
*
|
||||
* Copyright (C) 2024 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.dependecyinjection
|
||||
|
||||
import eu.qsfera.android.domain.appregistry.usecases.CreateFileWithAppProviderUseCase
|
||||
import eu.qsfera.android.domain.appregistry.usecases.GetAppRegistryForMimeTypeAsStreamUseCase
|
||||
import eu.qsfera.android.domain.appregistry.usecases.GetAppRegistryWhichAllowCreationAsStreamUseCase
|
||||
import eu.qsfera.android.domain.appregistry.usecases.GetUrlToOpenInWebUseCase
|
||||
import eu.qsfera.android.domain.authentication.oauth.OIDCDiscoveryUseCase
|
||||
import eu.qsfera.android.domain.authentication.oauth.RegisterClientUseCase
|
||||
import eu.qsfera.android.domain.authentication.oauth.RequestTokenUseCase
|
||||
import eu.qsfera.android.domain.authentication.usecases.GetBaseUrlUseCase
|
||||
import eu.qsfera.android.domain.authentication.usecases.LoginBasicAsyncUseCase
|
||||
import eu.qsfera.android.domain.authentication.usecases.LoginOAuthAsyncUseCase
|
||||
import eu.qsfera.android.domain.authentication.usecases.SupportsOAuth2UseCase
|
||||
import eu.qsfera.android.domain.availableoffline.usecases.GetFilesAvailableOfflineFromAccountAsStreamUseCase
|
||||
import eu.qsfera.android.domain.availableoffline.usecases.GetFilesAvailableOfflineFromAccountUseCase
|
||||
import eu.qsfera.android.domain.availableoffline.usecases.GetFilesAvailableOfflineFromEveryAccountUseCase
|
||||
import eu.qsfera.android.domain.availableoffline.usecases.SetFilesAsAvailableOfflineUseCase
|
||||
import eu.qsfera.android.domain.availableoffline.usecases.UnsetFilesAsAvailableOfflineUseCase
|
||||
import eu.qsfera.android.domain.automaticuploads.usecases.GetAutomaticUploadsConfigurationUseCase
|
||||
import eu.qsfera.android.domain.automaticuploads.usecases.GetPictureUploadsConfigurationStreamUseCase
|
||||
import eu.qsfera.android.domain.automaticuploads.usecases.GetVideoUploadsConfigurationStreamUseCase
|
||||
import eu.qsfera.android.domain.automaticuploads.usecases.ResetPictureUploadsUseCase
|
||||
import eu.qsfera.android.domain.automaticuploads.usecases.ResetVideoUploadsUseCase
|
||||
import eu.qsfera.android.domain.automaticuploads.usecases.SavePictureUploadsConfigurationUseCase
|
||||
import eu.qsfera.android.domain.automaticuploads.usecases.SaveVideoUploadsConfigurationUseCase
|
||||
import eu.qsfera.android.domain.capabilities.usecases.GetCapabilitiesAsLiveDataUseCase
|
||||
import eu.qsfera.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase
|
||||
import eu.qsfera.android.domain.capabilities.usecases.RefreshCapabilitiesFromServerAsyncUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.IsAnyFileAvailableLocallyAndNotAvailableOfflineUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.CleanConflictUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.CleanWorkersUUIDUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.CopyFileUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.CreateFolderAsyncUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.DisableThumbnailsForFileUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.GetFileByIdAsStreamUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.GetFileByIdUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.GetFileByRemotePathUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.GetFileWithSyncInfoByIdUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.GetFolderContentAsStreamUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.GetFolderContentUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.GetFolderImagesUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.GetPersonalRootFolderForAccountUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.GetSearchFolderContentUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.GetSharedByLinkForAccountAsStreamUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.GetSharesRootFolderForAccount
|
||||
import eu.qsfera.android.domain.files.usecases.GetWebDavUrlForSpaceUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.ManageDeepLinkUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.MoveFileUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.RemoveFileUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.RenameFileUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.SaveConflictUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.SaveDownloadWorkerUUIDUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.SaveFileOrFolderUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.SetLastUsageFileUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.SortFilesUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.SortFilesWithSyncInfoUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.UpdateAlreadyDownloadedFilesPathUseCase
|
||||
import eu.qsfera.android.domain.server.usecases.GetServerInfoAsyncUseCase
|
||||
import eu.qsfera.android.domain.sharing.sharees.GetShareesAsyncUseCase
|
||||
import eu.qsfera.android.domain.sharing.shares.usecases.CreatePrivateShareAsyncUseCase
|
||||
import eu.qsfera.android.domain.sharing.shares.usecases.CreatePublicShareAsyncUseCase
|
||||
import eu.qsfera.android.domain.sharing.shares.usecases.DeleteShareAsyncUseCase
|
||||
import eu.qsfera.android.domain.sharing.shares.usecases.EditPrivateShareAsyncUseCase
|
||||
import eu.qsfera.android.domain.sharing.shares.usecases.EditPublicShareAsyncUseCase
|
||||
import eu.qsfera.android.domain.sharing.shares.usecases.GetShareAsLiveDataUseCase
|
||||
import eu.qsfera.android.domain.sharing.shares.usecases.GetSharesAsLiveDataUseCase
|
||||
import eu.qsfera.android.domain.sharing.shares.usecases.RefreshSharesFromServerAsyncUseCase
|
||||
import eu.qsfera.android.domain.spaces.usecases.GetPersonalAndProjectSpacesForAccountUseCase
|
||||
import eu.qsfera.android.domain.spaces.usecases.GetPersonalAndProjectSpacesWithSpecialsForAccountAsStreamUseCase
|
||||
import eu.qsfera.android.domain.spaces.usecases.GetPersonalSpaceForAccountUseCase
|
||||
import eu.qsfera.android.domain.spaces.usecases.GetPersonalSpacesWithSpecialsForAccountAsStreamUseCase
|
||||
import eu.qsfera.android.domain.spaces.usecases.GetProjectSpacesWithSpecialsForAccountAsStreamUseCase
|
||||
import eu.qsfera.android.domain.spaces.usecases.GetSpaceByIdForAccountUseCase
|
||||
import eu.qsfera.android.domain.spaces.usecases.GetSpaceWithSpecialsByIdForAccountUseCase
|
||||
import eu.qsfera.android.domain.spaces.usecases.GetSpacesFromEveryAccountUseCaseAsStream
|
||||
import eu.qsfera.android.domain.spaces.usecases.RefreshSpacesFromServerAsyncUseCase
|
||||
import eu.qsfera.android.domain.transfers.usecases.ClearSuccessfulTransfersUseCase
|
||||
import eu.qsfera.android.domain.transfers.usecases.GetAllTransfersAsStreamUseCase
|
||||
import eu.qsfera.android.domain.transfers.usecases.GetAllTransfersUseCase
|
||||
import eu.qsfera.android.domain.transfers.usecases.UpdatePendingUploadsPathUseCase
|
||||
import eu.qsfera.android.domain.user.usecases.GetStoredQuotaUseCase
|
||||
import eu.qsfera.android.domain.user.usecases.GetStoredQuotaAsStreamUseCase
|
||||
import eu.qsfera.android.domain.user.usecases.GetUserAvatarAsyncUseCase
|
||||
import eu.qsfera.android.domain.user.usecases.GetUserInfoAsyncUseCase
|
||||
import eu.qsfera.android.domain.user.usecases.GetUserQuotasUseCase
|
||||
import eu.qsfera.android.domain.user.usecases.GetUserQuotasAsStreamUseCase
|
||||
import eu.qsfera.android.domain.user.usecases.RefreshUserQuotaFromServerAsyncUseCase
|
||||
import eu.qsfera.android.domain.webfinger.usecases.GetQSferaInstanceFromWebFingerUseCase
|
||||
import eu.qsfera.android.domain.webfinger.usecases.GetQSferaInstancesFromAuthenticatedWebFingerUseCase
|
||||
import eu.qsfera.android.usecases.accounts.RemoveAccountUseCase
|
||||
import eu.qsfera.android.usecases.files.FilterFileMenuOptionsUseCase
|
||||
import eu.qsfera.android.usecases.files.RemoveLocalFilesForAccountUseCase
|
||||
import eu.qsfera.android.usecases.files.RemoveLocallyFilesWithLastUsageOlderThanGivenTimeUseCase
|
||||
import eu.qsfera.android.usecases.synchronization.SynchronizeFileUseCase
|
||||
import eu.qsfera.android.usecases.synchronization.SynchronizeFolderUseCase
|
||||
import eu.qsfera.android.usecases.transfers.downloads.CancelDownloadForFileUseCase
|
||||
import eu.qsfera.android.usecases.transfers.downloads.CancelDownloadsRecursivelyUseCase
|
||||
import eu.qsfera.android.usecases.transfers.downloads.DownloadFileUseCase
|
||||
import eu.qsfera.android.usecases.transfers.downloads.GetLiveDataForDownloadingFileUseCase
|
||||
import eu.qsfera.android.usecases.transfers.downloads.GetLiveDataForFinishedDownloadsFromAccountUseCase
|
||||
import eu.qsfera.android.usecases.transfers.uploads.CancelTransfersFromAccountUseCase
|
||||
import eu.qsfera.android.usecases.transfers.uploads.CancelUploadForFileUseCase
|
||||
import eu.qsfera.android.usecases.transfers.uploads.CancelUploadUseCase
|
||||
import eu.qsfera.android.usecases.transfers.uploads.CancelUploadsRecursivelyUseCase
|
||||
import eu.qsfera.android.usecases.transfers.uploads.ClearFailedTransfersUseCase
|
||||
import eu.qsfera.android.usecases.transfers.uploads.RetryFailedUploadsForAccountUseCase
|
||||
import eu.qsfera.android.usecases.transfers.uploads.RetryFailedUploadsUseCase
|
||||
import eu.qsfera.android.usecases.transfers.uploads.RetryUploadFromContentUriUseCase
|
||||
import eu.qsfera.android.usecases.transfers.uploads.RetryUploadFromSystemUseCase
|
||||
import eu.qsfera.android.usecases.transfers.uploads.UploadFileFromContentUriUseCase
|
||||
import eu.qsfera.android.usecases.transfers.uploads.UploadFileFromSystemUseCase
|
||||
import eu.qsfera.android.usecases.transfers.uploads.UploadFileInConflictUseCase
|
||||
import eu.qsfera.android.usecases.transfers.uploads.UploadFilesFromContentUriUseCase
|
||||
import eu.qsfera.android.usecases.transfers.uploads.UploadFilesFromSystemUseCase
|
||||
import org.koin.core.module.dsl.factoryOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
val useCaseModule = module {
|
||||
// Authentication
|
||||
factoryOf(::GetBaseUrlUseCase)
|
||||
factoryOf(::GetQSferaInstanceFromWebFingerUseCase)
|
||||
factoryOf(::GetQSferaInstancesFromAuthenticatedWebFingerUseCase)
|
||||
factoryOf(::LoginBasicAsyncUseCase)
|
||||
factoryOf(::LoginOAuthAsyncUseCase)
|
||||
factoryOf(::SupportsOAuth2UseCase)
|
||||
|
||||
// OAuth
|
||||
factoryOf(::OIDCDiscoveryUseCase)
|
||||
factoryOf(::RegisterClientUseCase)
|
||||
factoryOf(::RequestTokenUseCase)
|
||||
|
||||
// Capabilities
|
||||
factoryOf(::GetCapabilitiesAsLiveDataUseCase)
|
||||
factoryOf(::GetStoredCapabilitiesUseCase)
|
||||
factoryOf(::RefreshCapabilitiesFromServerAsyncUseCase)
|
||||
|
||||
// Files
|
||||
factoryOf(::CleanConflictUseCase)
|
||||
factoryOf(::CleanWorkersUUIDUseCase)
|
||||
factoryOf(::CopyFileUseCase)
|
||||
factoryOf(::CreateFolderAsyncUseCase)
|
||||
factoryOf(::DisableThumbnailsForFileUseCase)
|
||||
factoryOf(::FilterFileMenuOptionsUseCase)
|
||||
factoryOf(::GetFileByIdAsStreamUseCase)
|
||||
factoryOf(::GetFileByIdUseCase)
|
||||
factoryOf(::GetFileByRemotePathUseCase)
|
||||
factoryOf(::GetFileWithSyncInfoByIdUseCase)
|
||||
factoryOf(::GetFolderContentAsStreamUseCase)
|
||||
factoryOf(::GetFolderContentUseCase)
|
||||
factoryOf(::GetFolderImagesUseCase)
|
||||
factoryOf(::IsAnyFileAvailableLocallyAndNotAvailableOfflineUseCase)
|
||||
factoryOf(::GetPersonalRootFolderForAccountUseCase)
|
||||
factoryOf(::GetSearchFolderContentUseCase)
|
||||
factoryOf(::GetSharedByLinkForAccountAsStreamUseCase)
|
||||
factoryOf(::GetSharesRootFolderForAccount)
|
||||
factoryOf(::GetUrlToOpenInWebUseCase)
|
||||
factoryOf(::ManageDeepLinkUseCase)
|
||||
factoryOf(::MoveFileUseCase)
|
||||
factoryOf(::RemoveFileUseCase)
|
||||
factoryOf(::RemoveLocalFilesForAccountUseCase)
|
||||
factoryOf(::RemoveLocallyFilesWithLastUsageOlderThanGivenTimeUseCase)
|
||||
factoryOf(::RenameFileUseCase)
|
||||
factoryOf(::SaveConflictUseCase)
|
||||
factoryOf(::SaveDownloadWorkerUUIDUseCase)
|
||||
factoryOf(::SaveFileOrFolderUseCase)
|
||||
factoryOf(::SetLastUsageFileUseCase)
|
||||
factoryOf(::SortFilesUseCase)
|
||||
factoryOf(::SortFilesWithSyncInfoUseCase)
|
||||
factoryOf(::SynchronizeFileUseCase)
|
||||
factoryOf(::SynchronizeFolderUseCase)
|
||||
|
||||
// Open in web
|
||||
factoryOf(::CreateFileWithAppProviderUseCase)
|
||||
factoryOf(::GetAppRegistryForMimeTypeAsStreamUseCase)
|
||||
factoryOf(::GetAppRegistryWhichAllowCreationAsStreamUseCase)
|
||||
factoryOf(::GetUrlToOpenInWebUseCase)
|
||||
|
||||
// Av Offline
|
||||
factoryOf(::GetFilesAvailableOfflineFromAccountAsStreamUseCase)
|
||||
factoryOf(::GetFilesAvailableOfflineFromAccountUseCase)
|
||||
factoryOf(::GetFilesAvailableOfflineFromEveryAccountUseCase)
|
||||
factoryOf(::SetFilesAsAvailableOfflineUseCase)
|
||||
factoryOf(::UnsetFilesAsAvailableOfflineUseCase)
|
||||
|
||||
// Sharing
|
||||
factoryOf(::CreatePrivateShareAsyncUseCase)
|
||||
factoryOf(::CreatePublicShareAsyncUseCase)
|
||||
factoryOf(::DeleteShareAsyncUseCase)
|
||||
factoryOf(::EditPrivateShareAsyncUseCase)
|
||||
factoryOf(::EditPublicShareAsyncUseCase)
|
||||
factoryOf(::GetShareAsLiveDataUseCase)
|
||||
factoryOf(::GetShareesAsyncUseCase)
|
||||
factoryOf(::GetSharesAsLiveDataUseCase)
|
||||
factoryOf(::RefreshSharesFromServerAsyncUseCase)
|
||||
|
||||
// Spaces
|
||||
factoryOf(::GetPersonalAndProjectSpacesForAccountUseCase)
|
||||
factoryOf(::GetPersonalAndProjectSpacesWithSpecialsForAccountAsStreamUseCase)
|
||||
factoryOf(::GetPersonalSpaceForAccountUseCase)
|
||||
factoryOf(::GetPersonalSpacesWithSpecialsForAccountAsStreamUseCase)
|
||||
factoryOf(::GetProjectSpacesWithSpecialsForAccountAsStreamUseCase)
|
||||
factoryOf(::GetSpaceWithSpecialsByIdForAccountUseCase)
|
||||
factoryOf(::GetSpacesFromEveryAccountUseCaseAsStream)
|
||||
factoryOf(::GetWebDavUrlForSpaceUseCase)
|
||||
factoryOf(::RefreshSpacesFromServerAsyncUseCase)
|
||||
factoryOf(::GetSpaceByIdForAccountUseCase)
|
||||
|
||||
// Transfers
|
||||
factoryOf(::CancelDownloadForFileUseCase)
|
||||
factoryOf(::CancelDownloadsRecursivelyUseCase)
|
||||
factoryOf(::CancelTransfersFromAccountUseCase)
|
||||
factoryOf(::CancelUploadForFileUseCase)
|
||||
factoryOf(::CancelUploadUseCase)
|
||||
factoryOf(::CancelUploadsRecursivelyUseCase)
|
||||
factoryOf(::ClearFailedTransfersUseCase)
|
||||
factoryOf(::ClearSuccessfulTransfersUseCase)
|
||||
factoryOf(::DownloadFileUseCase)
|
||||
factoryOf(::GetAllTransfersAsStreamUseCase)
|
||||
factoryOf(::GetAllTransfersUseCase)
|
||||
factoryOf(::GetLiveDataForDownloadingFileUseCase)
|
||||
factoryOf(::GetLiveDataForFinishedDownloadsFromAccountUseCase)
|
||||
factoryOf(::RetryFailedUploadsForAccountUseCase)
|
||||
factoryOf(::RetryFailedUploadsUseCase)
|
||||
factoryOf(::RetryUploadFromContentUriUseCase)
|
||||
factoryOf(::RetryUploadFromSystemUseCase)
|
||||
factoryOf(::UpdateAlreadyDownloadedFilesPathUseCase)
|
||||
factoryOf(::UpdatePendingUploadsPathUseCase)
|
||||
factoryOf(::UploadFileFromContentUriUseCase)
|
||||
factoryOf(::UploadFileFromSystemUseCase)
|
||||
factoryOf(::UploadFileInConflictUseCase)
|
||||
factoryOf(::UploadFilesFromContentUriUseCase)
|
||||
factoryOf(::UploadFilesFromSystemUseCase)
|
||||
|
||||
// User
|
||||
factoryOf(::GetStoredQuotaAsStreamUseCase)
|
||||
factoryOf(::GetStoredQuotaUseCase)
|
||||
factoryOf(::GetUserAvatarAsyncUseCase)
|
||||
factoryOf(::GetUserInfoAsyncUseCase)
|
||||
factoryOf(::GetUserQuotasAsStreamUseCase)
|
||||
factoryOf(::GetUserQuotasUseCase)
|
||||
factoryOf(::RefreshUserQuotaFromServerAsyncUseCase)
|
||||
|
||||
// Server
|
||||
factoryOf(::GetServerInfoAsyncUseCase)
|
||||
|
||||
// Camera Uploads
|
||||
factoryOf(::GetAutomaticUploadsConfigurationUseCase)
|
||||
factoryOf(::GetPictureUploadsConfigurationStreamUseCase)
|
||||
factoryOf(::GetVideoUploadsConfigurationStreamUseCase)
|
||||
factoryOf(::ResetPictureUploadsUseCase)
|
||||
factoryOf(::ResetVideoUploadsUseCase)
|
||||
factoryOf(::SavePictureUploadsConfigurationUseCase)
|
||||
factoryOf(::SaveVideoUploadsConfigurationUseCase)
|
||||
|
||||
// Accounts
|
||||
factoryOf(::RemoveAccountUseCase)
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David González Verdugo
|
||||
* @author Abel García de Prada
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
* @author David Crespo Ríos
|
||||
*
|
||||
* Copyright (C) 2024 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.dependecyinjection
|
||||
|
||||
import eu.qsfera.android.MainApp
|
||||
import eu.qsfera.android.domain.files.model.FileListOption
|
||||
import eu.qsfera.android.domain.files.model.OCFile
|
||||
import eu.qsfera.android.presentation.accounts.ManageAccountsViewModel
|
||||
import eu.qsfera.android.presentation.authentication.AuthenticationViewModel
|
||||
import eu.qsfera.android.presentation.authentication.oauth.OAuthViewModel
|
||||
import eu.qsfera.android.presentation.capabilities.CapabilityViewModel
|
||||
import eu.qsfera.android.presentation.common.DrawerViewModel
|
||||
import eu.qsfera.android.presentation.conflicts.ConflictsResolveViewModel
|
||||
import eu.qsfera.android.presentation.files.details.FileDetailsViewModel
|
||||
import eu.qsfera.android.presentation.files.filelist.MainFileListViewModel
|
||||
import eu.qsfera.android.presentation.files.operations.FileOperationsViewModel
|
||||
import eu.qsfera.android.presentation.logging.LogListViewModel
|
||||
import eu.qsfera.android.presentation.migration.MigrationViewModel
|
||||
import eu.qsfera.android.presentation.previews.PreviewAudioViewModel
|
||||
import eu.qsfera.android.presentation.previews.PreviewTextViewModel
|
||||
import eu.qsfera.android.presentation.previews.PreviewVideoViewModel
|
||||
import eu.qsfera.android.presentation.releasenotes.ReleaseNotesViewModel
|
||||
import eu.qsfera.android.presentation.security.biometric.BiometricViewModel
|
||||
import eu.qsfera.android.presentation.security.passcode.PassCodeViewModel
|
||||
import eu.qsfera.android.presentation.security.passcode.PasscodeAction
|
||||
import eu.qsfera.android.presentation.security.pattern.PatternViewModel
|
||||
import eu.qsfera.android.presentation.settings.SettingsViewModel
|
||||
import eu.qsfera.android.presentation.settings.advanced.SettingsAdvancedViewModel
|
||||
import eu.qsfera.android.presentation.settings.automaticuploads.SettingsPictureUploadsViewModel
|
||||
import eu.qsfera.android.presentation.settings.automaticuploads.SettingsVideoUploadsViewModel
|
||||
import eu.qsfera.android.presentation.settings.logging.SettingsLogsViewModel
|
||||
import eu.qsfera.android.presentation.settings.more.SettingsMoreViewModel
|
||||
import eu.qsfera.android.presentation.settings.security.SettingsSecurityViewModel
|
||||
import eu.qsfera.android.presentation.sharing.ShareViewModel
|
||||
import eu.qsfera.android.presentation.spaces.SpacesListViewModel
|
||||
import eu.qsfera.android.presentation.transfers.TransfersViewModel
|
||||
import eu.qsfera.android.ui.ReceiveExternalFilesViewModel
|
||||
import eu.qsfera.android.ui.preview.PreviewImageViewModel
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.androidx.viewmodel.dsl.viewModelOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
val viewModelModule = module {
|
||||
viewModelOf(::ManageAccountsViewModel)
|
||||
viewModelOf(::BiometricViewModel)
|
||||
viewModelOf(::DrawerViewModel)
|
||||
viewModelOf(::FileDetailsViewModel)
|
||||
viewModelOf(::FileOperationsViewModel)
|
||||
viewModelOf(::LogListViewModel)
|
||||
viewModelOf(::OAuthViewModel)
|
||||
viewModelOf(::PatternViewModel)
|
||||
viewModelOf(::PreviewAudioViewModel)
|
||||
viewModelOf(::PreviewImageViewModel)
|
||||
viewModelOf(::PreviewTextViewModel)
|
||||
viewModelOf(::PreviewVideoViewModel)
|
||||
viewModelOf(::ReceiveExternalFilesViewModel)
|
||||
viewModelOf(::ReleaseNotesViewModel)
|
||||
viewModelOf(::SettingsAdvancedViewModel)
|
||||
viewModelOf(::SettingsLogsViewModel)
|
||||
viewModelOf(::SettingsMoreViewModel)
|
||||
viewModelOf(::SettingsPictureUploadsViewModel)
|
||||
viewModelOf(::SettingsSecurityViewModel)
|
||||
viewModelOf(::SettingsVideoUploadsViewModel)
|
||||
viewModelOf(::SettingsViewModel)
|
||||
viewModelOf(::FileOperationsViewModel)
|
||||
|
||||
viewModel { (accountName: String) -> CapabilityViewModel(accountName, get(), get(), get(), get()) }
|
||||
viewModel { (action: PasscodeAction) -> PassCodeViewModel(get(), get(), action) }
|
||||
viewModel { (filePath: String, accountName: String) ->
|
||||
ShareViewModel(filePath, accountName, get(), get(), get(), get(), get(), get(), get(), get(), get(), get())
|
||||
}
|
||||
viewModel { (initialFolderToDisplay: OCFile, fileListOption: FileListOption) ->
|
||||
MainFileListViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(),
|
||||
initialFolderToDisplay, fileListOption)
|
||||
}
|
||||
viewModel { (ocFile: OCFile) -> ConflictsResolveViewModel(get(), get(), get(), get(), get(), ocFile) }
|
||||
viewModel { AuthenticationViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) }
|
||||
viewModel { MigrationViewModel(MainApp.dataFolder, get(), get(), get(), get(), get(), get(), get()) }
|
||||
viewModel { TransfersViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(),
|
||||
get()) }
|
||||
viewModel { ReceiveExternalFilesViewModel(get(), get(), get(), get()) }
|
||||
viewModel { (accountName: String, showPersonalSpace: Boolean) ->
|
||||
SpacesListViewModel(get(), get(), get(), get(), get(), get(), get(), accountName, showPersonalSpace)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David González Verdugo
|
||||
* @author Aitor Ballesteros Pavón
|
||||
*
|
||||
* Copyright (C) 2024 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.extensions
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Intent.FLAG_ACTIVITY_NO_HISTORY
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ResolveInfo
|
||||
import android.net.Uri
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.util.TypedValue
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import eu.qsfera.android.BuildConfig
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.data.providers.implementation.OCSharedPreferencesProvider
|
||||
import eu.qsfera.android.domain.files.model.OCFile
|
||||
import eu.qsfera.android.presentation.common.ShareSheetHelper
|
||||
import eu.qsfera.android.presentation.security.LockEnforcedType
|
||||
import eu.qsfera.android.presentation.security.LockEnforcedType.Companion.parseFromInteger
|
||||
import eu.qsfera.android.presentation.security.LockType
|
||||
import eu.qsfera.android.presentation.security.SecurityEnforced
|
||||
import eu.qsfera.android.presentation.security.biometric.BiometricActivity
|
||||
import eu.qsfera.android.presentation.security.biometric.BiometricStatus
|
||||
import eu.qsfera.android.presentation.security.biometric.EnableBiometrics
|
||||
import eu.qsfera.android.presentation.security.isDeviceSecure
|
||||
import eu.qsfera.android.presentation.security.passcode.PassCodeActivity
|
||||
import eu.qsfera.android.presentation.security.pattern.PatternActivity
|
||||
import eu.qsfera.android.presentation.settings.privacypolicy.PrivacyPolicyActivity
|
||||
import eu.qsfera.android.presentation.settings.security.SettingsSecurityFragment.Companion.EXTRAS_LOCK_ENFORCED
|
||||
import eu.qsfera.android.providers.MdmProvider
|
||||
import eu.qsfera.android.ui.activity.DrawerActivity
|
||||
import eu.qsfera.android.ui.activity.FileDisplayActivity.Companion.ALL_FILES_SAF_REGEX
|
||||
import eu.qsfera.android.utils.CONFIGURATION_DEVICE_PROTECTION
|
||||
import eu.qsfera.android.utils.MimetypeIconUtil
|
||||
import eu.qsfera.android.utils.UriUtilsKt.getExposedFileUriForOCFile
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.ext.android.inject
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
|
||||
fun Activity.showErrorInSnackbar(genericErrorMessageId: Int, throwable: Throwable?) =
|
||||
throwable?.let {
|
||||
showMessageInSnackbar(
|
||||
message = it.parseError(getString(genericErrorMessageId), resources)
|
||||
)
|
||||
}
|
||||
|
||||
fun Activity.showMessageInSnackbar(
|
||||
layoutId: Int = android.R.id.content,
|
||||
message: CharSequence,
|
||||
duration: Int = Snackbar.LENGTH_LONG
|
||||
) {
|
||||
Snackbar.make(findViewById(layoutId), message, duration).show()
|
||||
}
|
||||
|
||||
fun Activity.showErrorInToast(
|
||||
genericErrorMessageId: Int,
|
||||
throwable: Throwable?,
|
||||
duration: Int = Toast.LENGTH_SHORT
|
||||
) =
|
||||
throwable?.let {
|
||||
Toast.makeText(
|
||||
this,
|
||||
it.parseError(getString(genericErrorMessageId), resources),
|
||||
duration
|
||||
).show()
|
||||
}
|
||||
|
||||
fun Activity.goToUrl(
|
||||
url: String,
|
||||
flags: Int? = null
|
||||
) {
|
||||
if (url.isNotEmpty()) {
|
||||
val uriUrl = Uri.parse(url)
|
||||
val intent = Intent(Intent.ACTION_VIEW, uriUrl)
|
||||
if (flags != null) intent.addFlags(flags)
|
||||
|
||||
try {
|
||||
startActivity(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
showMessageInSnackbar(message = this.getString(R.string.file_list_no_app_for_perform_action))
|
||||
Timber.e("No Activity found to handle Intent")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Activity.openPrivacyPolicy() {
|
||||
val urlPrivacyPolicy = getString(R.string.url_privacy_policy)
|
||||
|
||||
val cantBeOpenedWithWebView = urlPrivacyPolicy.endsWith("pdf")
|
||||
if (cantBeOpenedWithWebView) {
|
||||
goToUrl(urlPrivacyPolicy)
|
||||
} else {
|
||||
val intent = Intent(this, PrivacyPolicyActivity::class.java)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun Activity.sendEmail(
|
||||
email: String,
|
||||
subject: String? = null,
|
||||
text: String? = null
|
||||
) {
|
||||
val intent = Intent(Intent.ACTION_SENDTO).apply {
|
||||
data = Uri.parse(email)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
putExtra(Intent.EXTRA_SUBJECT, subject)
|
||||
if (text != null) putExtra(Intent.EXTRA_TEXT, text)
|
||||
}
|
||||
|
||||
try {
|
||||
startActivity(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
showMessageInSnackbar(message = this.getString(R.string.file_list_no_app_for_perform_action))
|
||||
Timber.e("No Activity found to handle Intent")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getIntentForSavedMimeType(data: Uri, type: String): Intent {
|
||||
val intentForSavedMimeType = Intent(Intent.ACTION_VIEW)
|
||||
intentForSavedMimeType.setDataAndType(data, type)
|
||||
intentForSavedMimeType.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
return intentForSavedMimeType
|
||||
}
|
||||
|
||||
private fun getIntentForGuessedMimeType(storagePath: String, type: String, data: Uri): Intent? {
|
||||
var intentForGuessedMimeType: Intent? = null
|
||||
if (storagePath.lastIndexOf('.') >= 0) {
|
||||
val guessedMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(storagePath.substring(storagePath.lastIndexOf('.') + 1))
|
||||
if (guessedMimeType != null && guessedMimeType != type) {
|
||||
intentForGuessedMimeType = Intent(Intent.ACTION_VIEW)
|
||||
intentForGuessedMimeType.setDataAndType(data, guessedMimeType)
|
||||
intentForGuessedMimeType.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
}
|
||||
}
|
||||
return intentForGuessedMimeType
|
||||
}
|
||||
|
||||
fun Activity.openFile(file: File?) {
|
||||
if (file != null) {
|
||||
|
||||
val intentForSavedMimeType = getIntentForSavedMimeType(
|
||||
getExposedFileUri(this, file.path)!!,
|
||||
MimetypeIconUtil.getBestMimeTypeByFilename(file.name)
|
||||
)
|
||||
|
||||
val intentForGuessedMimeType = getIntentForGuessedMimeType(
|
||||
file.path,
|
||||
MimetypeIconUtil.getBestMimeTypeByFilename(file.name), getExposedFileUri(this, file.path)!!
|
||||
)
|
||||
|
||||
openFileWithIntent(intentForSavedMimeType, intentForGuessedMimeType)
|
||||
} else {
|
||||
Timber.e("Trying to open a NULL file")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getExposedFileUri(context: Context, localPath: String): Uri? {
|
||||
var exposedFileUri: Uri? = null
|
||||
|
||||
if (localPath.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Use the FileProvider to get a content URI
|
||||
try {
|
||||
exposedFileUri = FileProvider.getUriForFile(
|
||||
context,
|
||||
context.getString(R.string.file_provider_authority),
|
||||
File(localPath)
|
||||
)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Timber.e(e, "File can't be exported")
|
||||
}
|
||||
|
||||
return exposedFileUri
|
||||
}
|
||||
|
||||
fun Activity.openFileWithIntent(intentForSavedMimeType: Intent, intentForGuessedMimeType: Intent?) {
|
||||
val openFileWithIntent: Intent = intentForGuessedMimeType ?: intentForSavedMimeType
|
||||
val launchables: List<ResolveInfo> =
|
||||
this.packageManager.queryIntentActivities(openFileWithIntent, PackageManager.MATCH_DEFAULT_ONLY)
|
||||
if (launchables.isNotEmpty()) {
|
||||
try {
|
||||
this.startActivity(
|
||||
Intent.createChooser(
|
||||
openFileWithIntent, this.getString(R.string.actionbar_open_with)
|
||||
)
|
||||
)
|
||||
} catch (anfe: ActivityNotFoundException) {
|
||||
Timber.i(anfe, "No app found for file type")
|
||||
showMessageInSnackbar(
|
||||
message = this.getString(
|
||||
R.string.file_list_no_app_for_file_type
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
showMessageInSnackbar(
|
||||
message = this.getString(
|
||||
R.string.file_list_no_app_for_file_type
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun AppCompatActivity.sendFile(file: File?) {
|
||||
if (file != null) {
|
||||
val sendIntent: Intent = makeIntent(file, this)
|
||||
val shareSheetIntent = ShareSheetHelper().getShareSheetIntent(
|
||||
intent = sendIntent,
|
||||
context = this,
|
||||
title = R.string.activity_chooser_send_file_title,
|
||||
packagesToExclude = arrayOf()
|
||||
)
|
||||
this.startActivity(shareSheetIntent)
|
||||
} else {
|
||||
Timber.e("Trying to send a NULL file")
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeIntent(file: File?, context: Context): Intent {
|
||||
val sendIntent = Intent(Intent.ACTION_SEND)
|
||||
if (file != null) {
|
||||
// set MimeType
|
||||
sendIntent.type = MimetypeIconUtil.getBestMimeTypeByFilename(file.name)
|
||||
sendIntent.putExtra(
|
||||
Intent.EXTRA_STREAM,
|
||||
getExposedFileUri(context, file.path)
|
||||
)
|
||||
}
|
||||
sendIntent.putExtra(Intent.ACTION_SEND, true) // Send Action
|
||||
return sendIntent
|
||||
}
|
||||
|
||||
fun Activity.hideSoftKeyboard() {
|
||||
val focusedView = currentFocus
|
||||
focusedView?.let {
|
||||
val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
inputMethodManager.hideSoftInputFromWindow(
|
||||
focusedView.windowToken,
|
||||
0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Activity.checkPasscodeEnforced(securityEnforced: SecurityEnforced) {
|
||||
val sharedPreferencesProvider = OCSharedPreferencesProvider(this)
|
||||
val mdmProvider by inject<MdmProvider>()
|
||||
|
||||
// If device protection is false, launch the previous behaviour (check the lockEnforced).
|
||||
// If device protection is true, ask for security only if device is not secure.
|
||||
val showDeviceProtectionForced: Boolean =
|
||||
mdmProvider.getBrandingBoolean(CONFIGURATION_DEVICE_PROTECTION, R.bool.device_protection) && !isDeviceSecure()
|
||||
val lockEnforced: Int = this.resources.getInteger(R.integer.lock_enforced)
|
||||
val passcodeConfigured = sharedPreferencesProvider.getBoolean(PassCodeActivity.PREFERENCE_SET_PASSCODE, false)
|
||||
val patternConfigured = sharedPreferencesProvider.getBoolean(PatternActivity.PREFERENCE_SET_PATTERN, false)
|
||||
|
||||
when (parseFromInteger(lockEnforced)) {
|
||||
LockEnforcedType.DISABLED -> {
|
||||
if (showDeviceProtectionForced) {
|
||||
showSelectSecurityDialog(passcodeConfigured, patternConfigured, securityEnforced)
|
||||
}
|
||||
}
|
||||
|
||||
LockEnforcedType.EITHER_ENFORCED -> {
|
||||
showSelectSecurityDialog(passcodeConfigured, patternConfigured, securityEnforced)
|
||||
}
|
||||
|
||||
LockEnforcedType.PASSCODE_ENFORCED -> {
|
||||
if (!passcodeConfigured) {
|
||||
manageOptionLockSelected(LockType.PASSCODE)
|
||||
}
|
||||
}
|
||||
|
||||
LockEnforcedType.PATTERN_ENFORCED -> {
|
||||
if (!patternConfigured) {
|
||||
manageOptionLockSelected(LockType.PATTERN)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Activity.showSelectSecurityDialog(
|
||||
passcodeConfigured: Boolean,
|
||||
patternConfigured: Boolean,
|
||||
securityEnforced: SecurityEnforced
|
||||
) {
|
||||
if (!passcodeConfigured && !patternConfigured) {
|
||||
val options = arrayOf(getString(R.string.security_enforced_first_option), getString(R.string.security_enforced_second_option))
|
||||
var optionSelected = 0
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setCancelable(false)
|
||||
.setTitle(getString(R.string.security_enforced_title))
|
||||
.setSingleChoiceItems(options, LockType.PASSCODE.ordinal) { _, which -> optionSelected = which }
|
||||
.setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
when (LockType.parseFromInteger(optionSelected)) {
|
||||
LockType.PASSCODE -> securityEnforced.optionLockSelected(LockType.PASSCODE)
|
||||
LockType.PATTERN -> securityEnforced.optionLockSelected(LockType.PATTERN)
|
||||
}
|
||||
dialog.dismiss()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
fun Activity.sendEmailOrOpenFeedbackDialogAction(feedbackMail: String) {
|
||||
if (feedbackMail.isNotEmpty()) {
|
||||
val feedback = "Android v" + BuildConfig.VERSION_NAME + " - " + getString(R.string.prefs_feedback)
|
||||
sendEmail(email = feedbackMail, subject = feedback)
|
||||
} else {
|
||||
openFeedbackDialog()
|
||||
}
|
||||
}
|
||||
|
||||
fun Activity.openFeedbackDialog() {
|
||||
val getInContactDescription =
|
||||
getString(
|
||||
R.string.feedback_dialog_get_in_contact_description,
|
||||
DrawerActivity.GITHUB_URL
|
||||
).trimIndent()
|
||||
val spannableString = HtmlCompat.fromHtml(getInContactDescription, HtmlCompat.FROM_HTML_MODE_LEGACY)
|
||||
|
||||
val getInContactDescriptionTextView = TextView(this).apply {
|
||||
text = spannableString
|
||||
setTextColor(getColor(android.R.color.black))
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
|
||||
movementMethod = LinkMovementMethod.getInstance()
|
||||
}
|
||||
|
||||
val layout = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(64, 16, 64, 16)
|
||||
addView(getInContactDescriptionTextView)
|
||||
}
|
||||
val builder = AlertDialog.Builder(this)
|
||||
builder.apply {
|
||||
setTitle(getString(R.string.drawer_feedback))
|
||||
setView(layout)
|
||||
setNegativeButton(R.string.drawer_close) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
setCancelable(false)
|
||||
}
|
||||
val alertDialog = builder.create()
|
||||
alertDialog.show()
|
||||
}
|
||||
|
||||
fun Activity.manageOptionLockSelected(type: LockType) {
|
||||
|
||||
OCSharedPreferencesProvider(this).let {
|
||||
// Remove passcode
|
||||
it.removePreference(PassCodeActivity.PREFERENCE_PASSCODE)
|
||||
it.putBoolean(PassCodeActivity.PREFERENCE_SET_PASSCODE, false)
|
||||
|
||||
// Remove pattern
|
||||
it.removePreference(PatternActivity.PREFERENCE_PATTERN)
|
||||
it.putBoolean(PatternActivity.PREFERENCE_SET_PATTERN, false)
|
||||
|
||||
// Remove biometric
|
||||
it.putBoolean(BiometricActivity.PREFERENCE_SET_BIOMETRIC, false)
|
||||
}
|
||||
|
||||
when (type) {
|
||||
LockType.PASSCODE -> startActivity(Intent(this, PassCodeActivity::class.java).apply {
|
||||
action = PassCodeActivity.ACTION_CREATE
|
||||
flags = FLAG_ACTIVITY_NO_HISTORY
|
||||
putExtra(EXTRAS_LOCK_ENFORCED, true)
|
||||
})
|
||||
|
||||
LockType.PATTERN -> startActivity(Intent(this, PatternActivity::class.java).apply {
|
||||
action = PatternActivity.ACTION_REQUEST_WITH_RESULT
|
||||
flags = FLAG_ACTIVITY_NO_HISTORY
|
||||
putExtra(EXTRAS_LOCK_ENFORCED, true)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun Activity.showBiometricDialog(iEnableBiometrics: EnableBiometrics) {
|
||||
AlertDialog.Builder(this)
|
||||
.setCancelable(false)
|
||||
.setTitle(getString(R.string.biometric_dialog_title))
|
||||
.setPositiveButton(R.string.common_yes) { dialog, _ ->
|
||||
iEnableBiometrics.onOptionSelected(BiometricStatus.ENABLED_BY_USER)
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNegativeButton(R.string.common_no) { dialog, _ ->
|
||||
iEnableBiometrics.onOptionSelected(BiometricStatus.DISABLED_BY_USER)
|
||||
dialog.dismiss()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
fun FragmentActivity.sendDownloadedFilesByShareSheet(ocFiles: List<OCFile>) {
|
||||
if (ocFiles.isEmpty()) throw IllegalArgumentException("Can't share anything")
|
||||
|
||||
val sendIntent = if (ocFiles.size == 1) {
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = ocFiles.first().mimeType
|
||||
putExtra(Intent.EXTRA_STREAM, getExposedFileUriForOCFile(this@sendDownloadedFilesByShareSheet, ocFiles.first()))
|
||||
}
|
||||
} else {
|
||||
val fileUris = ocFiles.map { getExposedFileUriForOCFile(this@sendDownloadedFilesByShareSheet, it) }
|
||||
Intent(Intent.ACTION_SEND_MULTIPLE).apply {
|
||||
type = ALL_FILES_SAF_REGEX
|
||||
putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(fileUris))
|
||||
}
|
||||
}
|
||||
|
||||
val packagesToExclude = arrayOf<String>(this@sendDownloadedFilesByShareSheet.packageName)
|
||||
val shareSheetIntent = ShareSheetHelper().getShareSheetIntent(
|
||||
sendIntent,
|
||||
this@sendDownloadedFilesByShareSheet,
|
||||
R.string.activity_chooser_send_file_title,
|
||||
packagesToExclude
|
||||
)
|
||||
startActivity(shareSheetIntent)
|
||||
}
|
||||
|
||||
fun Activity.openOCFile(ocFile: OCFile) {
|
||||
val intentForSavedMimeType = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(getExposedFileUriForOCFile(this@openOCFile, ocFile), ocFile.mimeType)
|
||||
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
if (ocFile.hasWritePermission) {
|
||||
flags = flags or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
startActivity(Intent.createChooser(intentForSavedMimeType, getString(R.string.actionbar_open_with)))
|
||||
} catch (anfe: ActivityNotFoundException) {
|
||||
showErrorInSnackbar(genericErrorMessageId = R.string.file_list_no_app_for_file_type, anfe)
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> FragmentActivity.collectLatestLifecycleFlow(
|
||||
flow: Flow<T>,
|
||||
lifecycleState: Lifecycle.State = Lifecycle.State.STARTED,
|
||||
collect: suspend (T) -> Unit
|
||||
) {
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(lifecycleState) {
|
||||
flow.collectLatest(collect)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Abel García de Prada
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.extensions
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
fun Context.createNotificationChannel(
|
||||
id: String,
|
||||
name: String,
|
||||
description: String,
|
||||
importance: Int
|
||||
) {
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
val notificationChannel = NotificationChannel(id, name, importance).apply {
|
||||
setDescription(description)
|
||||
}
|
||||
|
||||
notificationManager.createNotificationChannel(notificationChannel)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Abel García de Prada
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.extensions
|
||||
|
||||
import android.database.Cursor
|
||||
|
||||
fun Cursor.getStringFromColumnOrThrow(
|
||||
columnName: String
|
||||
): String? = getString(getColumnIndexOrThrow(columnName))
|
||||
|
||||
fun Cursor.getStringFromColumnOrEmpty(
|
||||
columnName: String
|
||||
): String = getColumnIndex(columnName).takeUnless { it < 0 }?.let { getString(it) }.orEmpty()
|
||||
|
||||
fun Cursor.getIntFromColumnOrThrow(
|
||||
columnName: String
|
||||
): Int = getInt(getColumnIndexOrThrow(columnName))
|
||||
|
||||
fun Cursor.getLongFromColumnOrThrow(
|
||||
columnName: String
|
||||
): Long = getLong(getColumnIndexOrThrow(columnName))
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David Crespo Ríos
|
||||
* Copyright (C) 2022 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.extensions
|
||||
|
||||
import android.app.Dialog
|
||||
import android.view.WindowManager
|
||||
import eu.qsfera.android.BuildConfig
|
||||
import eu.qsfera.android.R
|
||||
|
||||
fun Dialog.avoidScreenshotsIfNeeded() {
|
||||
if (!BuildConfig.DEBUG && context.resources?.getBoolean(R.bool.allow_screenshots) == false) {
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Fernando Sanz Velasco
|
||||
* Copyright (C) 2021 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.extensions
|
||||
|
||||
import android.content.Context
|
||||
import eu.qsfera.android.utils.DisplayUtils
|
||||
import java.io.File
|
||||
|
||||
fun File.toLegibleStringSize(context: Context): String {
|
||||
val bytes = if (!exists()) 0L else length()
|
||||
return DisplayUtils.bytesToHumanReadable(bytes, context, true)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* Copyright (C) 2022 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.extensions
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.domain.files.model.FileListOption
|
||||
|
||||
@StringRes
|
||||
fun FileListOption.toTitleStringRes(): Int = when (this) {
|
||||
FileListOption.ALL_FILES -> R.string.file_list_empty_title_all_files
|
||||
FileListOption.SPACES_LIST -> R.string.spaces_list_empty_title
|
||||
FileListOption.SHARED_BY_LINK -> R.string.file_list_empty_title_shared_by_links
|
||||
FileListOption.AV_OFFLINE -> R.string.file_list_empty_title_available_offline
|
||||
}
|
||||
|
||||
@StringRes
|
||||
fun FileListOption.toSubtitleStringRes(): Int = when (this) {
|
||||
FileListOption.ALL_FILES -> R.string.file_list_empty_subtitle_all_files
|
||||
FileListOption.SPACES_LIST -> R.string.spaces_list_empty_subtitle
|
||||
FileListOption.SHARED_BY_LINK -> R.string.file_list_empty_subtitle_shared_by_links
|
||||
FileListOption.AV_OFFLINE -> R.string.file_list_empty_subtitle_available_offline
|
||||
}
|
||||
|
||||
@DrawableRes
|
||||
fun FileListOption.toDrawableRes(): Int = when (this) {
|
||||
FileListOption.ALL_FILES -> R.drawable.ic_folder
|
||||
FileListOption.SPACES_LIST -> R.drawable.ic_spaces
|
||||
FileListOption.SHARED_BY_LINK -> R.drawable.ic_shared_by_link
|
||||
FileListOption.AV_OFFLINE -> R.drawable.ic_available_offline
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
*
|
||||
* Copyright (C) 2023 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.extensions
|
||||
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.domain.files.model.FileMenuOption
|
||||
|
||||
fun FileMenuOption.toResId() =
|
||||
when (this) {
|
||||
FileMenuOption.SELECT_ALL -> R.id.file_action_select_all
|
||||
FileMenuOption.SELECT_INVERSE -> R.id.action_select_inverse
|
||||
FileMenuOption.DOWNLOAD -> R.id.action_download_file
|
||||
FileMenuOption.RENAME -> R.id.action_rename_file
|
||||
FileMenuOption.MOVE -> R.id.action_move
|
||||
FileMenuOption.COPY -> R.id.action_copy
|
||||
FileMenuOption.REMOVE -> R.id.action_remove_file
|
||||
FileMenuOption.OPEN_WITH -> R.id.action_open_file_with
|
||||
FileMenuOption.SYNC -> R.id.action_sync_file
|
||||
FileMenuOption.CANCEL_SYNC -> R.id.action_cancel_sync
|
||||
FileMenuOption.SHARE -> R.id.action_share_file
|
||||
FileMenuOption.DETAILS -> R.id.action_see_details
|
||||
FileMenuOption.SEND -> R.id.action_send_file
|
||||
FileMenuOption.SET_AV_OFFLINE -> R.id.action_set_available_offline
|
||||
FileMenuOption.UNSET_AV_OFFLINE -> R.id.action_unset_available_offline
|
||||
}
|
||||
|
||||
fun FileMenuOption.toStringResId() =
|
||||
when (this) {
|
||||
FileMenuOption.SELECT_ALL -> R.string.actionbar_select_all
|
||||
FileMenuOption.SELECT_INVERSE -> R.string.actionbar_select_inverse
|
||||
FileMenuOption.DOWNLOAD -> R.string.filedetails_download
|
||||
FileMenuOption.RENAME -> R.string.common_rename
|
||||
FileMenuOption.MOVE -> R.string.actionbar_move
|
||||
FileMenuOption.COPY -> android.R.string.copy
|
||||
FileMenuOption.REMOVE -> R.string.common_remove
|
||||
FileMenuOption.OPEN_WITH -> R.string.actionbar_open_with
|
||||
FileMenuOption.SYNC -> R.string.filedetails_sync_file
|
||||
FileMenuOption.CANCEL_SYNC -> R.string.common_cancel_sync
|
||||
FileMenuOption.SHARE -> R.string.action_share
|
||||
FileMenuOption.DETAILS -> R.string.actionbar_see_details
|
||||
FileMenuOption.SEND -> R.string.actionbar_send_file
|
||||
FileMenuOption.SET_AV_OFFLINE -> R.string.set_available_offline
|
||||
FileMenuOption.UNSET_AV_OFFLINE -> R.string.unset_available_offline
|
||||
}
|
||||
|
||||
fun FileMenuOption.toDrawableResId() =
|
||||
when (this) {
|
||||
FileMenuOption.SELECT_ALL -> R.drawable.ic_select_all
|
||||
FileMenuOption.SELECT_INVERSE -> R.drawable.ic_select_inverse
|
||||
FileMenuOption.DOWNLOAD -> R.drawable.ic_action_download
|
||||
FileMenuOption.RENAME -> R.drawable.ic_pencil
|
||||
FileMenuOption.MOVE -> R.drawable.ic_action_move
|
||||
FileMenuOption.COPY -> R.drawable.ic_action_copy
|
||||
FileMenuOption.REMOVE -> R.drawable.ic_action_delete_white
|
||||
FileMenuOption.OPEN_WITH -> R.drawable.ic_open_in_app
|
||||
FileMenuOption.SYNC -> R.drawable.ic_action_refresh
|
||||
FileMenuOption.CANCEL_SYNC -> R.drawable.ic_action_cancel_white
|
||||
FileMenuOption.SHARE -> R.drawable.ic_share_generic_white
|
||||
FileMenuOption.DETAILS -> R.drawable.ic_info_white
|
||||
FileMenuOption.SEND -> R.drawable.ic_send_white
|
||||
FileMenuOption.SET_AV_OFFLINE -> R.drawable.ic_action_set_available_offline
|
||||
FileMenuOption.UNSET_AV_OFFLINE -> R.drawable.ic_action_unset_available_offline
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David González Verdugo
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
*
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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:></http:>//www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package eu.qsfera.android.extensions
|
||||
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
|
||||
fun FragmentActivity.showDialogFragment(
|
||||
newFragment: DialogFragment, fragmentTag: String
|
||||
) {
|
||||
val ft = supportFragmentManager.beginTransaction()
|
||||
val prev = supportFragmentManager.findFragmentByTag(fragmentTag)
|
||||
|
||||
if (prev != null) {
|
||||
ft.remove(prev)
|
||||
}
|
||||
ft.addToBackStack(null)
|
||||
|
||||
newFragment.show(ft, fragmentTag)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David González Verdugo
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
*
|
||||
* Copyright (C) 2023 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.extensions
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem.SHOW_AS_ACTION_NEVER
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.domain.appregistry.model.AppRegistryProvider
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
fun Fragment.showErrorInSnackbar(genericErrorMessageId: Int, throwable: Throwable?) =
|
||||
throwable?.let {
|
||||
showMessageInSnackbar(it.parseError(getString(genericErrorMessageId), resources))
|
||||
}
|
||||
|
||||
fun Fragment.showMessageInSnackbar(
|
||||
message: CharSequence,
|
||||
duration: Int = Snackbar.LENGTH_LONG
|
||||
) {
|
||||
val requiredView = view ?: return
|
||||
Snackbar.make(requiredView, message, duration).show()
|
||||
}
|
||||
|
||||
fun Fragment.showAlertDialog(
|
||||
title: String,
|
||||
message: String,
|
||||
positiveButtonText: String = getString(android.R.string.ok),
|
||||
positiveButtonListener: ((DialogInterface, Int) -> Unit)? = null,
|
||||
negativeButtonText: String = "",
|
||||
negativeButtonListener: ((DialogInterface, Int) -> Unit)? = null
|
||||
) {
|
||||
val requiredActivity = activity ?: return
|
||||
AlertDialog.Builder(requiredActivity)
|
||||
.setTitle(title)
|
||||
.setMessage(message)
|
||||
.setPositiveButton(positiveButtonText, positiveButtonListener)
|
||||
.setNegativeButton(negativeButtonText, negativeButtonListener)
|
||||
.show()
|
||||
.avoidScreenshotsIfNeeded()
|
||||
}
|
||||
|
||||
fun Fragment.hideSoftKeyboard() {
|
||||
val focusedView = requireActivity().currentFocus
|
||||
focusedView?.let {
|
||||
val inputMethodManager = requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
inputMethodManager.hideSoftInputFromWindow(
|
||||
focusedView.windowToken,
|
||||
0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> Fragment.collectLatestLifecycleFlow(
|
||||
flow: Flow<T>,
|
||||
lifecycleState: Lifecycle.State = Lifecycle.State.STARTED,
|
||||
collect: suspend (T) -> Unit
|
||||
) {
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(lifecycleState) {
|
||||
flow.collectLatest(collect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Fragment.addOpenInWebMenuOptions(
|
||||
menu: Menu,
|
||||
openInWebProviders: Map<String, Int> = emptyMap(),
|
||||
appRegistryProviders: List<AppRegistryProvider>? = emptyList(),
|
||||
): Map<String, Int> {
|
||||
val newOpenInWebProviders = emptyMap<String, Int>().toMutableMap()
|
||||
// Remove "open in web" dynamic menu items and add them again to avoid duplications
|
||||
openInWebProviders.forEach { (_, menuItemId) ->
|
||||
menu.removeItem(menuItemId)
|
||||
}
|
||||
appRegistryProviders?.forEachIndexed { index, appRegistryProvider ->
|
||||
menu.add(Menu.NONE, index, 0, getString(R.string.ic_action_open_with_web, appRegistryProvider.name)).also {
|
||||
it.setShowAsAction(SHOW_AS_ACTION_NEVER)
|
||||
newOpenInWebProviders[appRegistryProvider.name] = it.itemId
|
||||
}
|
||||
}
|
||||
return newOpenInWebProviders
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Fernando Sanz Velasco
|
||||
* Copyright (C) 2021 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.extensions
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.widget.ImageView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
|
||||
@SuppressLint("CheckResult")
|
||||
fun ImageView.setPicture(imageToLoad: Int) {
|
||||
Glide.with(this)
|
||||
.load(imageToLoad)
|
||||
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
|
||||
.into(this)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Abel García de Prada
|
||||
* Copyright (C) 2021 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.extensions
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.work.WorkInfo
|
||||
import eu.qsfera.android.workers.DownloadFileWorker.Companion.WORKER_KEY_PROGRESS
|
||||
|
||||
fun LiveData<WorkInfo?>.observeWorkerTillItFinishes(
|
||||
owner: LifecycleOwner,
|
||||
onWorkEnqueued: () -> Unit = {},
|
||||
onWorkRunning: (progress: Int) -> Unit,
|
||||
onWorkSucceeded: () -> Unit,
|
||||
onWorkFailed: () -> Unit,
|
||||
onWorkBlocked: () -> Unit = {},
|
||||
onWorkCancelled: () -> Unit = {},
|
||||
removeObserverAfterNull: Boolean = true,
|
||||
) {
|
||||
observe(owner, object : Observer<WorkInfo?> {
|
||||
override fun onChanged(value: WorkInfo?) {
|
||||
if (value == null) {
|
||||
if (removeObserverAfterNull) {
|
||||
removeObserver(this)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (value.state.isFinished) {
|
||||
removeObserver(this)
|
||||
}
|
||||
when (value.state) {
|
||||
WorkInfo.State.ENQUEUED -> onWorkEnqueued()
|
||||
WorkInfo.State.RUNNING -> onWorkRunning(value.progress.getInt(WORKER_KEY_PROGRESS, -1))
|
||||
WorkInfo.State.SUCCEEDED -> onWorkSucceeded()
|
||||
WorkInfo.State.FAILED -> onWorkFailed()
|
||||
WorkInfo.State.BLOCKED -> onWorkBlocked()
|
||||
WorkInfo.State.CANCELLED -> onWorkCancelled()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
*
|
||||
* Copyright (C) 2023 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.extensions
|
||||
|
||||
import android.view.Menu
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.domain.files.model.FileMenuOption
|
||||
|
||||
fun Menu.filterMenuOptions(
|
||||
optionsToShow: List<FileMenuOption>,
|
||||
hasWritePermission: Boolean,
|
||||
) {
|
||||
FileMenuOption.values().forEach { fileMenuOption ->
|
||||
val item = this.findItem(fileMenuOption.toResId())
|
||||
item?.let {
|
||||
if (optionsToShow.contains(fileMenuOption)) {
|
||||
it.isVisible = true
|
||||
it.isEnabled = true
|
||||
if (fileMenuOption.toResId() == R.id.action_open_file_with) {
|
||||
if (!hasWritePermission) {
|
||||
item.setTitle(R.string.actionbar_open_with_read_only)
|
||||
} else {
|
||||
item.setTitle(R.string.actionbar_open_with)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
it.isVisible = false
|
||||
it.isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
*
|
||||
* Copyright (C) 2022 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.extensions
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.domain.transfers.model.OCTransfer
|
||||
import eu.qsfera.android.domain.transfers.model.TransferResult
|
||||
import eu.qsfera.android.domain.transfers.model.TransferStatus
|
||||
|
||||
@StringRes
|
||||
fun OCTransfer.statusToStringRes(): Int =
|
||||
when (status) {
|
||||
TransferStatus.TRANSFER_IN_PROGRESS -> R.string.uploader_upload_in_progress_ticker
|
||||
TransferStatus.TRANSFER_SUCCEEDED -> R.string.uploads_view_upload_status_succeeded
|
||||
TransferStatus.TRANSFER_QUEUED -> R.string.uploads_view_upload_status_queued
|
||||
TransferStatus.TRANSFER_FAILED -> when (lastResult) {
|
||||
TransferResult.CREDENTIAL_ERROR -> R.string.uploads_view_upload_status_failed_credentials_error
|
||||
TransferResult.FOLDER_ERROR -> R.string.uploads_view_upload_status_failed_folder_error
|
||||
TransferResult.FILE_NOT_FOUND -> R.string.uploads_view_upload_status_failed_localfile_error
|
||||
TransferResult.FILE_ERROR -> R.string.uploads_view_upload_status_failed_file_error
|
||||
TransferResult.PRIVILEGES_ERROR -> R.string.uploads_view_upload_status_failed_permission_error
|
||||
TransferResult.NETWORK_CONNECTION -> R.string.uploads_view_upload_status_failed_connection_error
|
||||
TransferResult.DELAYED_FOR_WIFI -> R.string.uploads_view_upload_status_waiting_for_wifi
|
||||
TransferResult.CONFLICT_ERROR -> R.string.uploads_view_upload_status_conflict
|
||||
TransferResult.SERVICE_INTERRUPTED -> R.string.uploads_view_upload_status_service_interrupted
|
||||
TransferResult.SERVICE_UNAVAILABLE -> R.string.service_unavailable
|
||||
TransferResult.QUOTA_EXCEEDED -> R.string.failed_upload_quota_exceeded_text
|
||||
TransferResult.SSL_RECOVERABLE_PEER_UNVERIFIED -> R.string.ssl_certificate_not_trusted
|
||||
TransferResult.UNKNOWN -> R.string.uploads_view_upload_status_unknown_fail
|
||||
// Should not get here; cancelled uploads should be wiped out
|
||||
TransferResult.CANCELLED -> R.string.uploads_view_upload_status_cancelled
|
||||
// Should not get here; status should be UPLOAD_SUCCESS
|
||||
TransferResult.UPLOADED -> R.string.uploads_view_upload_status_succeeded
|
||||
// We don't know the specific forbidden error message because it is not being saved in transfers storage
|
||||
TransferResult.SPECIFIC_FORBIDDEN -> R.string.uploads_view_upload_status_failed_permission_error
|
||||
// We don't know the specific unavailable service error message because it is not being saved in transfers storage
|
||||
TransferResult.SPECIFIC_SERVICE_UNAVAILABLE -> R.string.service_unavailable
|
||||
// We don't know the specific unsupported media type error message because it is not being saved in transfers storage
|
||||
TransferResult.SPECIFIC_UNSUPPORTED_MEDIA_TYPE -> R.string.uploads_view_unsupported_media_type
|
||||
// Should not get here; status should be not null
|
||||
null -> R.string.uploads_view_upload_status_unknown_fail
|
||||
}
|
||||
}
|
||||
|
||||
fun OCTransfer.isContentUri(context: Context): Boolean =
|
||||
DocumentFile.isDocumentUri(context, Uri.parse(localPath))
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David González Verdugo
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.extensions
|
||||
|
||||
import android.content.res.Resources
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.domain.exceptions.AccountNotNewException
|
||||
import eu.qsfera.android.domain.exceptions.AccountNotTheSameException
|
||||
import eu.qsfera.android.domain.exceptions.BadOcVersionException
|
||||
import eu.qsfera.android.domain.exceptions.ConflictException
|
||||
import eu.qsfera.android.domain.exceptions.CopyIntoDescendantException
|
||||
import eu.qsfera.android.domain.exceptions.CopyIntoSameFolderException
|
||||
import eu.qsfera.android.domain.exceptions.FileAlreadyExistsException
|
||||
import eu.qsfera.android.domain.exceptions.FileNotFoundException
|
||||
import eu.qsfera.android.domain.exceptions.ForbiddenException
|
||||
import eu.qsfera.android.domain.exceptions.IncorrectAddressException
|
||||
import eu.qsfera.android.domain.exceptions.InstanceNotConfiguredException
|
||||
import eu.qsfera.android.domain.exceptions.InvalidOverwriteException
|
||||
import eu.qsfera.android.domain.exceptions.LocalFileNotFoundException
|
||||
import eu.qsfera.android.domain.exceptions.MoveIntoDescendantException
|
||||
import eu.qsfera.android.domain.exceptions.MoveIntoSameFolderException
|
||||
import eu.qsfera.android.domain.exceptions.NetworkErrorException
|
||||
import eu.qsfera.android.domain.exceptions.NoConnectionWithServerException
|
||||
import eu.qsfera.android.domain.exceptions.NoNetworkConnectionException
|
||||
import eu.qsfera.android.domain.exceptions.OAuth2ErrorAccessDeniedException
|
||||
import eu.qsfera.android.domain.exceptions.OAuth2ErrorException
|
||||
import eu.qsfera.android.domain.exceptions.QuotaExceededException
|
||||
import eu.qsfera.android.domain.exceptions.RedirectToNonSecureException
|
||||
import eu.qsfera.android.domain.exceptions.ResourceLockedException
|
||||
import eu.qsfera.android.domain.exceptions.SSLErrorException
|
||||
import eu.qsfera.android.domain.exceptions.SSLRecoverablePeerUnverifiedException
|
||||
import eu.qsfera.android.domain.exceptions.ServerConnectionTimeoutException
|
||||
import eu.qsfera.android.domain.exceptions.ServerNotReachableException
|
||||
import eu.qsfera.android.domain.exceptions.ServerResponseTimeoutException
|
||||
import eu.qsfera.android.domain.exceptions.ServiceUnavailableException
|
||||
import eu.qsfera.android.domain.exceptions.SpecificForbiddenException
|
||||
import eu.qsfera.android.domain.exceptions.UnauthorizedException
|
||||
import eu.qsfera.android.domain.exceptions.validation.FileNameException
|
||||
import java.util.Locale
|
||||
|
||||
fun Throwable.parseError(
|
||||
genericErrorMessage: String,
|
||||
resources: Resources,
|
||||
showJustReason: Boolean = false
|
||||
): CharSequence {
|
||||
if (!this.message.isNullOrEmpty()) { // If there's an specific error message from layers below use it
|
||||
return this.message as String
|
||||
} else { // Build the error message otherwise
|
||||
val reason = when (this) {
|
||||
is AccountNotNewException -> resources.getString(R.string.auth_account_not_new)
|
||||
is AccountNotTheSameException -> resources.getString(R.string.auth_account_not_the_same)
|
||||
is BadOcVersionException -> resources.getString(R.string.auth_bad_oc_version_title)
|
||||
is ConflictException -> resources.getString(R.string.error_conflict)
|
||||
is CopyIntoDescendantException -> resources.getString(R.string.copy_file_invalid_into_descendent)
|
||||
is CopyIntoSameFolderException -> resources.getString(R.string.copy_file_invalid_overwrite)
|
||||
is FileAlreadyExistsException -> resources.getString(R.string.file_already_exists)
|
||||
is FileNameException -> resources.getString(when (this.type) {
|
||||
FileNameException.FileNameExceptionType.FILE_NAME_EMPTY -> R.string.filename_empty
|
||||
FileNameException.FileNameExceptionType.FILE_NAME_FORBIDDEN_CHARACTERS -> R.string.filename_forbidden_characters_from_server
|
||||
FileNameException.FileNameExceptionType.FILE_NAME_TOO_LONG -> R.string.filename_too_long
|
||||
})
|
||||
is FileNotFoundException -> resources.getString(R.string.common_not_found)
|
||||
is ForbiddenException -> resources.getString(R.string.uploads_view_upload_status_failed_permission_error)
|
||||
is IncorrectAddressException -> resources.getString(R.string.auth_incorrect_address_title)
|
||||
is InstanceNotConfiguredException -> resources.getString(R.string.auth_not_configured_title)
|
||||
is InvalidOverwriteException -> resources.getString(R.string.file_already_exists)
|
||||
is LocalFileNotFoundException -> resources.getString(R.string.local_file_not_found_toast)
|
||||
is MoveIntoDescendantException -> resources.getString(R.string.move_file_invalid_into_descendent)
|
||||
is MoveIntoSameFolderException -> resources.getString(R.string.move_file_invalid_overwrite)
|
||||
is NoConnectionWithServerException -> resources.getString(R.string.network_error_socket_exception)
|
||||
is NoNetworkConnectionException -> resources.getString(R.string.error_no_network_connection)
|
||||
is OAuth2ErrorAccessDeniedException -> resources.getString(R.string.auth_oauth_error_access_denied)
|
||||
is OAuth2ErrorException -> resources.getString(R.string.auth_oauth_error)
|
||||
is QuotaExceededException -> resources.getString(R.string.failed_upload_quota_exceeded_text)
|
||||
is RedirectToNonSecureException -> resources.getString(R.string.auth_redirect_non_secure_connection_title)
|
||||
is SSLErrorException -> resources.getString(R.string.auth_ssl_general_error_title)
|
||||
is SSLRecoverablePeerUnverifiedException -> resources.getString(R.string.ssl_certificate_not_trusted)
|
||||
is ServerConnectionTimeoutException -> resources.getString(R.string.network_error_connect_timeout_exception)
|
||||
is ServerNotReachableException -> resources.getString(R.string.network_host_not_available)
|
||||
is ServerResponseTimeoutException -> resources.getString(R.string.network_error_socket_timeout_exception)
|
||||
is ServiceUnavailableException -> resources.getString(R.string.service_unavailable)
|
||||
is SpecificForbiddenException -> resources.getString(R.string.uploads_view_upload_status_failed_permission_error)
|
||||
is UnauthorizedException -> resources.getString(R.string.auth_unauthorized)
|
||||
is NetworkErrorException -> resources.getString(R.string.network_error_message)
|
||||
is ResourceLockedException -> resources.getString(R.string.resource_locked_error_message)
|
||||
else -> resources.getString(R.string.common_error_unknown)
|
||||
}
|
||||
|
||||
return if (showJustReason) {
|
||||
reason
|
||||
} else {
|
||||
"$genericErrorMessage ${resources.getString(R.string.error_reason)} ${reason.lowercase(Locale.getDefault())}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author John Kalimeris
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.extensions
|
||||
|
||||
import eu.qsfera.android.domain.files.model.OCFile
|
||||
import java.util.ArrayList
|
||||
import java.util.Locale
|
||||
import java.util.Vector
|
||||
|
||||
fun Vector<OCFile>.filterByQuery(query: String): List<OCFile> {
|
||||
val lowerCaseQuery = query.lowercase(Locale.ROOT)
|
||||
|
||||
val filteredList: MutableList<OCFile> = ArrayList()
|
||||
|
||||
for (fileToAdd in this) {
|
||||
val nameOfTheFileToAdd: String = fileToAdd.fileName.lowercase(Locale.ROOT)
|
||||
if (nameOfTheFileToAdd.contains(lowerCaseQuery)) {
|
||||
filteredList.add(fileToAdd)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove not matching files from this filelist
|
||||
for (i in this.indices.reversed()) {
|
||||
if (!filteredList.contains(this[i])) {
|
||||
removeAt(i)
|
||||
}
|
||||
}
|
||||
|
||||
// Add matching files to this filelist
|
||||
for (i in filteredList.indices) {
|
||||
if (!contains(filteredList[i])) {
|
||||
add(i, filteredList[i])
|
||||
}
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Aitor Ballesteros Pavón
|
||||
*
|
||||
* Copyright (C) 2024 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.extensions
|
||||
|
||||
import android.view.View
|
||||
import androidx.core.view.AccessibilityDelegateCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
|
||||
|
||||
fun View.setAccessibilityRole(className: Class<*>? = null, roleDescription: String? = null) {
|
||||
ViewCompat.setAccessibilityDelegate(this, object : AccessibilityDelegateCompat() {
|
||||
override fun onInitializeAccessibilityNodeInfo(v: View, info: AccessibilityNodeInfoCompat) {
|
||||
super.onInitializeAccessibilityNodeInfo(v, info)
|
||||
className?.let { info.className = it.name }
|
||||
roleDescription?.let { info.roleDescription = it }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David González Verdugo
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
*
|
||||
* Copyright (C) 2023 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.extensions
|
||||
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import eu.qsfera.android.domain.BaseUseCaseWithResult
|
||||
import eu.qsfera.android.domain.exceptions.NoNetworkConnectionException
|
||||
import eu.qsfera.android.domain.utils.Event
|
||||
import eu.qsfera.android.presentation.common.UIResult
|
||||
import eu.qsfera.android.providers.ContextProvider
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import timber.log.Timber
|
||||
|
||||
object ViewModelExt : KoinComponent {
|
||||
|
||||
private val contextProvider: ContextProvider by inject()
|
||||
|
||||
fun <T, Params> ViewModel.runUseCaseWithResult(
|
||||
coroutineDispatcher: CoroutineDispatcher,
|
||||
requiresConnection: Boolean = true,
|
||||
showLoading: Boolean = false,
|
||||
liveData: MediatorLiveData<Event<UIResult<T>>>,
|
||||
useCase: BaseUseCaseWithResult<T, Params>,
|
||||
useCaseParams: Params,
|
||||
postSuccess: Boolean = true,
|
||||
postSuccessWithData: Boolean = true
|
||||
) {
|
||||
viewModelScope.launch(coroutineDispatcher) {
|
||||
if (showLoading) {
|
||||
liveData.postValue(Event(UIResult.Loading()))
|
||||
}
|
||||
|
||||
// If use case requires connection and is not connected, it is not needed to execute use case.
|
||||
if (requiresConnection and !contextProvider.isConnected()) {
|
||||
liveData.postValue(Event(UIResult.Error(error = NoNetworkConnectionException())))
|
||||
Timber.w("${useCase.javaClass.simpleName} will not be executed due to lack of network connection")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val useCaseResult = useCase(useCaseParams)
|
||||
|
||||
Timber.d("Use case executed: ${useCase.javaClass.simpleName} with result: $useCaseResult")
|
||||
|
||||
if (useCaseResult.isSuccess && postSuccess) {
|
||||
if (postSuccessWithData) {
|
||||
liveData.postValue(Event(UIResult.Success(useCaseResult.getDataOrNull())))
|
||||
} else {
|
||||
liveData.postValue(Event(UIResult.Success()))
|
||||
}
|
||||
} else if (useCaseResult.isError) {
|
||||
liveData.postValue(Event(UIResult.Error(error = useCaseResult.getThrowableOrNull())))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T, Params> ViewModel.runUseCaseWithResult(
|
||||
coroutineDispatcher: CoroutineDispatcher,
|
||||
requiresConnection: Boolean = true,
|
||||
showLoading: Boolean = false,
|
||||
flow: MutableStateFlow<Event<UIResult<T>>?>,
|
||||
useCase: BaseUseCaseWithResult<T, Params>,
|
||||
useCaseParams: Params,
|
||||
postSuccess: Boolean = true,
|
||||
postSuccessWithData: Boolean = true
|
||||
) {
|
||||
viewModelScope.launch(coroutineDispatcher) {
|
||||
if (showLoading) {
|
||||
flow.update { Event(UIResult.Loading()) }
|
||||
}
|
||||
|
||||
// If use case requires connection and is not connected, it is not needed to execute use case
|
||||
if (requiresConnection and !contextProvider.isConnected()) {
|
||||
flow.update { Event(UIResult.Error(error = NoNetworkConnectionException())) }
|
||||
Timber.w("${useCase.javaClass.simpleName} will not be executed due to lack of network connection")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val useCaseResult = useCase(useCaseParams)
|
||||
|
||||
Timber.d("Use case executed: ${useCase.javaClass.simpleName} with result: $useCaseResult")
|
||||
|
||||
if (useCaseResult.isSuccess && postSuccess) {
|
||||
if (postSuccessWithData) {
|
||||
flow.update { Event(UIResult.Success(useCaseResult.getDataOrNull())) }
|
||||
} else {
|
||||
flow.update { Event(UIResult.Success()) }
|
||||
}
|
||||
} else if (useCaseResult.isError) {
|
||||
flow.update { Event(UIResult.Error(error = useCaseResult.getThrowableOrNull())) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T, Params> ViewModel.runUseCaseWithResult(
|
||||
coroutineDispatcher: CoroutineDispatcher,
|
||||
requiresConnection: Boolean = true,
|
||||
showLoading: Boolean = false,
|
||||
sharedFlow: MutableSharedFlow<UIResult<T>>,
|
||||
useCase: BaseUseCaseWithResult<T, Params>,
|
||||
useCaseParams: Params,
|
||||
postSuccess: Boolean = true,
|
||||
postSuccessWithData: Boolean = true
|
||||
) {
|
||||
viewModelScope.launch(coroutineDispatcher) {
|
||||
if (showLoading) {
|
||||
sharedFlow.emit(UIResult.Loading())
|
||||
}
|
||||
|
||||
// If use case requires connection and is not connected, it is not needed to execute use case
|
||||
if (requiresConnection and !contextProvider.isConnected()) {
|
||||
sharedFlow.emit(UIResult.Error(error = NoNetworkConnectionException()))
|
||||
Timber.w("${useCase.javaClass.simpleName} will not be executed due to lack of network connection")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val useCaseResult = useCase(useCaseParams)
|
||||
Timber.d("Use case executed: ${useCase.javaClass.simpleName} with result: $useCaseResult")
|
||||
|
||||
if (useCaseResult.isSuccess && postSuccess) {
|
||||
if (postSuccessWithData) {
|
||||
sharedFlow.emit(UIResult.Success(useCaseResult.getDataOrNull()))
|
||||
} else {
|
||||
sharedFlow.emit(UIResult.Success())
|
||||
}
|
||||
} else if (useCaseResult.isError) {
|
||||
sharedFlow.emit(UIResult.Error(error = useCaseResult.getThrowableOrNull()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T, U, Params> ViewModel.runUseCaseWithResultAndUseCachedData(
|
||||
coroutineDispatcher: CoroutineDispatcher,
|
||||
requiresConnection: Boolean = true,
|
||||
cachedData: T?,
|
||||
liveData: MediatorLiveData<Event<UIResult<T>>>,
|
||||
useCase: BaseUseCaseWithResult<U, Params>,
|
||||
useCaseParams: Params
|
||||
) {
|
||||
viewModelScope.launch(coroutineDispatcher) {
|
||||
liveData.postValue(Event(UIResult.Loading(cachedData)))
|
||||
|
||||
// If use case requires connection and is not connected, it is not needed to execute use case
|
||||
if (requiresConnection && !contextProvider.isConnected()) {
|
||||
liveData.postValue(Event(UIResult.Error(error = NoNetworkConnectionException(), data = cachedData)))
|
||||
Timber.w("${useCase.javaClass.simpleName} will not be executed due to lack of network connection")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val useCaseResult = useCase(useCaseParams)
|
||||
|
||||
Timber.d("Use case executed: ${useCase.javaClass.simpleName} with result: $useCaseResult")
|
||||
|
||||
if (useCaseResult.isError) {
|
||||
liveData.postValue(Event(UIResult.Error(error = useCaseResult.getThrowableOrNull(), data = cachedData)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Abel García de Prada
|
||||
* Copyright (C) 2022 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.extensions
|
||||
|
||||
import androidx.work.WorkInfo
|
||||
import eu.qsfera.android.domain.extensions.isOneOf
|
||||
import eu.qsfera.android.workers.DownloadFileWorker
|
||||
import eu.qsfera.android.workers.UploadFileFromContentUriWorker
|
||||
import eu.qsfera.android.workers.UploadFileFromFileSystemWorker
|
||||
|
||||
fun WorkInfo.isUpload() =
|
||||
tags.any { it.isOneOf(UploadFileFromContentUriWorker::class.java.name, UploadFileFromFileSystemWorker::class.java.name) }
|
||||
|
||||
fun WorkInfo.isDownload() =
|
||||
tags.any { it.isOneOf(DownloadFileWorker::class.java.name) }
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Abel García de Prada
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
*
|
||||
* Copyright (C) 2022 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.extensions
|
||||
|
||||
import android.accounts.Account
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkQuery
|
||||
import eu.qsfera.android.domain.files.model.OCFile
|
||||
import eu.qsfera.android.usecases.transfers.TRANSFER_TAG_DOWNLOAD
|
||||
|
||||
val PENDING_WORK_STATUS = listOf(WorkInfo.State.ENQUEUED, WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED)
|
||||
val FINISHED_WORK_STATUS = listOf(WorkInfo.State.SUCCEEDED, WorkInfo.State.FAILED, WorkInfo.State.CANCELLED)
|
||||
|
||||
/**
|
||||
* Get a list of WorkInfo that matches EVERY tag.
|
||||
*/
|
||||
fun WorkManager.getWorkInfoByTags(tags: List<String>): List<WorkInfo> =
|
||||
this.getWorkInfos(buildWorkQuery(tags = tags)).get().filter { it.tags.containsAll(tags) }
|
||||
|
||||
/**
|
||||
* Get a list of WorkInfo of running workers that matches EVERY tag.
|
||||
*/
|
||||
fun WorkManager.getRunningWorkInfosByTags(tags: List<String>): List<WorkInfo> =
|
||||
getWorkInfos(buildWorkQuery(tags = tags, states = listOf(WorkInfo.State.RUNNING))).get().filter { it.tags.containsAll(tags) }
|
||||
|
||||
/**
|
||||
* Get a list of WorkInfo of running workers as LiveData that matches at least one of the tags.
|
||||
*/
|
||||
fun WorkManager.getRunningWorkInfosLiveData(tags: List<String>): LiveData<List<WorkInfo>> =
|
||||
getWorkInfosLiveData(buildWorkQuery(tags = tags, states = listOf(WorkInfo.State.RUNNING)))
|
||||
|
||||
/**
|
||||
* Check if a download is pending. It could be enqueued, downloading or blocked.
|
||||
* @param account - Owner of the file
|
||||
* @param file - File to check whether it is pending.
|
||||
*
|
||||
* @return true if the download is pending.
|
||||
*/
|
||||
fun WorkManager.isDownloadPending(account: Account, file: OCFile): Boolean =
|
||||
this.getWorkInfoByTags(getTagsForDownload(file, account.name)).any { !it.state.isFinished }
|
||||
|
||||
fun getTagsForDownload(file: OCFile, accountName: String) =
|
||||
listOf(TRANSFER_TAG_DOWNLOAD, file.id.toString(), accountName)
|
||||
|
||||
/**
|
||||
* Take care with WorkQueries. It will return workers that match at least ONE of the tags.
|
||||
* If we perform a query with tags {"account@server", "2"}, [WorkManager.getWorkInfos] will return workers that
|
||||
* contains at least ONE of the tags, but not both of them. If we want workers that match every tag,
|
||||
* @see getWorkInfoByTags
|
||||
*/
|
||||
fun buildWorkQuery(
|
||||
tags: List<String>,
|
||||
states: List<WorkInfo.State> = listOf(),
|
||||
): WorkQuery = WorkQuery.Builder
|
||||
.fromTags(tags)
|
||||
.addStates(states)
|
||||
.build()
|
||||
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David A. Velasco
|
||||
* @author Christian Schabesberger
|
||||
* @author David González Verdugo
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
* <p>
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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 eu.qsfera.android.media;
|
||||
|
||||
import android.content.Context;
|
||||
import android.media.MediaPlayer;
|
||||
import android.os.Handler;
|
||||
import android.os.Message;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.View.OnClickListener;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.accessibility.AccessibilityEvent;
|
||||
import android.view.accessibility.AccessibilityNodeInfo;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.MediaController.MediaPlayerControl;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.SeekBar;
|
||||
import android.widget.SeekBar.OnSeekBarChangeListener;
|
||||
import android.widget.TextView;
|
||||
|
||||
import eu.qsfera.android.R;
|
||||
import eu.qsfera.android.utils.PreferenceUtils;
|
||||
|
||||
import java.util.Formatter;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* View containing controls for a {@link MediaPlayer}.
|
||||
*
|
||||
* Holds buttons "play / pause", "rewind", "fast forward"
|
||||
* and a progress slider.
|
||||
*
|
||||
* It synchronizes itself with the state of the
|
||||
* {@link MediaPlayer}.
|
||||
*/
|
||||
|
||||
public class MediaControlView extends FrameLayout implements OnClickListener, OnSeekBarChangeListener {
|
||||
|
||||
private MediaPlayerControl mPlayer;
|
||||
private Context mContext;
|
||||
private View mRoot;
|
||||
private ProgressBar mProgress;
|
||||
private TextView mEndTime, mCurrentTime;
|
||||
private boolean mDragging;
|
||||
private static final int SHOW_PROGRESS = 1;
|
||||
StringBuilder mFormatBuilder;
|
||||
Formatter mFormatter;
|
||||
private ImageButton mPauseButton;
|
||||
private ImageButton mFfwdButton;
|
||||
private ImageButton mRewButton;
|
||||
|
||||
public MediaControlView(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
mContext = context;
|
||||
|
||||
FrameLayout.LayoutParams frameParams = new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
);
|
||||
LayoutInflater inflate = (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
|
||||
mRoot = inflate.inflate(R.layout.media_control, null);
|
||||
|
||||
// Allow or disallow touches with other visible windows
|
||||
mRoot.setFilterTouchesWhenObscured(
|
||||
PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context)
|
||||
);
|
||||
|
||||
initControllerView(mRoot);
|
||||
addView(mRoot, frameParams);
|
||||
|
||||
setFocusable(true);
|
||||
setFocusableInTouchMode(true);
|
||||
setDescendantFocusability(ViewGroup.FOCUS_AFTER_DESCENDANTS);
|
||||
requestFocus();
|
||||
}
|
||||
|
||||
public void setMediaPlayer(MediaPlayerControl player) {
|
||||
mPlayer = player;
|
||||
mHandler.sendEmptyMessage(SHOW_PROGRESS);
|
||||
updatePausePlay();
|
||||
}
|
||||
|
||||
private void initControllerView(View v) {
|
||||
mPauseButton = v.findViewById(R.id.playBtn);
|
||||
if (mPauseButton != null) {
|
||||
mPauseButton.requestFocus();
|
||||
mPauseButton.setOnClickListener(this);
|
||||
}
|
||||
|
||||
mFfwdButton = v.findViewById(R.id.forwardBtn);
|
||||
if (mFfwdButton != null) {
|
||||
mFfwdButton.setOnClickListener(this);
|
||||
}
|
||||
|
||||
mRewButton = v.findViewById(R.id.rewindBtn);
|
||||
if (mRewButton != null) {
|
||||
mRewButton.setOnClickListener(this);
|
||||
}
|
||||
|
||||
mProgress = v.findViewById(R.id.progressBar);
|
||||
if (mProgress != null) {
|
||||
if (mProgress instanceof SeekBar) {
|
||||
SeekBar seeker = (SeekBar) mProgress;
|
||||
seeker.setOnSeekBarChangeListener(this);
|
||||
}
|
||||
mProgress.setMax(1000);
|
||||
}
|
||||
|
||||
mEndTime = v.findViewById(R.id.totalTimeText);
|
||||
mCurrentTime = v.findViewById(R.id.currentTimeText);
|
||||
mFormatBuilder = new StringBuilder();
|
||||
mFormatter = new Formatter(mFormatBuilder, Locale.getDefault());
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable pause or seek buttons if the stream cannot be paused or seeked.
|
||||
* This requires the control interface to be a MediaPlayerControlExt
|
||||
*/
|
||||
private void disableUnsupportedButtons() {
|
||||
try {
|
||||
if (mPauseButton != null && !mPlayer.canPause()) {
|
||||
mPauseButton.setEnabled(false);
|
||||
}
|
||||
if (mRewButton != null && !mPlayer.canSeekBackward()) {
|
||||
mRewButton.setEnabled(false);
|
||||
}
|
||||
if (mFfwdButton != null && !mPlayer.canSeekForward()) {
|
||||
mFfwdButton.setEnabled(false);
|
||||
}
|
||||
} catch (IncompatibleClassChangeError ex) {
|
||||
// We were given an old version of the interface, that doesn't have
|
||||
// the canPause/canSeekXYZ methods. This is OK, it just means we
|
||||
// assume the media can be paused and seeked, and so we don't disable
|
||||
// the buttons.
|
||||
}
|
||||
}
|
||||
|
||||
private Handler mHandler = new Handler() {
|
||||
@Override
|
||||
public void handleMessage(Message msg) {
|
||||
int pos;
|
||||
switch (msg.what) {
|
||||
case SHOW_PROGRESS:
|
||||
pos = setProgress();
|
||||
if (!mDragging) {
|
||||
msg = obtainMessage(SHOW_PROGRESS);
|
||||
sendMessageDelayed(msg, 1000 - (pos % 1000));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private String stringForTime(int timeMs) {
|
||||
int totalSeconds = timeMs / 1000;
|
||||
|
||||
int seconds = totalSeconds % 60;
|
||||
int minutes = (totalSeconds / 60) % 60;
|
||||
int hours = totalSeconds / 3600;
|
||||
|
||||
mFormatBuilder.setLength(0);
|
||||
if (hours > 0) {
|
||||
return mFormatter.format("%d:%02d:%02d", hours, minutes, seconds).toString();
|
||||
} else {
|
||||
return mFormatter.format("%02d:%02d", minutes, seconds).toString();
|
||||
}
|
||||
}
|
||||
|
||||
private int setProgress() {
|
||||
if (mPlayer == null || mDragging) {
|
||||
return 0;
|
||||
}
|
||||
int position = mPlayer.getCurrentPosition();
|
||||
int duration = mPlayer.getDuration();
|
||||
if (mProgress != null) {
|
||||
if (duration > 0) {
|
||||
// use long to avoid overflow
|
||||
long pos = 1000L * position / duration;
|
||||
mProgress.setProgress((int) pos);
|
||||
}
|
||||
int percent = mPlayer.getBufferPercentage();
|
||||
mProgress.setSecondaryProgress(percent * 10);
|
||||
}
|
||||
|
||||
if (mEndTime != null) {
|
||||
mEndTime.setText(stringForTime(duration));
|
||||
}
|
||||
if (mCurrentTime != null) {
|
||||
mCurrentTime.setText(stringForTime(position));
|
||||
}
|
||||
|
||||
return position;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchKeyEvent(KeyEvent event) {
|
||||
int keyCode = event.getKeyCode();
|
||||
final boolean uniqueDown = event.getRepeatCount() == 0
|
||||
&& event.getAction() == KeyEvent.ACTION_DOWN;
|
||||
if (keyCode == KeyEvent.KEYCODE_HEADSETHOOK
|
||||
|| keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
|
||||
|| keyCode == KeyEvent.KEYCODE_SPACE) {
|
||||
if (uniqueDown) {
|
||||
doPauseResume();
|
||||
//show(sDefaultTimeout);
|
||||
if (mPauseButton != null) {
|
||||
mPauseButton.requestFocus();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} else if (keyCode == KeyEvent.KEYCODE_MEDIA_PLAY) {
|
||||
if (uniqueDown && !mPlayer.isPlaying()) {
|
||||
mPlayer.start();
|
||||
updatePausePlay();
|
||||
}
|
||||
return true;
|
||||
} else if (keyCode == KeyEvent.KEYCODE_MEDIA_STOP
|
||||
|| keyCode == KeyEvent.KEYCODE_MEDIA_PAUSE) {
|
||||
if (uniqueDown && mPlayer.isPlaying()) {
|
||||
mPlayer.pause();
|
||||
updatePausePlay();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.dispatchKeyEvent(event);
|
||||
}
|
||||
|
||||
public void updatePausePlay() {
|
||||
if (mRoot == null || mPauseButton == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mPlayer.isPlaying()) {
|
||||
mPauseButton.setImageResource(android.R.drawable.ic_media_pause);
|
||||
} else {
|
||||
mPauseButton.setImageResource(android.R.drawable.ic_media_play);
|
||||
}
|
||||
}
|
||||
|
||||
private void doPauseResume() {
|
||||
if (mPlayer.isPlaying()) {
|
||||
mPlayer.pause();
|
||||
} else {
|
||||
mPlayer.start();
|
||||
}
|
||||
updatePausePlay();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setEnabled(boolean enabled) {
|
||||
if (mPauseButton != null) {
|
||||
mPauseButton.setEnabled(enabled);
|
||||
}
|
||||
if (mFfwdButton != null) {
|
||||
mFfwdButton.setEnabled(enabled);
|
||||
}
|
||||
if (mRewButton != null) {
|
||||
mRewButton.setEnabled(enabled);
|
||||
}
|
||||
if (mProgress != null) {
|
||||
mProgress.setEnabled(enabled);
|
||||
}
|
||||
disableUnsupportedButtons();
|
||||
super.setEnabled(enabled);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
int pos;
|
||||
boolean playing = mPlayer.isPlaying();
|
||||
switch (v.getId()) {
|
||||
|
||||
case R.id.playBtn:
|
||||
doPauseResume();
|
||||
break;
|
||||
|
||||
case R.id.rewindBtn:
|
||||
pos = mPlayer.getCurrentPosition();
|
||||
pos -= 5000;
|
||||
mPlayer.seekTo(pos);
|
||||
if (!playing) {
|
||||
mPlayer.pause(); // necessary in some 2.3.x devices
|
||||
}
|
||||
setProgress();
|
||||
break;
|
||||
|
||||
case R.id.forwardBtn:
|
||||
pos = mPlayer.getCurrentPosition();
|
||||
pos += 15000;
|
||||
mPlayer.seekTo(pos);
|
||||
if (!playing) {
|
||||
mPlayer.pause(); // necessary in some 2.3.x devices
|
||||
}
|
||||
setProgress();
|
||||
break;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
|
||||
if (!fromUser) {
|
||||
// We're not interested in programmatically generated changes to
|
||||
// the progress bar's position.
|
||||
return;
|
||||
}
|
||||
|
||||
long duration = mPlayer.getDuration();
|
||||
long newposition = (duration * progress) / 1000L;
|
||||
mPlayer.seekTo((int) newposition);
|
||||
if (mCurrentTime != null) {
|
||||
mCurrentTime.setText(stringForTime((int) newposition));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called in devices with touchpad when the user starts to adjust the
|
||||
* position of the seekbar's thumb.
|
||||
*
|
||||
* Will be followed by several onProgressChanged notifications.
|
||||
*/
|
||||
@Override
|
||||
public void onStartTrackingTouch(SeekBar seekBar) {
|
||||
mDragging = true; // monitors the duration of dragging
|
||||
mHandler.removeMessages(SHOW_PROGRESS); // grants no more updates with media player progress while dragging
|
||||
}
|
||||
|
||||
/**
|
||||
* Called in devices with touchpad when the user finishes the
|
||||
* adjusting of the seekbar.
|
||||
*/
|
||||
@Override
|
||||
public void onStopTrackingTouch(SeekBar seekBar) {
|
||||
mDragging = false;
|
||||
setProgress();
|
||||
updatePausePlay();
|
||||
mHandler.sendEmptyMessage(SHOW_PROGRESS); // grants future updates with media player progress
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInitializeAccessibilityEvent(AccessibilityEvent event) {
|
||||
super.onInitializeAccessibilityEvent(event);
|
||||
event.setClassName(MediaControlView.class.getName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
|
||||
super.onInitializeAccessibilityNodeInfo(info);
|
||||
info.setClassName(MediaControlView.class.getName());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,763 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David A. Velasco
|
||||
* @author Christian Schabesberger
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
* <p>
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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 eu.qsfera.android.media;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.accounts.AccountManager;
|
||||
import android.accounts.OnAccountsUpdateListener;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.media.AudioManager;
|
||||
import android.media.MediaPlayer;
|
||||
import android.media.MediaPlayer.OnCompletionListener;
|
||||
import android.media.MediaPlayer.OnErrorListener;
|
||||
import android.media.MediaPlayer.OnPreparedListener;
|
||||
import android.net.wifi.WifiManager;
|
||||
import android.net.wifi.WifiManager.WifiLock;
|
||||
import android.os.FileObserver;
|
||||
import android.os.IBinder;
|
||||
import android.os.PowerManager;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import eu.qsfera.android.R;
|
||||
import eu.qsfera.android.presentation.authentication.AccountUtils;
|
||||
import eu.qsfera.android.domain.files.model.OCFile;
|
||||
import eu.qsfera.android.ui.activity.FileActivity;
|
||||
import eu.qsfera.android.ui.activity.FileDisplayActivity;
|
||||
import eu.qsfera.android.utils.NotificationUtils;
|
||||
import timber.log.Timber;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
|
||||
import static eu.qsfera.android.utils.NotificationConstantsKt.MEDIA_SERVICE_NOTIFICATION_CHANNEL_ID;
|
||||
|
||||
/**
|
||||
* Service that handles media playback, both audio and video.
|
||||
*
|
||||
* Waits for Intents which signal the service to perform specific operations: Play, Pause,
|
||||
* Rewind, etc.
|
||||
*/
|
||||
public class MediaService extends Service implements OnCompletionListener, OnPreparedListener,
|
||||
OnErrorListener, AudioManager.OnAudioFocusChangeListener {
|
||||
|
||||
private static final String MY_PACKAGE = MediaService.class.getPackage() != null ?
|
||||
MediaService.class.getPackage().getName() : "eu.qsfera.android.media";
|
||||
|
||||
/// Intent actions that we are prepared to handle
|
||||
public static final String ACTION_PLAY_FILE = MY_PACKAGE + ".action.PLAY_FILE";
|
||||
public static final String ACTION_STOP_ALL = MY_PACKAGE + ".action.STOP_ALL";
|
||||
public static final String ACTION_STOP_FILE = MY_PACKAGE + ".action.STOP_FILE";
|
||||
|
||||
/// Keys to add extras to the action
|
||||
public static final String EXTRA_FILE = MY_PACKAGE + ".extra.FILE";
|
||||
public static final String EXTRA_ACCOUNT = MY_PACKAGE + ".extra.ACCOUNT";
|
||||
public static String EXTRA_START_POSITION = MY_PACKAGE + ".extra.START_POSITION";
|
||||
public static final String EXTRA_PLAY_ON_LOAD = MY_PACKAGE + ".extra.PLAY_ON_LOAD";
|
||||
|
||||
/** Error code for specific messages - see regular error codes at {@link MediaPlayer} */
|
||||
public static final int OC_MEDIA_ERROR = 0;
|
||||
|
||||
/** Time To keep the control panel visible when the user does not use it */
|
||||
public static final int MEDIA_CONTROL_SHORT_LIFE = 4000;
|
||||
|
||||
/** Time To keep the control panel visible when the user does not use it */
|
||||
public static final int MEDIA_CONTROL_PERMANENT = 0;
|
||||
|
||||
/** Volume to set when audio focus is lost and ducking is allowed */
|
||||
private static final float DUCK_VOLUME = 0.1f;
|
||||
|
||||
/** Media player instance */
|
||||
private MediaPlayer mPlayer = null;
|
||||
|
||||
/** Reference to the system AudioManager */
|
||||
private AudioManager mAudioManager = null;
|
||||
|
||||
/** Reference to the system AccountManager */
|
||||
private AccountManager mAccountManager;
|
||||
|
||||
/** Values to indicate the state of the service */
|
||||
enum State {
|
||||
STOPPED,
|
||||
PREPARING,
|
||||
PLAYING,
|
||||
PAUSED
|
||||
}
|
||||
|
||||
/** Current state */
|
||||
private State mState = State.STOPPED;
|
||||
|
||||
/** Possible focus values */
|
||||
enum AudioFocus {
|
||||
NO_FOCUS,
|
||||
NO_FOCUS_CAN_DUCK,
|
||||
FOCUS
|
||||
}
|
||||
|
||||
/** Current focus state */
|
||||
private AudioFocus mAudioFocus = AudioFocus.NO_FOCUS;
|
||||
|
||||
/** 'True' when the current song is streaming from the network */
|
||||
private boolean mIsStreaming = false;
|
||||
|
||||
/** Wifi lock kept to prevents the device from shutting off the radio when streaming a file. */
|
||||
private WifiLock mWifiLock;
|
||||
|
||||
private static final String MEDIA_WIFI_LOCK_TAG = MY_PACKAGE + ".WIFI_LOCK";
|
||||
|
||||
/** Notification to keep in the notification bar while a song is playing */
|
||||
private NotificationManager mNotificationManager;
|
||||
|
||||
/** File being played */
|
||||
private OCFile mFile;
|
||||
|
||||
/** Observer being notified if played file is deleted */
|
||||
private MediaFileObserver mFileObserver = null;
|
||||
|
||||
/** Account holding the file being played */
|
||||
private Account mAccount;
|
||||
|
||||
/** Flag signaling if the audio should be played immediately when the file is prepared */
|
||||
protected boolean mPlayOnPrepared;
|
||||
|
||||
/** Position, in milliseconds, where the audio should be started */
|
||||
private int mStartPosition;
|
||||
|
||||
/** Interface to access the service through binding */
|
||||
private IBinder mBinder;
|
||||
|
||||
/** Control panel shown to the user to control the playback, to register through binding */
|
||||
private MediaControlView mMediaController;
|
||||
|
||||
/** Notification builder to create notifications, new reuse way since Android 6 */
|
||||
private NotificationCompat.Builder mNotificationBuilder;
|
||||
|
||||
/**
|
||||
* Helper method to get an error message suitable to show to users for errors occurred in media playback,
|
||||
*
|
||||
* @param context A context to access string resources.
|
||||
* @param what See {@link MediaPlayer.OnErrorListener#onError(MediaPlayer, int, int)
|
||||
* @param extra See {@link MediaPlayer.OnErrorListener#onError(MediaPlayer, int, int)
|
||||
* @return Message suitable to users.
|
||||
*/
|
||||
public static String getMessageForMediaError(Context context, int what, int extra) {
|
||||
int messageId;
|
||||
|
||||
if (what == OC_MEDIA_ERROR) {
|
||||
messageId = extra;
|
||||
|
||||
} else if (extra == MediaPlayer.MEDIA_ERROR_UNSUPPORTED) {
|
||||
/* Added in API level 17
|
||||
Bitstream is conforming to the related coding standard or file spec,
|
||||
but the media framework does not support the feature.
|
||||
Constant Value: -1010 (0xfffffc0e)
|
||||
*/
|
||||
messageId = R.string.media_err_unsupported;
|
||||
|
||||
} else if (extra == MediaPlayer.MEDIA_ERROR_IO) {
|
||||
/* Added in API level 17
|
||||
File or network related operation errors.
|
||||
Constant Value: -1004 (0xfffffc14)
|
||||
*/
|
||||
messageId = R.string.media_err_io;
|
||||
|
||||
} else if (extra == MediaPlayer.MEDIA_ERROR_MALFORMED) {
|
||||
/* Added in API level 17
|
||||
Bitstream is not conforming to the related coding standard or file spec.
|
||||
Constant Value: -1007 (0xfffffc11)
|
||||
*/
|
||||
messageId = R.string.media_err_malformed;
|
||||
|
||||
} else if (extra == MediaPlayer.MEDIA_ERROR_TIMED_OUT) {
|
||||
/* Added in API level 17
|
||||
Some operation takes too long to complete, usually more than 3-5 seconds.
|
||||
Constant Value: -110 (0xffffff92)
|
||||
*/
|
||||
messageId = R.string.media_err_timeout;
|
||||
|
||||
} else if (what == MediaPlayer.MEDIA_ERROR_NOT_VALID_FOR_PROGRESSIVE_PLAYBACK) {
|
||||
/* Added in API level 3
|
||||
The video is streamed and its container is not valid for progressive playback i.e the video's index
|
||||
(e.g moov atom) is not at the start of the file.
|
||||
Constant Value: 200 (0x000000c8)
|
||||
*/
|
||||
messageId = R.string.media_err_invalid_progressive_playback;
|
||||
|
||||
} else {
|
||||
/* MediaPlayer.MEDIA_ERROR_UNKNOWN
|
||||
Added in API level 1
|
||||
Unspecified media player error.
|
||||
Constant Value: 1 (0x00000001)
|
||||
*/
|
||||
/* MediaPlayer.MEDIA_ERROR_SERVER_DIED)
|
||||
Added in API level 1
|
||||
Media server died. In this case, the application must release the MediaPlayer
|
||||
object and instantiate a new one.
|
||||
Constant Value: 100 (0x00000064)
|
||||
*/
|
||||
messageId = R.string.media_err_unknown;
|
||||
}
|
||||
return context.getString(messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a service instance
|
||||
*
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
Timber.d("Creating qsfera media service");
|
||||
|
||||
mWifiLock = ((WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE)).
|
||||
createWifiLock(WifiManager.WIFI_MODE_FULL, MEDIA_WIFI_LOCK_TAG);
|
||||
|
||||
mNotificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
|
||||
|
||||
mNotificationBuilder = new NotificationCompat.Builder(this);
|
||||
mNotificationBuilder.setColor(this.getResources().getColor(R.color.primary));
|
||||
mAudioManager = (AudioManager) getSystemService(AUDIO_SERVICE);
|
||||
mBinder = new MediaServiceBinder(this);
|
||||
|
||||
// add AccountsUpdatedListener
|
||||
mAccountManager = AccountManager.get(this);
|
||||
mAccountManager.addOnAccountsUpdatedListener(new OnAccountsUpdateListener() {
|
||||
@Override
|
||||
public void onAccountsUpdated(Account[] accounts) {
|
||||
// stop playback if account of the played media files was removed
|
||||
if (mAccount != null && !AccountUtils.exists(mAccount.name, MediaService.this)) {
|
||||
processStopRequest(false);
|
||||
}
|
||||
}
|
||||
}, null, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point for Intents requesting actions, sent here via startService.
|
||||
*
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
String action = intent.getAction();
|
||||
if (action.equals(ACTION_PLAY_FILE)) {
|
||||
processPlayFileRequest(intent);
|
||||
|
||||
} else if (action.equals(ACTION_STOP_ALL)) {
|
||||
processStopRequest(true);
|
||||
} else if (action.equals(ACTION_STOP_FILE)) {
|
||||
processStopFileRequest(intent);
|
||||
}
|
||||
|
||||
return START_NOT_STICKY; // don't want it to restart in case it's killed.
|
||||
}
|
||||
|
||||
private void processStopFileRequest(Intent intent) {
|
||||
OCFile file = intent.getExtras().getParcelable(EXTRA_FILE);
|
||||
if (file != null && file.equals(mFile)) {
|
||||
processStopRequest(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a request to play a media file received as a parameter
|
||||
*
|
||||
* TODO If a new request is received when a file is being prepared, it is ignored. Is this what we want?
|
||||
*
|
||||
* @param intent Intent received in the request with the data to identify the file to play.
|
||||
*/
|
||||
private void processPlayFileRequest(Intent intent) {
|
||||
if (mState != State.PREPARING) {
|
||||
mFile = intent.getExtras().getParcelable(EXTRA_FILE);
|
||||
mAccount = intent.getExtras().getParcelable(EXTRA_ACCOUNT);
|
||||
mPlayOnPrepared = intent.getExtras().getBoolean(EXTRA_PLAY_ON_LOAD, false);
|
||||
mStartPosition = intent.getExtras().getInt(EXTRA_START_POSITION, 0);
|
||||
tryToGetAudioFocus();
|
||||
playMedia();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a request to play a media file.
|
||||
*/
|
||||
protected void processPlayRequest() {
|
||||
// request audio focus
|
||||
tryToGetAudioFocus();
|
||||
|
||||
// actually play the song
|
||||
if (mState == State.STOPPED) {
|
||||
// (re)start playback
|
||||
playMedia();
|
||||
|
||||
} else if (mState == State.PAUSED) {
|
||||
// continue playback
|
||||
mState = State.PLAYING;
|
||||
setUpAsForeground(String.format(getString(R.string.media_state_playing), mFile.getFileName()));
|
||||
configAndStartMediaPlayer();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure the media player exists and has been reset. This will create the media player
|
||||
* if needed. reset the existing media player if one already exists.
|
||||
*/
|
||||
protected void createMediaPlayerIfNeeded() {
|
||||
if (mPlayer == null) {
|
||||
mPlayer = new MediaPlayer();
|
||||
|
||||
// make sure the CPU won't go to sleep while media is playing
|
||||
mPlayer.setWakeMode(getApplicationContext(), PowerManager.PARTIAL_WAKE_LOCK);
|
||||
|
||||
// the media player will notify the service when it's ready preparing, and when it's done playing
|
||||
mPlayer.setOnPreparedListener(this);
|
||||
mPlayer.setOnCompletionListener(this);
|
||||
mPlayer.setOnErrorListener(this);
|
||||
|
||||
} else {
|
||||
mPlayer.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a request to pause the current playback
|
||||
*/
|
||||
protected void processPauseRequest() {
|
||||
if (mState == State.PLAYING) {
|
||||
mState = State.PAUSED;
|
||||
mPlayer.pause();
|
||||
releaseResources(false); // retain media player in pause
|
||||
// TODO polite audio focus, instead of keep it owned; or not?
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a request to stop the playback.
|
||||
*
|
||||
* @param force When 'true', the playback is stopped no matter the value of mState
|
||||
*/
|
||||
protected void processStopRequest(boolean force) {
|
||||
if (mState != State.PREPARING || force) {
|
||||
mState = State.STOPPED;
|
||||
mFile = null;
|
||||
stopFileObserver();
|
||||
mAccount = null;
|
||||
releaseResources(true);
|
||||
giveUpAudioFocus();
|
||||
stopSelf(); // service is no longer necessary
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases resources used by the service for playback. This includes the "foreground service"
|
||||
* status and notification, the wake locks and possibly the MediaPlayer.
|
||||
*
|
||||
* @param releaseMediaPlayer Indicates whether the Media Player should also be released or not
|
||||
*/
|
||||
protected void releaseResources(boolean releaseMediaPlayer) {
|
||||
// stop being a foreground service
|
||||
stopForeground(true);
|
||||
|
||||
// stop and release the Media Player, if it's available
|
||||
if (releaseMediaPlayer && mPlayer != null) {
|
||||
mPlayer.reset();
|
||||
mPlayer.release();
|
||||
mPlayer = null;
|
||||
}
|
||||
|
||||
// release the Wifi lock, if holding it
|
||||
if (mWifiLock.isHeld()) {
|
||||
mWifiLock.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully releases the audio focus.
|
||||
*/
|
||||
private void giveUpAudioFocus() {
|
||||
if (mAudioFocus == AudioFocus.FOCUS
|
||||
&& mAudioManager != null
|
||||
&& AudioManager.AUDIOFOCUS_REQUEST_GRANTED == mAudioManager.abandonAudioFocus(this)) {
|
||||
|
||||
mAudioFocus = AudioFocus.NO_FOCUS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconfigures MediaPlayer according to audio focus settings and starts/restarts it.
|
||||
*/
|
||||
protected void configAndStartMediaPlayer() {
|
||||
if (mPlayer == null) {
|
||||
throw new IllegalStateException("mPlayer is NULL");
|
||||
}
|
||||
|
||||
if (mAudioFocus == AudioFocus.NO_FOCUS) {
|
||||
if (mPlayer.isPlaying()) {
|
||||
mPlayer.pause(); // have to be polite; but mState is not changed, to resume when focus is
|
||||
// received again
|
||||
}
|
||||
|
||||
} else {
|
||||
if (mAudioFocus == AudioFocus.NO_FOCUS_CAN_DUCK) {
|
||||
mPlayer.setVolume(DUCK_VOLUME, DUCK_VOLUME);
|
||||
|
||||
} else {
|
||||
mPlayer.setVolume(1.0f, 1.0f); // full volume
|
||||
}
|
||||
|
||||
if (!mPlayer.isPlaying()) {
|
||||
mPlayer.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the audio focus to the Audio Manager
|
||||
*/
|
||||
private void tryToGetAudioFocus() {
|
||||
if (mAudioFocus != AudioFocus.FOCUS
|
||||
&& mAudioManager != null
|
||||
&& (AudioManager.AUDIOFOCUS_REQUEST_GRANTED == mAudioManager.requestAudioFocus(this,
|
||||
AudioManager.STREAM_MUSIC,
|
||||
AudioManager.AUDIOFOCUS_GAIN))
|
||||
) {
|
||||
mAudioFocus = AudioFocus.FOCUS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts playing the current media file.
|
||||
*/
|
||||
protected void playMedia() {
|
||||
mState = State.STOPPED;
|
||||
releaseResources(false); // release everything except MediaPlayer
|
||||
|
||||
try {
|
||||
if (mFile == null) {
|
||||
Toast.makeText(this, R.string.media_err_nothing_to_play, Toast.LENGTH_LONG).show();
|
||||
processStopRequest(true);
|
||||
return;
|
||||
|
||||
} else if (mAccount == null) {
|
||||
Toast.makeText(this, R.string.media_err_not_in_qsfera, Toast.LENGTH_LONG).show();
|
||||
processStopRequest(true);
|
||||
return;
|
||||
}
|
||||
|
||||
createMediaPlayerIfNeeded();
|
||||
mPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
|
||||
String url = mFile.getStoragePath();
|
||||
updateFileObserver(url);
|
||||
/* Streaming is not possible right now
|
||||
if (url == null || url.length() <= 0) {
|
||||
url = AccountUtils.constructFullURLForAccount(this, mAccount) + mFile.getRemotePath();
|
||||
}
|
||||
mIsStreaming = url.startsWith("http:") || url.startsWith("https:");
|
||||
*/
|
||||
mIsStreaming = false;
|
||||
|
||||
mPlayer.setDataSource(url);
|
||||
|
||||
mState = State.PREPARING;
|
||||
setUpAsForeground(String.format(getString(R.string.media_state_loading), mFile.getFileName()));
|
||||
|
||||
// starts preparing the media player in background
|
||||
mPlayer.prepareAsync();
|
||||
|
||||
// prevent the Wifi from going to sleep when streaming
|
||||
if (mIsStreaming) {
|
||||
mWifiLock.acquire();
|
||||
} else if (mWifiLock.isHeld()) {
|
||||
mWifiLock.release();
|
||||
}
|
||||
|
||||
} catch (SecurityException e) {
|
||||
Timber.e(e, "SecurityException playing " + mAccount.name + mFile.getRemotePath());
|
||||
Toast.makeText(this, String.format(getString(R.string.media_err_security_ex), mFile.getFileName()),
|
||||
Toast.LENGTH_LONG).show();
|
||||
processStopRequest(true);
|
||||
|
||||
} catch (IOException e) {
|
||||
Timber.e(e, "IOException playing " + mAccount.name + mFile.getRemotePath());
|
||||
Toast.makeText(this, String.format(getString(R.string.media_err_io_ex), mFile.getFileName()),
|
||||
Toast.LENGTH_LONG).show();
|
||||
processStopRequest(true);
|
||||
|
||||
} catch (IllegalStateException e) {
|
||||
Timber.e(e, "IllegalStateException " + mAccount.name + mFile.getRemotePath());
|
||||
Toast.makeText(this, String.format(getString(R.string.media_err_unexpected), mFile.getFileName()),
|
||||
Toast.LENGTH_LONG).show();
|
||||
processStopRequest(true);
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
Timber.e(e, "IllegalArgumentException " + mAccount.name + mFile.getRemotePath());
|
||||
Toast.makeText(this, String.format(getString(R.string.media_err_unexpected), mFile.getFileName()),
|
||||
Toast.LENGTH_LONG).show();
|
||||
processStopRequest(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateFileObserver(String url) {
|
||||
stopFileObserver();
|
||||
mFileObserver = new MediaFileObserver(url);
|
||||
mFileObserver.startWatching();
|
||||
}
|
||||
|
||||
private void stopFileObserver() {
|
||||
if (mFileObserver != null) {
|
||||
mFileObserver.stopWatching();
|
||||
}
|
||||
}
|
||||
|
||||
/** Called when media player is done playing current song. */
|
||||
public void onCompletion(MediaPlayer player) {
|
||||
Toast.makeText(this, String.format(getString(R.string.media_event_done), mFile.getFileName()),
|
||||
Toast.LENGTH_LONG).show();
|
||||
if (mMediaController != null) {
|
||||
// somebody is still bound to the service
|
||||
player.seekTo(0);
|
||||
processPauseRequest();
|
||||
mMediaController.updatePausePlay();
|
||||
} else {
|
||||
// nobody is bound
|
||||
processStopRequest(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when media player is done preparing.
|
||||
*
|
||||
* Time to start.
|
||||
*/
|
||||
public void onPrepared(MediaPlayer player) {
|
||||
mState = State.PLAYING;
|
||||
updateNotification(String.format(getString(R.string.media_state_playing), mFile.getFileName()));
|
||||
if (mMediaController != null) {
|
||||
mMediaController.setEnabled(true);
|
||||
}
|
||||
player.seekTo(mStartPosition);
|
||||
configAndStartMediaPlayer();
|
||||
if (!mPlayOnPrepared) {
|
||||
processPauseRequest();
|
||||
}
|
||||
|
||||
if (mMediaController != null) {
|
||||
mMediaController.updatePausePlay();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the status notification
|
||||
*/
|
||||
private void updateNotification(String content) {
|
||||
String ticker = String.format(getString(R.string.media_notif_ticker), getString(R.string.app_name));
|
||||
|
||||
// TODO check if updating the Intent is really necessary
|
||||
Intent showDetailsIntent = new Intent(this, FileDisplayActivity.class);
|
||||
showDetailsIntent.putExtra(FileActivity.EXTRA_FILE, mFile);
|
||||
showDetailsIntent.putExtra(FileActivity.EXTRA_ACCOUNT, mAccount);
|
||||
showDetailsIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
|
||||
mNotificationBuilder.setContentIntent(PendingIntent.getActivity(getApplicationContext(),
|
||||
(int) System.currentTimeMillis(),
|
||||
showDetailsIntent,
|
||||
NotificationUtils.pendingIntentFlags));
|
||||
mNotificationBuilder.setWhen(System.currentTimeMillis());
|
||||
mNotificationBuilder.setTicker(ticker);
|
||||
mNotificationBuilder.setContentTitle(ticker);
|
||||
mNotificationBuilder.setContentText(content);
|
||||
mNotificationBuilder.setChannelId(MEDIA_SERVICE_NOTIFICATION_CHANNEL_ID);
|
||||
|
||||
mNotificationManager.notify(R.string.media_notif_ticker, mNotificationBuilder.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the service as a foreground service.
|
||||
*
|
||||
* The system will avoid finishing the service as much as possible when resources as low.
|
||||
*
|
||||
* A notification must be created to keep the user aware of the existence of the service.
|
||||
*/
|
||||
private void setUpAsForeground(String content) {
|
||||
String ticker = String.format(getString(R.string.media_notif_ticker), getString(R.string.app_name));
|
||||
|
||||
/// creates status notification
|
||||
// TODO put a progress bar to follow the playback progress
|
||||
mNotificationBuilder.setSmallIcon(R.drawable.ic_play_arrow);
|
||||
//mNotification.tickerText = text;
|
||||
mNotificationBuilder.setWhen(System.currentTimeMillis());
|
||||
mNotificationBuilder.setOngoing(true);
|
||||
|
||||
/// includes a pending intent in the notification showing the details view of the file
|
||||
Intent showDetailsIntent = new Intent(this, FileDisplayActivity.class);
|
||||
showDetailsIntent.putExtra(FileActivity.EXTRA_FILE, mFile);
|
||||
showDetailsIntent.putExtra(FileActivity.EXTRA_ACCOUNT, mAccount);
|
||||
showDetailsIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
mNotificationBuilder.setContentIntent(PendingIntent.getActivity(getApplicationContext(),
|
||||
(int) System.currentTimeMillis(),
|
||||
showDetailsIntent,
|
||||
NotificationUtils.pendingIntentFlags));
|
||||
mNotificationBuilder.setContentTitle(ticker);
|
||||
mNotificationBuilder.setContentText(content);
|
||||
mNotificationBuilder.setChannelId(MEDIA_SERVICE_NOTIFICATION_CHANNEL_ID);
|
||||
|
||||
startForeground(R.string.media_notif_ticker, mNotificationBuilder.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when there's an error playing media.
|
||||
*
|
||||
* Warns the user about the error and resets the media player.
|
||||
*/
|
||||
public boolean onError(MediaPlayer mp, int what, int extra) {
|
||||
Timber.e("Error in audio playback, what = " + what + ", extra = " + extra);
|
||||
|
||||
String message = getMessageForMediaError(this, what, extra);
|
||||
Toast.makeText(getApplicationContext(), message, Toast.LENGTH_SHORT).show();
|
||||
|
||||
processStopRequest(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the system when another app tries to play some sound.
|
||||
*
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public void onAudioFocusChange(int focusChange) {
|
||||
if (focusChange > 0) {
|
||||
// focus gain; check AudioManager.AUDIOFOCUS_* values
|
||||
mAudioFocus = AudioFocus.FOCUS;
|
||||
// restart media player with new focus settings
|
||||
if (mState == State.PLAYING) {
|
||||
configAndStartMediaPlayer();
|
||||
}
|
||||
|
||||
} else if (focusChange < 0) {
|
||||
// focus loss; check AudioManager.AUDIOFOCUS_* values
|
||||
boolean canDuck = AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK == focusChange;
|
||||
mAudioFocus = canDuck ? AudioFocus.NO_FOCUS_CAN_DUCK : AudioFocus.NO_FOCUS;
|
||||
// start/restart/pause media player with new focus settings
|
||||
if (mPlayer != null && mPlayer.isPlaying()) {
|
||||
configAndStartMediaPlayer();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the service is finished for final clean-up.
|
||||
*
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
mState = State.STOPPED;
|
||||
releaseResources(true);
|
||||
giveUpAudioFocus();
|
||||
stopForeground(true);
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a binder object that clients can use to perform operations on the MediaPlayer managed by the
|
||||
* MediaService.
|
||||
*/
|
||||
@Override
|
||||
public IBinder onBind(Intent arg) {
|
||||
return mBinder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when ALL the bound clients were onbound.
|
||||
*
|
||||
* The service is destroyed if playback stopped or paused
|
||||
*/
|
||||
@Override
|
||||
public boolean onUnbind(Intent intent) {
|
||||
if (mState == State.PAUSED || mState == State.STOPPED) {
|
||||
processStopRequest(false);
|
||||
}
|
||||
return false; // not accepting rebinding (default behaviour)
|
||||
}
|
||||
|
||||
/**
|
||||
* Accesses the current MediaPlayer instance in the service.
|
||||
*
|
||||
* To be handled carefully. Visibility is protected to be accessed only
|
||||
*
|
||||
* @return Current MediaPlayer instance handled by MediaService.
|
||||
*/
|
||||
protected MediaPlayer getPlayer() {
|
||||
return mPlayer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accesses the current OCFile loaded in the service.
|
||||
*
|
||||
* @return The current OCFile loaded in the service.
|
||||
*/
|
||||
protected OCFile getCurrentFile() {
|
||||
return mFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accesses the current {@link State} of the MediaService.
|
||||
*
|
||||
* @return The current {@link State} of the MediaService.
|
||||
*/
|
||||
protected State getState() {
|
||||
return mState;
|
||||
}
|
||||
|
||||
protected void setMediaController(MediaControlView mediaController) {
|
||||
mMediaController = mediaController;
|
||||
}
|
||||
|
||||
protected MediaControlView getMediaController() {
|
||||
return mMediaController;
|
||||
}
|
||||
|
||||
/**
|
||||
* Observer monitoring the media file currently played and stopping the playback in case
|
||||
* that it's deleted or moved away from its storage location.
|
||||
*/
|
||||
private class MediaFileObserver extends FileObserver {
|
||||
|
||||
public MediaFileObserver(String path) {
|
||||
super((new File(path)).getParent(), FileObserver.DELETE | FileObserver.MOVED_FROM);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEvent(int event, String path) {
|
||||
if (path != null && path.equals(mFile.getFileName())) {
|
||||
Timber.d("Media file deleted or moved out of sight, stopping playback");
|
||||
processStopRequest(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David A. Velasco
|
||||
* Copyright (C) 2016 ownCloud GmbH.
|
||||
* <p>
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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 eu.qsfera.android.media;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.content.Intent;
|
||||
import android.media.MediaPlayer;
|
||||
import android.os.Binder;
|
||||
import android.widget.MediaController;
|
||||
|
||||
import eu.qsfera.android.domain.files.model.OCFile;
|
||||
import eu.qsfera.android.media.MediaService.State;
|
||||
import timber.log.Timber;
|
||||
|
||||
/**
|
||||
* Binder allowing client components to perform operations on on the MediaPlayer managed by a MediaService instance.
|
||||
*
|
||||
* Provides the operations of {@link MediaController.MediaPlayerControl}, and an extra method to check if
|
||||
* an {@link OCFile} instance is handled by the MediaService.
|
||||
*/
|
||||
public class MediaServiceBinder extends Binder implements MediaController.MediaPlayerControl {
|
||||
|
||||
/**
|
||||
* {@link MediaService} instance to access with the binder
|
||||
*/
|
||||
private MediaService mService = null;
|
||||
|
||||
/**
|
||||
* Public constructor
|
||||
*
|
||||
* @param service A {@link MediaService} instance to access with the binder
|
||||
*/
|
||||
public MediaServiceBinder(MediaService service) {
|
||||
if (service == null) {
|
||||
throw new IllegalArgumentException("Argument 'service' can not be null");
|
||||
}
|
||||
mService = service;
|
||||
}
|
||||
|
||||
public boolean isPlaying(OCFile mFile) {
|
||||
return (mFile != null && mFile.equals(mService.getCurrentFile()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canPause() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canSeekBackward() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canSeekForward() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getBufferPercentage() {
|
||||
MediaPlayer currentPlayer = mService.getPlayer();
|
||||
if (currentPlayer != null) {
|
||||
return 100;
|
||||
// TODO update for streamed playback; add OnBufferUpdateListener in MediaService
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCurrentPosition() {
|
||||
MediaPlayer currentPlayer = mService.getPlayer();
|
||||
if (currentPlayer != null) {
|
||||
return currentPlayer.getCurrentPosition();
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getDuration() {
|
||||
MediaPlayer currentPlayer = mService.getPlayer();
|
||||
if (currentPlayer != null) {
|
||||
return currentPlayer.getDuration();
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports if the MediaService is playing a file or not.
|
||||
*
|
||||
* Considers that the file is being played when it is in preparation because the expected
|
||||
* client of this method is a {@link MediaController} , and we do not want that the 'play'
|
||||
* button is shown when the file is being prepared by the MediaService.
|
||||
*/
|
||||
@Override
|
||||
public boolean isPlaying() {
|
||||
MediaService.State currentState = mService.getState();
|
||||
return (currentState == State.PLAYING || (currentState == State.PREPARING && mService.mPlayOnPrepared));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void pause() {
|
||||
Timber.d("Pausing through binder...");
|
||||
mService.processPauseRequest();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void seekTo(int pos) {
|
||||
Timber.d("Seeking " + pos + " through binder...");
|
||||
MediaPlayer currentPlayer = mService.getPlayer();
|
||||
MediaService.State currentState = mService.getState();
|
||||
if (currentPlayer != null && currentState != State.PREPARING && currentState != State.STOPPED) {
|
||||
currentPlayer.seekTo(pos);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
Timber.d("Starting through binder...");
|
||||
mService.processPlayRequest(); // this will finish the service if there is no file preloaded to play
|
||||
}
|
||||
|
||||
public void start(Account account, OCFile file, boolean playImmediately, int position) {
|
||||
Timber.d("Loading and starting through binder...");
|
||||
Intent i = new Intent(mService, MediaService.class);
|
||||
i.putExtra(MediaService.EXTRA_ACCOUNT, account);
|
||||
i.putExtra(MediaService.EXTRA_FILE, file);
|
||||
i.putExtra(MediaService.EXTRA_PLAY_ON_LOAD, playImmediately);
|
||||
i.putExtra(MediaService.EXTRA_START_POSITION, position);
|
||||
i.setAction(MediaService.ACTION_PLAY_FILE);
|
||||
mService.startService(i);
|
||||
}
|
||||
|
||||
public void registerMediaController(MediaControlView mediaController) {
|
||||
mService.setMediaController(mediaController);
|
||||
}
|
||||
|
||||
public void unregisterMediaController(MediaControlView mediaController) {
|
||||
if (mediaController != null && mediaController == mService.getMediaController()) {
|
||||
mService.setMediaController(null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public boolean isInPlaybackState() {
|
||||
MediaService.State currentState = mService.getState();
|
||||
return (currentState == MediaService.State.PLAYING || currentState == MediaService.State.PAUSED);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getAudioSessionId() {
|
||||
return 1; // not really used
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David A. Velasco
|
||||
* @author Christian Schabesberger
|
||||
* @author David González Verdugo
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
* <p>
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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 eu.qsfera.android.operations;
|
||||
|
||||
import android.accounts.Account;
|
||||
|
||||
import eu.qsfera.android.domain.files.model.OCFile;
|
||||
import eu.qsfera.android.lib.common.QSferaClient;
|
||||
import eu.qsfera.android.lib.common.operations.RemoteOperation;
|
||||
import eu.qsfera.android.lib.common.operations.RemoteOperationResult;
|
||||
import eu.qsfera.android.lib.resources.files.CheckPathExistenceRemoteOperation;
|
||||
import eu.qsfera.android.operations.common.SyncOperation;
|
||||
|
||||
/**
|
||||
* Checks validity of currently stored credentials for a given OC account
|
||||
*/
|
||||
public class CheckCurrentCredentialsOperation extends SyncOperation<Account> {
|
||||
|
||||
private Account mAccount;
|
||||
|
||||
public CheckCurrentCredentialsOperation(Account account) {
|
||||
if (account == null) {
|
||||
throw new IllegalArgumentException("NULL account");
|
||||
}
|
||||
mAccount = account;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected RemoteOperationResult<Account> run(QSferaClient client) {
|
||||
if (!getStorageManager().getAccount().name.equals(mAccount.name)) {
|
||||
return new RemoteOperationResult<>(new IllegalStateException(
|
||||
"Account to validate is not the account connected to!"));
|
||||
} else {
|
||||
RemoteOperation checkPathExistenceOperation = new CheckPathExistenceRemoteOperation(OCFile.ROOT_PATH, false, null);
|
||||
final RemoteOperationResult existenceCheckResult = checkPathExistenceOperation.execute(client);
|
||||
final RemoteOperationResult<Account> result
|
||||
= new RemoteOperationResult<>(existenceCheckResult.getCode());
|
||||
result.setData(mAccount);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David A. Velasco
|
||||
* @author David González Verdugo
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
* <p>
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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 eu.qsfera.android.operations
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import eu.qsfera.android.MainApp.Companion.appContext
|
||||
import eu.qsfera.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase
|
||||
|
||||
import eu.qsfera.android.domain.user.usecases.GetUserInfoAsyncUseCase
|
||||
import eu.qsfera.android.domain.user.usecases.RefreshUserQuotaFromServerAsyncUseCase
|
||||
import eu.qsfera.android.lib.common.accounts.AccountUtils
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Performs the Profile synchronization for account step by step.
|
||||
*
|
||||
* First: Synchronize user info
|
||||
* Second: Synchronize user quota
|
||||
*
|
||||
* If one step fails, next one is not performed since it may fail too.
|
||||
*/
|
||||
class SyncProfileOperation(
|
||||
private val account: Account
|
||||
) : KoinComponent {
|
||||
fun syncUserProfile() {
|
||||
try {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val getUserInfoAsyncUseCase: GetUserInfoAsyncUseCase by inject()
|
||||
val userInfoResult = getUserInfoAsyncUseCase(GetUserInfoAsyncUseCase.Params(account.name))
|
||||
userInfoResult.getDataOrNull()?.let { userInfo ->
|
||||
Timber.d("User info synchronized for account ${account.name}")
|
||||
|
||||
AccountManager.get(appContext).run {
|
||||
setUserData(account, AccountUtils.Constants.KEY_DISPLAY_NAME, userInfo.displayName)
|
||||
setUserData(account, AccountUtils.Constants.KEY_ID, userInfo.id)
|
||||
}
|
||||
|
||||
val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase by inject()
|
||||
val storedCapabilities = getStoredCapabilitiesUseCase(GetStoredCapabilitiesUseCase.Params(account.name))
|
||||
|
||||
storedCapabilities?.let {
|
||||
if (!it.isSpacesAllowed()) {
|
||||
val refreshUserQuotaFromServerAsyncUseCase: RefreshUserQuotaFromServerAsyncUseCase by inject()
|
||||
val userQuotaResult =
|
||||
refreshUserQuotaFromServerAsyncUseCase(
|
||||
RefreshUserQuotaFromServerAsyncUseCase.Params(
|
||||
account.name
|
||||
)
|
||||
)
|
||||
userQuotaResult.getDataOrNull()?.let {
|
||||
Timber.d("User quota synchronized for oC10 account ${account.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: Timber.d("User profile was not synchronized")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Exception while getting user profile")
|
||||
}
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David A. Velasco
|
||||
* @author Christian Schabesberger
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.operations.common;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Handler;
|
||||
|
||||
import eu.qsfera.android.datamodel.FileDataStorageManager;
|
||||
import eu.qsfera.android.lib.common.QSferaClient;
|
||||
import eu.qsfera.android.lib.common.operations.OnRemoteOperationListener;
|
||||
import eu.qsfera.android.lib.common.operations.RemoteOperation;
|
||||
import eu.qsfera.android.lib.common.operations.RemoteOperationResult;
|
||||
|
||||
/**
|
||||
* Operation which execution involves both interactions with an qsfera server and
|
||||
* with local data in the device.
|
||||
*
|
||||
* Provides methods to execute the operation both synchronously or asynchronously.
|
||||
*/
|
||||
public abstract class SyncOperation<T> extends RemoteOperation<T> {
|
||||
|
||||
private FileDataStorageManager mStorageManager;
|
||||
|
||||
public FileDataStorageManager getStorageManager() {
|
||||
return mStorageManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously executes the operation on the received qsfera account.
|
||||
*
|
||||
* Do not call this method from the main thread.
|
||||
*
|
||||
* This method should be used whenever an qsfera account is available, instead of
|
||||
* {@link #execute(QSferaClient, eu.qsfera.android.datamodel.FileDataStorageManager)}.
|
||||
*
|
||||
* @param storageManager
|
||||
* @param context Android context for the component calling the method.
|
||||
* @return Result of the operation.
|
||||
*/
|
||||
public RemoteOperationResult<T> execute(FileDataStorageManager storageManager, Context context) {
|
||||
if (storageManager == null) {
|
||||
throw new IllegalArgumentException("Trying to execute a sync operation with a " +
|
||||
"NULL storage manager");
|
||||
}
|
||||
if (storageManager.getAccount() == null) {
|
||||
throw new IllegalArgumentException("Trying to execute a sync operation with a " +
|
||||
"storage manager for a NULL account");
|
||||
}
|
||||
mStorageManager = storageManager;
|
||||
return super.execute(mStorageManager.getAccount(), context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously executes the remote operation
|
||||
*
|
||||
* Do not call this method from the main thread.
|
||||
*
|
||||
* @param client Client object to reach an qsfera server during the execution of the operation.
|
||||
* @param storageManager Instance of local repository to sync with remote.
|
||||
* @return Result of the operation.
|
||||
*/
|
||||
public RemoteOperationResult<T> execute(QSferaClient client,
|
||||
FileDataStorageManager storageManager) {
|
||||
if (storageManager == null) {
|
||||
throw new IllegalArgumentException("Trying to execute a sync operation with a " +
|
||||
"NULL storage manager");
|
||||
}
|
||||
mStorageManager = storageManager;
|
||||
return super.execute(client);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously executes the remote operation
|
||||
*
|
||||
* This method should be used whenever an qsfera account is available,
|
||||
* instead of {@link #execute(QSferaClient, OnRemoteOperationListener, Handler))}.
|
||||
*
|
||||
* @param storageManager Instance of local repository to sync with remote.
|
||||
* @param context Android context for the component calling the method.
|
||||
* @param listener Listener to be notified about the execution of the operation.
|
||||
* @param listenerHandler Handler associated to the thread where the methods of the listener
|
||||
* objects must be called.
|
||||
* @return Thread were the remote operation is executed.
|
||||
*/
|
||||
public Thread execute(FileDataStorageManager storageManager, Context context,
|
||||
OnRemoteOperationListener listener, Handler listenerHandler) {
|
||||
if (storageManager == null) {
|
||||
throw new IllegalArgumentException("Trying to execute a sync operation " +
|
||||
"with a NULL storage manager");
|
||||
}
|
||||
if (storageManager.getAccount() == null) {
|
||||
throw new IllegalArgumentException("Trying to execute a sync operation with a" +
|
||||
" storage manager for a NULL account");
|
||||
}
|
||||
mStorageManager = storageManager;
|
||||
return super.execute(mStorageManager.getAccount(), context, listener, listenerHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously executes the remote operation
|
||||
*
|
||||
* @param client Client object to reach an qsfera server during the
|
||||
* execution of the operation.
|
||||
* @param storageManager Instance of local repository to sync with remote.
|
||||
* @param listener Listener to be notified about the execution of the operation.
|
||||
* @param listenerHandler Handler associated to the thread where the methods of
|
||||
* the listener objects must be called.
|
||||
* @return Thread were the remote operation is executed.
|
||||
*/
|
||||
public Thread execute(QSferaClient client, FileDataStorageManager storageManager,
|
||||
OnRemoteOperationListener listener, Handler listenerHandler) {
|
||||
if (storageManager == null) {
|
||||
throw new IllegalArgumentException("Trying to execute a sync operation " +
|
||||
"with a NULL storage manager");
|
||||
}
|
||||
mStorageManager = storageManager;
|
||||
return super.execute(client, listener, listenerHandler);
|
||||
}
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Javier Rodríguez Pérez
|
||||
* @author Aitor Ballesteros Pavón
|
||||
* @author Jorge Aguado Recio
|
||||
*
|
||||
* Copyright (C) 2024 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.presentation.accounts
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.databinding.AccountActionBinding
|
||||
import eu.qsfera.android.databinding.AccountItemBinding
|
||||
import eu.qsfera.android.domain.user.model.UserQuotaState
|
||||
import eu.qsfera.android.domain.user.model.UserQuota
|
||||
import eu.qsfera.android.extensions.setAccessibilityRole
|
||||
import eu.qsfera.android.lib.common.QSferaAccount
|
||||
import eu.qsfera.android.presentation.authentication.AccountUtils
|
||||
import eu.qsfera.android.presentation.avatar.AvatarUtils
|
||||
import eu.qsfera.android.utils.DisplayUtils
|
||||
import eu.qsfera.android.utils.PreferenceUtils
|
||||
import timber.log.Timber
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ManageAccountsAdapter(
|
||||
private val accountListener: AccountAdapterListener,
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private var accountItemsList = listOf<AccountRecyclerItem>()
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
|
||||
return if (viewType == AccountManagementRecyclerItemViewType.ITEM_VIEW_ACCOUNT.ordinal) {
|
||||
val view = inflater.inflate(R.layout.account_item, parent, false)
|
||||
view.filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(parent.context)
|
||||
view.setAccessibilityRole(className = Button::class.java)
|
||||
AccountManagementViewHolder(view)
|
||||
} else {
|
||||
val view = inflater.inflate(R.layout.account_action, parent, false)
|
||||
view.filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(parent.context)
|
||||
NewAccountViewHolder(view)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun submitAccountList(accountList: List<AccountRecyclerItem>) {
|
||||
accountItemsList = accountList
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (holder) {
|
||||
is AccountManagementViewHolder -> {
|
||||
val accountItem = getItem(position) as AccountRecyclerItem.AccountItem
|
||||
val account: Account = accountItem.account
|
||||
val accountAvatarRadiusDimension = holder.itemView.context.resources.getDimension(R.dimen.list_item_avatar_icon_radius)
|
||||
|
||||
try {
|
||||
val oca = QSferaAccount(account, holder.itemView.context)
|
||||
holder.binding.name.text = oca.displayName
|
||||
} catch (e: Exception) {
|
||||
Timber.w(
|
||||
e, "Account not found right after being read :\\ ; using account name instead of display name"
|
||||
)
|
||||
holder.binding.name.text = AccountUtils.getUsernameOfAccount(account.name)
|
||||
}
|
||||
holder.binding.name.tag = account.name
|
||||
|
||||
holder.binding.account.text = DisplayUtils.convertIdn(account.name, false)
|
||||
|
||||
updateQuota(
|
||||
quotaText = holder.binding.manageAccountsQuotaText,
|
||||
quotaBar = holder.binding.manageAccountsQuotaBar,
|
||||
userQuota = accountItem.userQuota,
|
||||
context = holder.itemView.context
|
||||
)
|
||||
|
||||
|
||||
try {
|
||||
val avatarUtils = AvatarUtils()
|
||||
holder.itemView.findViewTreeLifecycleOwner()?.lifecycleScope?.launch(Dispatchers.IO) {
|
||||
val loader = eu.qsfera.android.presentation.thumbnails.ThumbnailsRequester.getRevalidatingImageLoader(account)
|
||||
withContext(Dispatchers.Main) {
|
||||
avatarUtils.loadAvatarForAccount(
|
||||
holder.binding.icon,
|
||||
account,
|
||||
accountAvatarRadiusDimension,
|
||||
loader
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: java.lang.Exception) {
|
||||
Timber.e(e, "Error calculating RGB value for account list item.")
|
||||
// use user icon as a fallback
|
||||
holder.binding.icon.setImageResource(R.drawable.ic_user)
|
||||
}
|
||||
|
||||
if (AccountUtils.getCurrentQSferaAccount(holder.itemView.context).name == account.name) {
|
||||
holder.binding.ticker.visibility = View.VISIBLE
|
||||
} else {
|
||||
holder.binding.ticker.visibility = View.INVISIBLE
|
||||
}
|
||||
|
||||
/// bind listener to clean local storage from account
|
||||
holder.binding.cleanAccountLocalStorageButton.apply {
|
||||
setImageResource(R.drawable.ic_clean_account)
|
||||
setOnClickListener { accountListener.cleanAccountLocalStorage(account) }
|
||||
}
|
||||
/// bind listener to remove account
|
||||
holder.binding.removeButton.apply {
|
||||
setImageResource(R.drawable.ic_action_delete_grey)
|
||||
setOnClickListener { accountListener.removeAccount(account) }
|
||||
}
|
||||
|
||||
///bind listener to switchAccount
|
||||
holder.itemView.apply {
|
||||
setOnClickListener { accountListener.switchAccount(position) }
|
||||
}
|
||||
}
|
||||
is NewAccountViewHolder -> {
|
||||
holder.binding.icon.setImageResource(R.drawable.ic_account_plus)
|
||||
holder.binding.name.setText(R.string.prefs_add_account)
|
||||
holder.binding.name.setAccessibilityRole(className = Button::class.java)
|
||||
|
||||
// bind action listener
|
||||
holder.binding.constraintLayoutAction.setOnClickListener {
|
||||
accountListener.createAccount()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = accountItemsList.size
|
||||
|
||||
fun getItem(position: Int) = accountItemsList[position]
|
||||
|
||||
private fun updateQuota(quotaText: TextView, quotaBar: ProgressBar, userQuota: UserQuota, context: Context) {
|
||||
when {
|
||||
userQuota.available == -4L -> { // Light users
|
||||
quotaBar.visibility = View.GONE
|
||||
quotaText.text = context.getString(R.string.drawer_unavailable_used_storage)
|
||||
}
|
||||
|
||||
userQuota.available < 0 -> { // Pending, unknown or unlimited free storage. The progress bar is hid
|
||||
quotaBar.visibility = View.GONE
|
||||
quotaText.text = DisplayUtils.bytesToHumanReadable(userQuota.used, context, false)
|
||||
}
|
||||
|
||||
userQuota.available == 0L -> { // Exceeded storage. Value over 100%
|
||||
quotaBar.apply {
|
||||
progress = 100
|
||||
progressTintList = ColorStateList.valueOf(resources.getColor(R.color.quota_exceeded))
|
||||
}
|
||||
if (userQuota.state == UserQuotaState.EXCEEDED) {
|
||||
quotaText.text = String.format(
|
||||
context.getString(R.string.manage_accounts_quota),
|
||||
DisplayUtils.bytesToHumanReadable(userQuota.used, context, false),
|
||||
DisplayUtils.bytesToHumanReadable(userQuota.getTotal(), context, false)
|
||||
)
|
||||
} else { // oC10
|
||||
quotaText.text = context.getString(R.string.drawer_exceeded_quota)
|
||||
}
|
||||
}
|
||||
|
||||
else -> { // Limited storage. Value under 100%
|
||||
if (userQuota.state == UserQuotaState.CRITICAL || userQuota.state == UserQuotaState.EXCEEDED ||
|
||||
userQuota.state == UserQuotaState.NEARING) { // Value over 75%
|
||||
quotaBar.apply {
|
||||
progressTintList = ColorStateList.valueOf(resources.getColor(R.color.quota_exceeded))
|
||||
}
|
||||
}
|
||||
quotaBar.progress = userQuota.getRelative().toInt()
|
||||
quotaText.text = String.format(
|
||||
context.getString(R.string.manage_accounts_quota),
|
||||
DisplayUtils.bytesToHumanReadable(userQuota.used, context, false),
|
||||
DisplayUtils.bytesToHumanReadable(userQuota.getTotal(), context, false)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class AccountRecyclerItem {
|
||||
data class AccountItem(val account: Account, val userQuota: UserQuota) : AccountRecyclerItem()
|
||||
object NewAccount : AccountRecyclerItem()
|
||||
}
|
||||
|
||||
class AccountManagementViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
val binding = AccountItemBinding.bind(itemView)
|
||||
}
|
||||
|
||||
class NewAccountViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
val binding = AccountActionBinding.bind(itemView)
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int =
|
||||
when (getItem(position)) {
|
||||
is AccountRecyclerItem.AccountItem -> AccountManagementRecyclerItemViewType.ITEM_VIEW_ACCOUNT.ordinal
|
||||
is AccountRecyclerItem.NewAccount -> AccountManagementRecyclerItemViewType.ITEM_VIEW_ADD.ordinal
|
||||
}
|
||||
|
||||
enum class AccountManagementRecyclerItemViewType {
|
||||
ITEM_VIEW_ACCOUNT, ITEM_VIEW_ADD
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener interface for Activities using the [ManageAccountsAdapter]
|
||||
*/
|
||||
interface AccountAdapterListener {
|
||||
fun removeAccount(account: Account)
|
||||
fun cleanAccountLocalStorage(account: Account)
|
||||
fun createAccount()
|
||||
fun switchAccount(position: Int)
|
||||
}
|
||||
|
||||
}
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Jorge Aguado Recio
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
*
|
||||
* Copyright (C) 2024 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.presentation.accounts
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.accounts.AccountManagerFuture
|
||||
import android.accounts.OperationCanceledException
|
||||
import android.app.AlertDialog
|
||||
import android.app.Dialog
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.ContextThemeWrapper
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import eu.qsfera.android.MainApp
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.domain.user.model.UserQuota
|
||||
import eu.qsfera.android.extensions.avoidScreenshotsIfNeeded
|
||||
import eu.qsfera.android.extensions.collectLatestLifecycleFlow
|
||||
import eu.qsfera.android.extensions.showErrorInSnackbar
|
||||
import eu.qsfera.android.presentation.authentication.AccountUtils
|
||||
import eu.qsfera.android.presentation.common.UIResult
|
||||
import eu.qsfera.android.ui.activity.FileActivity
|
||||
import eu.qsfera.android.ui.activity.FileDisplayActivity
|
||||
import eu.qsfera.android.ui.activity.ToolbarActivity
|
||||
import eu.qsfera.android.utils.PreferenceUtils
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import timber.log.Timber
|
||||
|
||||
class ManageAccountsDialogFragment : DialogFragment(), ManageAccountsAdapter.AccountAdapterListener {
|
||||
|
||||
private lateinit var accountListAdapter: ManageAccountsAdapter
|
||||
private var currentAccount: Account? = null
|
||||
|
||||
private lateinit var dialogView: View
|
||||
private lateinit var parentActivity: ToolbarActivity
|
||||
private lateinit var recyclerView: RecyclerView
|
||||
|
||||
private val manageAccountsViewModel: ManageAccountsViewModel by viewModel()
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
parentActivity = requireActivity() as ToolbarActivity
|
||||
currentAccount = requireArguments().getParcelable(KEY_CURRENT_ACCOUNT)
|
||||
|
||||
subscribeToViewModels()
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val builder = AlertDialog.Builder(ContextThemeWrapper(requireContext(), R.style.Theme_AppCompat_Dialog_Alert))
|
||||
val inflater = this.layoutInflater
|
||||
dialogView = inflater.inflate(R.layout.manage_accounts_dialog, null)
|
||||
builder.setView(dialogView)
|
||||
|
||||
recyclerView = dialogView.findViewById(R.id.account_list_recycler_view)
|
||||
recyclerView.apply {
|
||||
filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(requireContext())
|
||||
layoutManager = LinearLayoutManager(requireContext())
|
||||
}
|
||||
|
||||
accountListAdapter = ManageAccountsAdapter(this)
|
||||
|
||||
val closeButton = dialogView.findViewById<ImageView>(R.id.cross)
|
||||
closeButton.setOnClickListener {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
val dialog = builder.create()
|
||||
dialog.window?.setBackgroundDrawableResource(R.color.transparent)
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
override fun removeAccount(account: Account) {
|
||||
dialogView.isVisible = false
|
||||
val hasAccountAttachedCameraUploads = manageAccountsViewModel.hasAutomaticUploadsAttached(account.name)
|
||||
val dialog = AlertDialog.Builder(requireContext())
|
||||
.setMessage(getString(
|
||||
if (hasAccountAttachedCameraUploads) R.string.confirmation_remove_account_alert_camera_uploads
|
||||
else R.string.confirmation_remove_account_alert, account.name)
|
||||
)
|
||||
.setPositiveButton(getString(R.string.common_yes)) { _, _ ->
|
||||
val accountManager = AccountManager.get(MainApp.appContext)
|
||||
accountManager.removeAccount(account, null, null)
|
||||
if (manageAccountsViewModel.getLoggedAccounts().size > 1) {
|
||||
dialogView.isVisible = true
|
||||
}
|
||||
}
|
||||
.setNegativeButton(getString(R.string.common_no)) { _, _ ->
|
||||
dialogView.isVisible = true
|
||||
}
|
||||
.setOnDismissListener {
|
||||
dialogView.isVisible = true
|
||||
}
|
||||
.create()
|
||||
dialog.avoidScreenshotsIfNeeded()
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
override fun cleanAccountLocalStorage(account: Account) {
|
||||
dialogView.isVisible = false
|
||||
val dialog = AlertDialog.Builder(requireContext())
|
||||
.setTitle(getString(R.string.clean_data_account_title))
|
||||
.setIcon(R.drawable.ic_warning)
|
||||
.setMessage(getString(R.string.clean_data_account_message, account.name))
|
||||
.setPositiveButton(getString(R.string.clean_data_account_button_yes)) { _, _ ->
|
||||
dialogView.isVisible = true
|
||||
manageAccountsViewModel.cleanAccountLocalStorage(account.name)
|
||||
}
|
||||
.setNegativeButton(R.string.drawer_close) { _, _ ->
|
||||
dialogView.isVisible = true
|
||||
}
|
||||
.setOnDismissListener {
|
||||
dialogView.isVisible = true
|
||||
}
|
||||
.create()
|
||||
dialog.avoidScreenshotsIfNeeded()
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
override fun createAccount() {
|
||||
val accountManager = AccountManager.get(MainApp.appContext)
|
||||
accountManager.addAccount(
|
||||
MainApp.accountType,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
parentActivity,
|
||||
{ future: AccountManagerFuture<Bundle>? ->
|
||||
if (future != null) {
|
||||
try {
|
||||
val result = future.result
|
||||
val name = result.getString(AccountManager.KEY_ACCOUNT_NAME)
|
||||
val newAccount = AccountUtils.getQSferaAccountByName(parentActivity.applicationContext, name)
|
||||
changeToAccountContext(newAccount)
|
||||
} catch (e: OperationCanceledException) {
|
||||
Timber.e(e, "Account creation canceled")
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Account creation finished in exception")
|
||||
}
|
||||
}
|
||||
},
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch current account to that contained in the received position of the list adapter.
|
||||
*
|
||||
* @param position A position of the account adapter containing an account.
|
||||
*/
|
||||
override fun switchAccount(position: Int) {
|
||||
val clickedAccount: Account = (accountListAdapter.getItem(position) as ManageAccountsAdapter.AccountRecyclerItem.AccountItem).account
|
||||
if (currentAccount?.name == clickedAccount.name) {
|
||||
// current account selected, just go back
|
||||
dismiss()
|
||||
} else {
|
||||
// restart list of files with new account
|
||||
parentActivity.showLoadingDialog(R.string.common_loading)
|
||||
dismiss()
|
||||
changeToAccountContext(clickedAccount)
|
||||
}
|
||||
}
|
||||
|
||||
private fun changeToAccountContext(account: Account) {
|
||||
AccountUtils.setCurrentQSferaAccount(
|
||||
parentActivity.applicationContext,
|
||||
account.name
|
||||
)
|
||||
parentActivity.account = account
|
||||
// Refresh dependencies to be used in selected account
|
||||
MainApp.initDependencyInjection()
|
||||
val i = Intent(
|
||||
parentActivity.applicationContext,
|
||||
FileDisplayActivity::class.java
|
||||
)
|
||||
i.putExtra(FileActivity.EXTRA_ACCOUNT, account)
|
||||
i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
parentActivity.startActivity(i)
|
||||
}
|
||||
|
||||
private fun subscribeToViewModels() {
|
||||
collectLatestLifecycleFlow(manageAccountsViewModel.cleanAccountLocalStorageFlow) { event ->
|
||||
event?.peekContent()?.let { uiResult ->
|
||||
when (uiResult) {
|
||||
is UIResult.Loading -> {
|
||||
parentActivity.showLoadingDialog(R.string.common_loading)
|
||||
dialogView.isVisible = false
|
||||
}
|
||||
is UIResult.Success -> {
|
||||
parentActivity.dismissLoadingDialog()
|
||||
dialogView.isVisible = true
|
||||
}
|
||||
is UIResult.Error -> {
|
||||
parentActivity.dismissLoadingDialog()
|
||||
showErrorInSnackbar(R.string.common_error_unknown, uiResult.error)
|
||||
Timber.e(uiResult.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collectLatestLifecycleFlow(manageAccountsViewModel.userQuotas) { listUserQuotas ->
|
||||
if (listUserQuotas.isNotEmpty()) {
|
||||
manageAccountsViewModel.getCurrentAccount()?.let {
|
||||
if (currentAccount != it) {
|
||||
parentActivity.showLoadingDialog(R.string.common_loading)
|
||||
dismiss()
|
||||
changeToAccountContext(it)
|
||||
}
|
||||
}
|
||||
// hide the progress bar and show manage accounts dialog
|
||||
val indeterminateProgressBar = dialogView.findViewById<ProgressBar>(R.id.indeterminate_progress_bar)
|
||||
indeterminateProgressBar.visibility = View.GONE
|
||||
val manageAccountsLayout = dialogView.findViewById<LinearLayout>(R.id.manage_accounts_layout)
|
||||
manageAccountsLayout.visibility = View.VISIBLE
|
||||
|
||||
accountListAdapter.submitAccountList(accountList = getAccountListItems(listUserQuotas))
|
||||
|
||||
recyclerView.adapter = accountListAdapter
|
||||
} else {
|
||||
createAccount()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* creates the account list items list including the add-account action in case multiaccount_support is enabled.
|
||||
*
|
||||
* @return list of account list items
|
||||
*/
|
||||
private fun getAccountListItems(userQuotasList: List<UserQuota>): List<ManageAccountsAdapter.AccountRecyclerItem> {
|
||||
val accountList = manageAccountsViewModel.getLoggedAccounts()
|
||||
val provisionalAccountList = mutableListOf<ManageAccountsAdapter.AccountRecyclerItem>()
|
||||
accountList.forEach { account ->
|
||||
val userQuota = userQuotasList.firstOrNull { userQuota -> userQuota.accountName == account.name }
|
||||
if (userQuota != null) {
|
||||
provisionalAccountList.add(ManageAccountsAdapter.AccountRecyclerItem.AccountItem(account, userQuota))
|
||||
}
|
||||
}
|
||||
|
||||
// Add Create Account item at the end of account list if multi-account is enabled
|
||||
if (resources.getBoolean(R.bool.multiaccount_support) || accountList.isEmpty()) {
|
||||
provisionalAccountList.add(ManageAccountsAdapter.AccountRecyclerItem.NewAccount)
|
||||
}
|
||||
return provisionalAccountList
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MANAGE_ACCOUNTS_DIALOG = "MANAGE_ACCOUNTS_DIALOG"
|
||||
const val KEY_CURRENT_ACCOUNT = "KEY_CURRENT_ACCOUNT"
|
||||
|
||||
fun newInstance(currentAccount: Account?): ManageAccountsDialogFragment {
|
||||
val args = Bundle().apply {
|
||||
putParcelable(KEY_CURRENT_ACCOUNT, currentAccount)
|
||||
}
|
||||
return ManageAccountsDialogFragment().apply { arguments = args }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Javier Rodríguez Pérez
|
||||
* @author Aitor Ballesteros Pavón
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
* @author Jorge Aguado Recio
|
||||
*
|
||||
* Copyright (C) 2024 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.presentation.accounts
|
||||
|
||||
import android.accounts.Account
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import eu.qsfera.android.domain.user.model.UserQuota
|
||||
import eu.qsfera.android.domain.automaticuploads.model.AutomaticUploadsConfiguration
|
||||
import eu.qsfera.android.domain.automaticuploads.usecases.GetAutomaticUploadsConfigurationUseCase
|
||||
import eu.qsfera.android.domain.user.usecases.GetStoredQuotaUseCase
|
||||
import eu.qsfera.android.domain.user.usecases.GetUserQuotasAsStreamUseCase
|
||||
import eu.qsfera.android.domain.utils.Event
|
||||
import eu.qsfera.android.extensions.ViewModelExt.runUseCaseWithResult
|
||||
import eu.qsfera.android.presentation.common.UIResult
|
||||
import eu.qsfera.android.providers.AccountProvider
|
||||
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
|
||||
import eu.qsfera.android.usecases.files.RemoveLocalFilesForAccountUseCase
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ManageAccountsViewModel(
|
||||
private val accountProvider: AccountProvider,
|
||||
private val removeLocalFilesForAccountUseCase: RemoveLocalFilesForAccountUseCase,
|
||||
private val getAutomaticUploadsConfigurationUseCase: GetAutomaticUploadsConfigurationUseCase,
|
||||
private val getStoredQuotaUseCase: GetStoredQuotaUseCase,
|
||||
getUserQuotasAsStreamUseCase: GetUserQuotasAsStreamUseCase,
|
||||
private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _cleanAccountLocalStorageFlow = MutableStateFlow<Event<UIResult<Unit>>?>(null)
|
||||
val cleanAccountLocalStorageFlow: StateFlow<Event<UIResult<Unit>>?> = _cleanAccountLocalStorageFlow
|
||||
|
||||
val userQuotas: Flow<List<UserQuota>> = getUserQuotasAsStreamUseCase(Unit)
|
||||
|
||||
private var automaticUploadsConfiguration: AutomaticUploadsConfiguration? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch(coroutinesDispatcherProvider.io) {
|
||||
automaticUploadsConfiguration = getAutomaticUploadsConfigurationUseCase(Unit).getDataOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
fun getLoggedAccounts(): Array<Account> =
|
||||
accountProvider.getLoggedAccounts()
|
||||
|
||||
fun getCurrentAccount(): Account? =
|
||||
accountProvider.getCurrentQSferaAccount()
|
||||
|
||||
fun cleanAccountLocalStorage(accountName: String) {
|
||||
runUseCaseWithResult(
|
||||
coroutineDispatcher = coroutinesDispatcherProvider.io,
|
||||
showLoading = true,
|
||||
flow = _cleanAccountLocalStorageFlow,
|
||||
useCase = removeLocalFilesForAccountUseCase,
|
||||
useCaseParams = RemoveLocalFilesForAccountUseCase.Params(accountName),
|
||||
)
|
||||
}
|
||||
|
||||
fun hasAutomaticUploadsAttached(accountName: String): Boolean =
|
||||
accountName == automaticUploadsConfiguration?.pictureUploadsConfiguration?.accountName ||
|
||||
accountName == automaticUploadsConfiguration?.videoUploadsConfiguration?.accountName
|
||||
|
||||
fun checkUserLight(accountName: String): Boolean = runBlocking(CoroutinesDispatcherProvider().io) {
|
||||
val quota = withContext(CoroutinesDispatcherProvider().io) {
|
||||
getStoredQuotaUseCase(GetStoredQuotaUseCase.Params(accountName))
|
||||
}
|
||||
quota.getDataOrNull()?.available == -4L
|
||||
}
|
||||
}
|
||||
+472
@@ -0,0 +1,472 @@
|
||||
/*
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David A. Velasco
|
||||
* @author Christian Schabesberger
|
||||
* @author David González Verdugo
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
*
|
||||
* Copyright (C) 2012 Bartek Przybylski
|
||||
* Copyright (C) 2024 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.presentation.authentication;
|
||||
|
||||
import android.accounts.AbstractAccountAuthenticator;
|
||||
import android.accounts.Account;
|
||||
import android.accounts.AccountAuthenticatorResponse;
|
||||
import android.accounts.AccountManager;
|
||||
import android.accounts.NetworkErrorException;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
|
||||
import eu.qsfera.android.MainApp;
|
||||
import eu.qsfera.android.R;
|
||||
import eu.qsfera.android.presentation.authentication.oauth.OAuthUtils;
|
||||
import eu.qsfera.android.domain.UseCaseResult;
|
||||
import eu.qsfera.android.domain.authentication.oauth.OIDCDiscoveryUseCase;
|
||||
import eu.qsfera.android.domain.authentication.oauth.RequestTokenUseCase;
|
||||
import eu.qsfera.android.domain.authentication.oauth.model.OIDCServerConfiguration;
|
||||
import eu.qsfera.android.domain.authentication.oauth.model.TokenRequest;
|
||||
import eu.qsfera.android.domain.authentication.oauth.model.TokenResponse;
|
||||
import eu.qsfera.android.lib.common.accounts.AccountTypeUtils;
|
||||
import eu.qsfera.android.lib.common.accounts.AccountUtils;
|
||||
import kotlin.Lazy;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import timber.log.Timber;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
import static eu.qsfera.android.data.authentication.AuthenticationConstantsKt.KEY_CLIENT_REGISTRATION_CLIENT_EXPIRATION_DATE;
|
||||
import static eu.qsfera.android.data.authentication.AuthenticationConstantsKt.KEY_CLIENT_REGISTRATION_CLIENT_ID;
|
||||
import static eu.qsfera.android.data.authentication.AuthenticationConstantsKt.KEY_CLIENT_REGISTRATION_CLIENT_SECRET;
|
||||
import static eu.qsfera.android.data.authentication.AuthenticationConstantsKt.KEY_OAUTH2_REFRESH_TOKEN;
|
||||
import static eu.qsfera.android.data.authentication.AuthenticationConstantsKt.KEY_OAUTH2_SCOPE;
|
||||
import static eu.qsfera.android.data.authentication.AuthenticationConstantsKt.KEY_OIDC_ISSUER;
|
||||
import static eu.qsfera.android.presentation.authentication.AuthenticatorConstants.KEY_AUTH_TOKEN_TYPE;
|
||||
import static org.koin.java.KoinJavaComponent.inject;
|
||||
|
||||
/**
|
||||
* Authenticator for qsfera accounts.
|
||||
*
|
||||
* Controller class accessed from the system AccountManager, providing integration of qsfera accounts with the
|
||||
* Android system.
|
||||
*/
|
||||
public class AccountAuthenticator extends AbstractAccountAuthenticator {
|
||||
|
||||
/**
|
||||
* Is used by android system to assign accounts to authenticators. Should be
|
||||
* used by application and all extensions.
|
||||
*/
|
||||
private static final String KEY_REQUIRED_FEATURES = "requiredFeatures";
|
||||
public static final String KEY_ACCOUNT = "account";
|
||||
|
||||
private Context mContext;
|
||||
|
||||
AccountAuthenticator(Context context) {
|
||||
super(context);
|
||||
mContext = context;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public Bundle addAccount(AccountAuthenticatorResponse response,
|
||||
String accountType, String authTokenType,
|
||||
String[] requiredFeatures, Bundle options) {
|
||||
Timber.i("Adding account with type " + accountType + " and auth token " + authTokenType);
|
||||
|
||||
final Bundle bundle = new Bundle();
|
||||
|
||||
AccountManager accountManager = AccountManager.get(mContext);
|
||||
Account[] accounts = accountManager.getAccountsByType(MainApp.Companion.getAccountType());
|
||||
|
||||
if (mContext.getResources().getBoolean(R.bool.multiaccount_support) || accounts.length < 1) {
|
||||
try {
|
||||
validateAccountType(accountType);
|
||||
} catch (AuthenticatorException e) {
|
||||
Timber.e(e, "Failed to validate account type %s", accountType);
|
||||
return e.getFailureBundle();
|
||||
}
|
||||
|
||||
final Intent intent = new Intent(mContext, LoginActivity.class);
|
||||
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);
|
||||
intent.putExtra(KEY_AUTH_TOKEN_TYPE, authTokenType);
|
||||
intent.putExtra(KEY_REQUIRED_FEATURES, requiredFeatures);
|
||||
intent.putExtra(AuthenticatorConstants.EXTRA_ACTION, AuthenticatorConstants.ACTION_CREATE);
|
||||
|
||||
setIntentFlags(intent);
|
||||
|
||||
bundle.putParcelable(AccountManager.KEY_INTENT, intent);
|
||||
}
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public Bundle confirmCredentials(AccountAuthenticatorResponse response,
|
||||
Account account, Bundle options) {
|
||||
try {
|
||||
validateAccountType(account.type);
|
||||
} catch (AuthenticatorException e) {
|
||||
Timber.e(e, "Failed to validate account type %s", account.type);
|
||||
return e.getFailureBundle();
|
||||
}
|
||||
Intent intent = new Intent(mContext, LoginActivity.class);
|
||||
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE,
|
||||
response);
|
||||
intent.putExtra(KEY_ACCOUNT, account);
|
||||
|
||||
setIntentFlags(intent);
|
||||
|
||||
Bundle resultBundle = new Bundle();
|
||||
resultBundle.putParcelable(AccountManager.KEY_INTENT, intent);
|
||||
return resultBundle;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Bundle editProperties(AccountAuthenticatorResponse response,
|
||||
String accountType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public Bundle getAuthToken(AccountAuthenticatorResponse accountAuthenticatorResponse,
|
||||
Account account, String authTokenType, Bundle options) {
|
||||
/// validate parameters
|
||||
try {
|
||||
validateAccountType(account.type);
|
||||
validateAuthTokenType(authTokenType);
|
||||
} catch (AuthenticatorException e) {
|
||||
Timber.e(e, "Failed to validate account type %s", account.type);
|
||||
return e.getFailureBundle();
|
||||
}
|
||||
|
||||
String accessToken;
|
||||
|
||||
/// check if required token is stored
|
||||
final AccountManager accountManager = AccountManager.get(mContext);
|
||||
if (authTokenType.equals(AccountTypeUtils.getAuthTokenTypePass(MainApp.Companion.getAccountType()))) {
|
||||
// Basic
|
||||
accessToken = accountManager.getPassword(account);
|
||||
} else {
|
||||
// OAuth, gets an auth token from the AccountManager's cache. If no auth token is cached for
|
||||
// this account, null will be returned
|
||||
accessToken = accountManager.peekAuthToken(account, authTokenType);
|
||||
if (accessToken == null && canBeRefreshed(authTokenType) && clientSecretIsValid(accountManager, account)) {
|
||||
accessToken = refreshToken(account, authTokenType, accountManager);
|
||||
}
|
||||
}
|
||||
|
||||
if (accessToken != null) {
|
||||
final Bundle result = new Bundle();
|
||||
result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name);
|
||||
result.putString(AccountManager.KEY_ACCOUNT_TYPE, MainApp.Companion.getAccountType());
|
||||
result.putString(AccountManager.KEY_AUTHTOKEN, accessToken);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// if not stored, return Intent to access the LoginActivity and UPDATE the token for the account
|
||||
return prepareBundleToAccessLoginActivity(accountAuthenticatorResponse, account, authTokenType, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the client has expired or not.
|
||||
* If the client has expired, we can not refresh the token and user needs to re-authenticate.
|
||||
*
|
||||
* @return true if the client is still valid
|
||||
*/
|
||||
private boolean clientSecretIsValid(AccountManager accountManager, Account account) {
|
||||
String clientSecretExpiration = accountManager.getUserData(account,
|
||||
KEY_CLIENT_REGISTRATION_CLIENT_EXPIRATION_DATE);
|
||||
|
||||
Timber.d("Client secret expiration [" + clientSecretExpiration + "]");
|
||||
if (clientSecretExpiration == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
long currentTimeStamp = System.currentTimeMillis() / 1000L;
|
||||
int clientSecretExpirationInt = Integer.parseInt(clientSecretExpiration);
|
||||
boolean clientSecretIsValid = clientSecretExpirationInt == 0 || clientSecretExpirationInt > currentTimeStamp;
|
||||
|
||||
Timber.d("Current time [" + currentTimeStamp + "]");
|
||||
Timber.d("Client is valid [" + clientSecretIsValid + "]");
|
||||
|
||||
return clientSecretIsValid;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAuthTokenLabel(String authTokenType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Bundle hasFeatures(AccountAuthenticatorResponse response,
|
||||
Account account, String[] features) {
|
||||
final Bundle result = new Bundle();
|
||||
result.putBoolean(AccountManager.KEY_BOOLEAN_RESULT, true);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Bundle updateCredentials(AccountAuthenticatorResponse response,
|
||||
Account account, String authTokenType, Bundle options) {
|
||||
final Intent intent = new Intent(mContext, LoginActivity.class);
|
||||
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE,
|
||||
response);
|
||||
intent.putExtra(KEY_ACCOUNT, account);
|
||||
intent.putExtra(KEY_AUTH_TOKEN_TYPE, authTokenType);
|
||||
setIntentFlags(intent);
|
||||
|
||||
final Bundle bundle = new Bundle();
|
||||
bundle.putParcelable(AccountManager.KEY_INTENT, intent);
|
||||
return bundle;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Bundle getAccountRemovalAllowed(
|
||||
AccountAuthenticatorResponse response, Account account)
|
||||
throws NetworkErrorException {
|
||||
return super.getAccountRemovalAllowed(response, account);
|
||||
}
|
||||
|
||||
private void setIntentFlags(Intent intent) {
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
|
||||
intent.addFlags(Intent.FLAG_FROM_BACKGROUND);
|
||||
}
|
||||
|
||||
private void validateAccountType(String type)
|
||||
throws UnsupportedAccountTypeException {
|
||||
if (!type.equals(MainApp.Companion.getAccountType())) {
|
||||
throw new UnsupportedAccountTypeException();
|
||||
}
|
||||
}
|
||||
|
||||
private void validateAuthTokenType(String authTokenType)
|
||||
throws UnsupportedAuthTokenTypeException {
|
||||
if (!authTokenType.equals(MainApp.Companion.getAuthTokenType()) &&
|
||||
!authTokenType.equals(AccountTypeUtils.getAuthTokenTypePass(MainApp.Companion.getAccountType())) &&
|
||||
!authTokenType.equals(AccountTypeUtils.getAuthTokenTypeAccessToken(MainApp.Companion.getAccountType())) &&
|
||||
!authTokenType.equals(AccountTypeUtils.getAuthTokenTypeRefreshToken(MainApp.Companion.getAccountType()))
|
||||
) {
|
||||
throw new UnsupportedAuthTokenTypeException();
|
||||
}
|
||||
}
|
||||
|
||||
public static class AuthenticatorException extends Exception {
|
||||
private static final long serialVersionUID = 1L;
|
||||
private Bundle mFailureBundle;
|
||||
|
||||
AuthenticatorException(int code, String errorMsg) {
|
||||
mFailureBundle = new Bundle();
|
||||
mFailureBundle.putInt(AccountManager.KEY_ERROR_CODE, code);
|
||||
mFailureBundle
|
||||
.putString(AccountManager.KEY_ERROR_MESSAGE, errorMsg);
|
||||
}
|
||||
|
||||
Bundle getFailureBundle() {
|
||||
return mFailureBundle;
|
||||
}
|
||||
}
|
||||
|
||||
public static class UnsupportedAccountTypeException extends
|
||||
AuthenticatorException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
UnsupportedAccountTypeException() {
|
||||
super(AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION,
|
||||
"Unsupported account type");
|
||||
}
|
||||
}
|
||||
|
||||
public static class UnsupportedAuthTokenTypeException extends
|
||||
AuthenticatorException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
UnsupportedAuthTokenTypeException() {
|
||||
super(AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION,
|
||||
"Unsupported auth token type");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean canBeRefreshed(String authTokenType) {
|
||||
return (authTokenType.equals(AccountTypeUtils.getAuthTokenTypeAccessToken(MainApp.Companion.
|
||||
getAccountType())));
|
||||
}
|
||||
|
||||
private String refreshToken(
|
||||
Account account,
|
||||
String authTokenType,
|
||||
AccountManager accountManager
|
||||
) {
|
||||
|
||||
// Prepare everything to perform the token request
|
||||
String refreshToken = accountManager.getUserData(account, KEY_OAUTH2_REFRESH_TOKEN);
|
||||
|
||||
if (refreshToken == null || refreshToken.isEmpty()) {
|
||||
Timber.w("No refresh token stored for silent renewal of access token");
|
||||
return null;
|
||||
}
|
||||
|
||||
Timber.d("Ready to exchange for new tokens. Account: [ %s ], Refresh token: [ %s ]", account.name,
|
||||
refreshToken);
|
||||
|
||||
String baseUrl = accountManager.getUserData(account, AccountUtils.Constants.KEY_OC_BASE_URL);
|
||||
|
||||
// OIDC Discovery: prefer the stored webfinger issuer (points to the real IDP),
|
||||
// fall back to baseUrl for accounts created before webfinger support.
|
||||
String oidcIssuer = accountManager.getUserData(account, KEY_OIDC_ISSUER);
|
||||
String discoveryUrl = (oidcIssuer != null) ? oidcIssuer : baseUrl;
|
||||
|
||||
@NotNull Lazy<OIDCDiscoveryUseCase> oidcDiscoveryUseCase = inject(OIDCDiscoveryUseCase.class);
|
||||
OIDCDiscoveryUseCase.Params oidcDiscoveryUseCaseParams = new OIDCDiscoveryUseCase.Params(discoveryUrl);
|
||||
UseCaseResult<OIDCServerConfiguration> oidcServerConfigurationUseCaseResult =
|
||||
oidcDiscoveryUseCase.getValue().invoke(oidcDiscoveryUseCaseParams);
|
||||
|
||||
String tokenEndpoint;
|
||||
|
||||
String clientId = accountManager.getUserData(account, KEY_CLIENT_REGISTRATION_CLIENT_ID);
|
||||
String clientSecret = accountManager.getUserData(account, KEY_CLIENT_REGISTRATION_CLIENT_SECRET);
|
||||
|
||||
String clientIdForRequest = null;
|
||||
String clientSecretForRequest = null;
|
||||
String clientAuth;
|
||||
|
||||
if (clientId == null) {
|
||||
Timber.d("Client Id not stored. Let's use the hardcoded one");
|
||||
clientId = mContext.getString(R.string.oauth2_client_id);
|
||||
}
|
||||
if (clientSecret == null) {
|
||||
Timber.d("Client Secret not stored. Let's use the hardcoded one");
|
||||
clientSecret = mContext.getString(R.string.oauth2_client_secret);
|
||||
}
|
||||
|
||||
if (oidcServerConfigurationUseCaseResult.isSuccess()) {
|
||||
Timber.d("OIDC Discovery success. Server discovery info: [ %s ]",
|
||||
oidcServerConfigurationUseCaseResult.getDataOrNull());
|
||||
|
||||
// Use token endpoint retrieved from oidc discovery
|
||||
tokenEndpoint = oidcServerConfigurationUseCaseResult.getDataOrNull().getTokenEndpoint();
|
||||
|
||||
// RFC 7636: Public clients (token_endpoint_auth_method: none) must not send Authorization header
|
||||
if (oidcServerConfigurationUseCaseResult.getDataOrNull() != null &&
|
||||
oidcServerConfigurationUseCaseResult.getDataOrNull().isTokenEndpointAuthMethodNone()) {
|
||||
clientAuth = "";
|
||||
clientIdForRequest = clientId;
|
||||
} else if (oidcServerConfigurationUseCaseResult.getDataOrNull() != null &&
|
||||
oidcServerConfigurationUseCaseResult.getDataOrNull().isTokenEndpointAuthMethodSupportedClientSecretPost()) {
|
||||
// For client_secret_post, credentials go in body, not Authorization header
|
||||
clientAuth = "";
|
||||
clientIdForRequest = clientId;
|
||||
clientSecretForRequest = clientSecret;
|
||||
} else {
|
||||
// For other methods (e.g., client_secret_basic), use Basic auth header
|
||||
clientAuth = OAuthUtils.Companion.getClientAuth(clientSecret, clientId);
|
||||
}
|
||||
} else {
|
||||
Timber.d("OIDC Discovery failed. Server discovery info: [ %s ]",
|
||||
oidcServerConfigurationUseCaseResult.getThrowableOrNull().toString());
|
||||
|
||||
tokenEndpoint = baseUrl + File.separator + mContext.getString(R.string.oauth2_url_endpoint_access);
|
||||
clientAuth = OAuthUtils.Companion.getClientAuth(clientSecret, clientId);
|
||||
}
|
||||
|
||||
String scope = accountManager.getUserData(account, KEY_OAUTH2_SCOPE);
|
||||
if (scope == null) {
|
||||
scope = mContext.getResources().getString(R.string.oauth2_openid_scope);
|
||||
}
|
||||
|
||||
TokenRequest oauthTokenRequest = new TokenRequest.RefreshToken(
|
||||
baseUrl,
|
||||
tokenEndpoint,
|
||||
clientAuth,
|
||||
scope,
|
||||
clientIdForRequest,
|
||||
clientSecretForRequest,
|
||||
refreshToken
|
||||
);
|
||||
|
||||
// Token exchange
|
||||
@NotNull Lazy<RequestTokenUseCase> requestTokenUseCase = inject(RequestTokenUseCase.class);
|
||||
RequestTokenUseCase.Params requestTokenParams = new RequestTokenUseCase.Params(oauthTokenRequest);
|
||||
UseCaseResult<TokenResponse> tokenResponseResult = requestTokenUseCase.getValue().invoke(requestTokenParams);
|
||||
|
||||
TokenResponse safeTokenResponse = tokenResponseResult.getDataOrNull();
|
||||
if (safeTokenResponse != null) {
|
||||
return handleSuccessfulRefreshToken(safeTokenResponse,
|
||||
account, authTokenType, accountManager, refreshToken);
|
||||
} else {
|
||||
Timber.e(tokenResponseResult.getThrowableOrNull(), "OAuth request to refresh access token failed. Preparing to access Login Activity");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String handleSuccessfulRefreshToken(
|
||||
TokenResponse tokenResponse,
|
||||
Account account,
|
||||
String authTokenType,
|
||||
AccountManager accountManager,
|
||||
String oldRefreshToken
|
||||
) {
|
||||
String newAccessToken = tokenResponse.getAccessToken();
|
||||
accountManager.setAuthToken(account, authTokenType, newAccessToken);
|
||||
|
||||
String refreshTokenToUseFromNowOn;
|
||||
if (tokenResponse.getRefreshToken() != null) {
|
||||
refreshTokenToUseFromNowOn = tokenResponse.getRefreshToken();
|
||||
} else {
|
||||
refreshTokenToUseFromNowOn = oldRefreshToken;
|
||||
}
|
||||
accountManager.setUserData(account, KEY_OAUTH2_REFRESH_TOKEN, refreshTokenToUseFromNowOn);
|
||||
|
||||
Timber.d("Token refreshed successfully. New access token: [ %s ]. New refresh token: [ %s ]",
|
||||
newAccessToken, refreshTokenToUseFromNowOn);
|
||||
|
||||
return newAccessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return bundle with intent to access LoginActivity and UPDATE the token for the account
|
||||
*/
|
||||
private Bundle prepareBundleToAccessLoginActivity(
|
||||
AccountAuthenticatorResponse accountAuthenticatorResponse,
|
||||
Account account,
|
||||
String authTokenType,
|
||||
Bundle options
|
||||
) {
|
||||
final Intent intent = new Intent(mContext, LoginActivity.class);
|
||||
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE,
|
||||
accountAuthenticatorResponse);
|
||||
intent.putExtra(KEY_AUTH_TOKEN_TYPE, authTokenType);
|
||||
intent.putExtra(AuthenticatorConstants.EXTRA_ACCOUNT, account);
|
||||
intent.putExtra(
|
||||
AuthenticatorConstants.EXTRA_ACTION,
|
||||
AuthenticatorConstants.ACTION_UPDATE_EXPIRED_TOKEN
|
||||
);
|
||||
|
||||
final Bundle bundle = new Bundle();
|
||||
bundle.putParcelable(AccountManager.KEY_INTENT, intent);
|
||||
return bundle;
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
* <p>
|
||||
* Copyright (C) 2011 Bartek Przybylski
|
||||
* Copyright (C) 2016 ownCloud GmbH.
|
||||
* <p>
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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 eu.qsfera.android.presentation.authentication;
|
||||
|
||||
import android.app.Service;
|
||||
import android.content.Intent;
|
||||
import android.os.IBinder;
|
||||
|
||||
public class AccountAuthenticatorService extends Service {
|
||||
|
||||
private AccountAuthenticator mAuthenticator;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
mAuthenticator = new AccountAuthenticator(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return mAuthenticator.getIBinder();
|
||||
}
|
||||
|
||||
}
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
* <p>
|
||||
* @author Aitor Ballesteros Pavón
|
||||
* <p>
|
||||
* Copyright (C) 2012 Bartek Przybylski
|
||||
* Copyright (C) 2024 ownCloud GmbH.
|
||||
* <p>
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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 eu.qsfera.android.presentation.authentication;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.accounts.AccountManager;
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.Uri;
|
||||
import android.preference.PreferenceManager;
|
||||
|
||||
import eu.qsfera.android.MainApp;
|
||||
import eu.qsfera.android.domain.capabilities.model.OCCapability;
|
||||
import eu.qsfera.android.lib.common.accounts.AccountUtils.Constants;
|
||||
import timber.log.Timber;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import static eu.qsfera.android.data.authentication.AuthenticationConstantsKt.KEY_FEATURE_ALLOWED;
|
||||
import static eu.qsfera.android.data.authentication.AuthenticationConstantsKt.KEY_FEATURE_SPACES;
|
||||
import static eu.qsfera.android.data.authentication.AuthenticationConstantsKt.SELECTED_ACCOUNT;
|
||||
import static eu.qsfera.android.lib.common.accounts.AccountUtils.Constants.OAUTH_SUPPORTED_TRUE;
|
||||
|
||||
public class AccountUtils {
|
||||
|
||||
private static final int ACCOUNT_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Can be used to get the currently selected qsfera {@link Account} in the
|
||||
* application preferences.
|
||||
*
|
||||
* @param context The current application {@link Context}
|
||||
* @return The qsfera {@link Account} currently saved in preferences, or the first
|
||||
* {@link Account} available, if valid (still registered in the system as qsfera
|
||||
* account). If none is available and valid, returns null.
|
||||
*/
|
||||
public static Account getCurrentQSferaAccount(Context context) {
|
||||
Account[] ocAccounts = getAccounts(context);
|
||||
Account defaultAccount = null;
|
||||
|
||||
SharedPreferences appPreferences = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
String accountName = appPreferences.getString(SELECTED_ACCOUNT, null);
|
||||
|
||||
// account validation: the saved account MUST be in the list of qsfera Accounts known by the AccountManager
|
||||
if (accountName != null) {
|
||||
for (Account account : ocAccounts) {
|
||||
if (account.name.equals(accountName)) {
|
||||
defaultAccount = account;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (defaultAccount == null && ocAccounts.length != 0) {
|
||||
// take first account as fallback
|
||||
defaultAccount = ocAccounts[0];
|
||||
}
|
||||
|
||||
return defaultAccount;
|
||||
}
|
||||
|
||||
public static Account[] getAccounts(Context context) {
|
||||
AccountManager accountManager = AccountManager.get(context);
|
||||
return accountManager.getAccountsByType(MainApp.Companion.getAccountType());
|
||||
}
|
||||
|
||||
public static void deleteAccounts(Context context) {
|
||||
AccountManager accountManager = AccountManager.get(context);
|
||||
Account[] accounts = getAccounts(context);
|
||||
for (Account account : accounts) {
|
||||
accountManager.removeAccount(account, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean exists(String accountName, Context context) {
|
||||
Account[] ocAccounts = getAccounts(context);
|
||||
|
||||
if (accountName != null) {
|
||||
int lastAtPos = accountName.lastIndexOf("@");
|
||||
String hostAndPort = accountName.substring(lastAtPos + 1);
|
||||
String username = accountName.substring(0, lastAtPos);
|
||||
String otherHostAndPort, otherUsername;
|
||||
Locale currentLocale = context.getResources().getConfiguration().locale;
|
||||
for (Account otherAccount : ocAccounts) {
|
||||
lastAtPos = otherAccount.name.lastIndexOf("@");
|
||||
otherHostAndPort = otherAccount.name.substring(lastAtPos + 1);
|
||||
otherUsername = otherAccount.name.substring(0, lastAtPos);
|
||||
if (otherHostAndPort.equals(hostAndPort) &&
|
||||
otherUsername.toLowerCase(currentLocale).
|
||||
equals(username.toLowerCase(currentLocale))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* returns the user's name based on the account name.
|
||||
*
|
||||
* @param accountName the account name
|
||||
* @return the user's name
|
||||
*/
|
||||
public static String getUsernameOfAccount(String accountName) {
|
||||
if (accountName != null) {
|
||||
return accountName.substring(0, accountName.lastIndexOf("@"));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns qsfera account identified by accountName or null if it does not exist.
|
||||
*
|
||||
* @param context
|
||||
* @param accountName name of account to be returned
|
||||
* @return qsfera account named accountName
|
||||
*/
|
||||
public static Account getQSferaAccountByName(Context context, String accountName) {
|
||||
Account[] ocAccounts = AccountManager.get(context).getAccountsByType(
|
||||
MainApp.Companion.getAccountType());
|
||||
for (Account account : ocAccounts) {
|
||||
if (account.name.equals(accountName)) {
|
||||
return account;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static boolean isSpacesFeatureAllowedForAccount(Context context, Account account, OCCapability capability) {
|
||||
if (capability == null || !capability.isSpacesAllowed()) {
|
||||
return false;
|
||||
}
|
||||
AccountManager accountManager = AccountManager.get(context);
|
||||
String spacesFeatureValue = accountManager.getUserData(account, KEY_FEATURE_SPACES);
|
||||
return KEY_FEATURE_ALLOWED.equals(spacesFeatureValue);
|
||||
}
|
||||
|
||||
public static boolean setCurrentQSferaAccount(Context context, String accountName) {
|
||||
boolean result = false;
|
||||
if (accountName != null) {
|
||||
boolean found;
|
||||
for (Account account : getAccounts(context)) {
|
||||
found = (account.name.equals(accountName));
|
||||
if (found) {
|
||||
SharedPreferences.Editor appPrefs = PreferenceManager
|
||||
.getDefaultSharedPreferences(context).edit();
|
||||
appPrefs.putString(SELECTED_ACCOUNT, accountName);
|
||||
|
||||
appPrefs.apply();
|
||||
result = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the accounts in AccountManager to meet the current version of accounts expected by the app, if needed.
|
||||
* <p>
|
||||
* Introduced to handle a change in the structure of stored account names needed to allow different OC servers
|
||||
* in the same domain, but not in the same path.
|
||||
*
|
||||
* @param context Used to access the AccountManager.
|
||||
*/
|
||||
public static void updateAccountVersion(Context context) {
|
||||
Account currentAccount = AccountUtils.getCurrentQSferaAccount(context);
|
||||
AccountManager accountMgr = AccountManager.get(context);
|
||||
|
||||
if (currentAccount != null) {
|
||||
String currentAccountVersion = accountMgr.getUserData(currentAccount, Constants.KEY_OC_ACCOUNT_VERSION);
|
||||
|
||||
if (currentAccountVersion == null) {
|
||||
Timber.i("Upgrading accounts to account version #%s", ACCOUNT_VERSION);
|
||||
Account[] ocAccounts = accountMgr.getAccountsByType(MainApp.Companion.getAccountType());
|
||||
String serverUrl, username, newAccountName, password;
|
||||
Account newAccount;
|
||||
for (Account account : ocAccounts) {
|
||||
// build new account name
|
||||
serverUrl = accountMgr.getUserData(account, Constants.KEY_OC_BASE_URL);
|
||||
username = eu.qsfera.android.lib.common.accounts.AccountUtils.
|
||||
getUsernameForAccount(account);
|
||||
newAccountName = eu.qsfera.android.lib.common.accounts.AccountUtils.
|
||||
buildAccountName(Uri.parse(serverUrl), username);
|
||||
|
||||
// migrate to a new account, if needed
|
||||
if (!newAccountName.equals(account.name)) {
|
||||
Timber.d("Upgrading " + account.name + " to " + newAccountName);
|
||||
|
||||
// create the new account
|
||||
newAccount = new Account(newAccountName, MainApp.Companion.getAccountType());
|
||||
password = accountMgr.getPassword(account);
|
||||
accountMgr.addAccountExplicitly(newAccount, (password != null) ? password : "", null);
|
||||
|
||||
// copy base URL
|
||||
accountMgr.setUserData(newAccount, Constants.KEY_OC_BASE_URL, serverUrl);
|
||||
|
||||
String isOauthStr = accountMgr.getUserData(account, Constants.KEY_SUPPORTS_OAUTH2);
|
||||
boolean isOAuth = OAUTH_SUPPORTED_TRUE.equals(isOauthStr);
|
||||
if (isOAuth) {
|
||||
accountMgr.setUserData(newAccount, Constants.KEY_SUPPORTS_OAUTH2, OAUTH_SUPPORTED_TRUE);
|
||||
}
|
||||
|
||||
// don't forget the account saved in preferences as the current one
|
||||
if (currentAccount.name.equals(account.name)) {
|
||||
AccountUtils.setCurrentQSferaAccount(context, newAccountName);
|
||||
}
|
||||
|
||||
// remove the old account
|
||||
accountMgr.removeAccount(account, null, null);
|
||||
// will assume it succeeds, not a big deal otherwise
|
||||
|
||||
} else {
|
||||
// servers which base URL is in the root of their domain need no change
|
||||
Timber.d("%s needs no upgrade ", account.name);
|
||||
newAccount = account;
|
||||
}
|
||||
|
||||
// at least, upgrade account version
|
||||
Timber.d("Setting version " + ACCOUNT_VERSION + " to " + newAccountName);
|
||||
accountMgr.setUserData(
|
||||
newAccount, Constants.KEY_OC_ACCOUNT_VERSION, Integer.toString(ACCOUNT_VERSION)
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David González Verdugo
|
||||
* @author Abel García de Prada
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
*
|
||||
* Copyright (C) 2024 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.presentation.authentication
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import eu.qsfera.android.MainApp
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.domain.authentication.oauth.RegisterClientUseCase
|
||||
import eu.qsfera.android.domain.authentication.oauth.RequestTokenUseCase
|
||||
import eu.qsfera.android.domain.authentication.oauth.model.ClientRegistrationInfo
|
||||
import eu.qsfera.android.domain.authentication.oauth.model.TokenRequest
|
||||
import eu.qsfera.android.domain.authentication.oauth.model.TokenResponse
|
||||
import eu.qsfera.android.domain.authentication.usecases.GetBaseUrlUseCase
|
||||
import eu.qsfera.android.domain.authentication.usecases.LoginBasicAsyncUseCase
|
||||
import eu.qsfera.android.domain.authentication.usecases.LoginOAuthAsyncUseCase
|
||||
import eu.qsfera.android.domain.authentication.usecases.SupportsOAuth2UseCase
|
||||
import eu.qsfera.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase
|
||||
import eu.qsfera.android.domain.capabilities.usecases.RefreshCapabilitiesFromServerAsyncUseCase
|
||||
import eu.qsfera.android.domain.server.model.ServerInfo
|
||||
import eu.qsfera.android.domain.server.usecases.GetServerInfoAsyncUseCase
|
||||
import eu.qsfera.android.domain.spaces.usecases.RefreshSpacesFromServerAsyncUseCase
|
||||
import eu.qsfera.android.domain.utils.Event
|
||||
import eu.qsfera.android.domain.webfinger.usecases.GetQSferaInstanceFromWebFingerUseCase
|
||||
import eu.qsfera.android.domain.webfinger.usecases.GetQSferaInstancesFromAuthenticatedWebFingerUseCase
|
||||
import eu.qsfera.android.extensions.ViewModelExt.runUseCaseWithResult
|
||||
import eu.qsfera.android.presentation.authentication.oauth.OAuthUtils
|
||||
import eu.qsfera.android.presentation.common.UIResult
|
||||
import eu.qsfera.android.providers.ContextProvider
|
||||
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
|
||||
import eu.qsfera.android.providers.WorkManagerProvider
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
class AuthenticationViewModel(
|
||||
private val loginBasicAsyncUseCase: LoginBasicAsyncUseCase,
|
||||
private val loginOAuthAsyncUseCase: LoginOAuthAsyncUseCase,
|
||||
private val getServerInfoAsyncUseCase: GetServerInfoAsyncUseCase,
|
||||
private val supportsOAuth2UseCase: SupportsOAuth2UseCase,
|
||||
private val getBaseUrlUseCase: GetBaseUrlUseCase,
|
||||
private val getQSferaInstancesFromAuthenticatedWebFingerUseCase: GetQSferaInstancesFromAuthenticatedWebFingerUseCase,
|
||||
private val getQSferaInstanceFromWebFingerUseCase: GetQSferaInstanceFromWebFingerUseCase,
|
||||
private val refreshCapabilitiesFromServerAsyncUseCase: RefreshCapabilitiesFromServerAsyncUseCase,
|
||||
private val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase,
|
||||
private val refreshSpacesFromServerAsyncUseCase: RefreshSpacesFromServerAsyncUseCase,
|
||||
private val workManagerProvider: WorkManagerProvider,
|
||||
private val requestTokenUseCase: RequestTokenUseCase,
|
||||
private val registerClientUseCase: RegisterClientUseCase,
|
||||
private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider,
|
||||
private val contextProvider: ContextProvider,
|
||||
) : ViewModel() {
|
||||
|
||||
var codeVerifier: String = OAuthUtils().generateRandomCodeVerifier()
|
||||
var codeChallenge: String = OAuthUtils().generateCodeChallenge(codeVerifier)
|
||||
var oidcState: String = OAuthUtils().generateRandomState()
|
||||
|
||||
private val _legacyWebfingerHost = MediatorLiveData<Event<UIResult<String>>>()
|
||||
val legacyWebfingerHost: LiveData<Event<UIResult<String>>> = _legacyWebfingerHost
|
||||
|
||||
private val _serverInfo = MediatorLiveData<Event<UIResult<ServerInfo>>>()
|
||||
val serverInfo: LiveData<Event<UIResult<ServerInfo>>> = _serverInfo
|
||||
|
||||
private val _loginResult = MediatorLiveData<Event<UIResult<String>>>()
|
||||
val loginResult: LiveData<Event<UIResult<String>>> = _loginResult
|
||||
|
||||
private val _supportsOAuth2 = MediatorLiveData<Event<UIResult<Boolean>>>()
|
||||
val supportsOAuth2: LiveData<Event<UIResult<Boolean>>> = _supportsOAuth2
|
||||
|
||||
private val _baseUrl = MediatorLiveData<Event<UIResult<String>>>()
|
||||
val baseUrl: LiveData<Event<UIResult<String>>> = _baseUrl
|
||||
|
||||
private val _registerClient = MediatorLiveData<Event<UIResult<ClientRegistrationInfo>>>()
|
||||
val registerClient: LiveData<Event<UIResult<ClientRegistrationInfo>>> = _registerClient
|
||||
|
||||
private val _requestToken = MediatorLiveData<Event<UIResult<TokenResponse>>>()
|
||||
val requestToken: LiveData<Event<UIResult<TokenResponse>>> = _requestToken
|
||||
|
||||
private val _accountDiscovery = MediatorLiveData<Event<UIResult<Unit>>>()
|
||||
val accountDiscovery: LiveData<Event<UIResult<Unit>>> = _accountDiscovery
|
||||
|
||||
var launchedFromDeepLink = false
|
||||
|
||||
fun getLegacyWebfingerHost(
|
||||
webfingerLookupServer: String,
|
||||
webfingerUsername: String,
|
||||
) {
|
||||
runUseCaseWithResult(
|
||||
coroutineDispatcher = coroutinesDispatcherProvider.io,
|
||||
showLoading = true,
|
||||
liveData = _legacyWebfingerHost,
|
||||
useCase = getQSferaInstanceFromWebFingerUseCase,
|
||||
useCaseParams = GetQSferaInstanceFromWebFingerUseCase.Params(server = webfingerLookupServer, resource = webfingerUsername)
|
||||
)
|
||||
}
|
||||
|
||||
fun getServerInfo(
|
||||
serverUrl: String,
|
||||
creatingAccount: Boolean = false,
|
||||
) {
|
||||
runUseCaseWithResult(
|
||||
coroutineDispatcher = coroutinesDispatcherProvider.io,
|
||||
showLoading = true,
|
||||
liveData = _serverInfo,
|
||||
useCase = getServerInfoAsyncUseCase,
|
||||
useCaseParams = GetServerInfoAsyncUseCase.Params(
|
||||
serverPath = serverUrl,
|
||||
creatingAccount = creatingAccount,
|
||||
enforceOIDC = contextProvider.getBoolean(R.bool.enforce_oidc),
|
||||
secureConnectionEnforced = contextProvider.getBoolean(R.bool.enforce_secure_connection),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun loginBasic(
|
||||
username: String,
|
||||
password: String,
|
||||
updateAccountWithUsername: String?
|
||||
) = runUseCaseWithResult(
|
||||
coroutineDispatcher = coroutinesDispatcherProvider.io,
|
||||
liveData = _loginResult,
|
||||
showLoading = true,
|
||||
useCase = loginBasicAsyncUseCase,
|
||||
useCaseParams = LoginBasicAsyncUseCase.Params(
|
||||
serverInfo = serverInfo.value?.peekContent()?.getStoredData(),
|
||||
username = username,
|
||||
password = password,
|
||||
updateAccountWithUsername = updateAccountWithUsername
|
||||
)
|
||||
)
|
||||
|
||||
fun loginOAuth(
|
||||
serverBaseUrl: String,
|
||||
username: String,
|
||||
authTokenType: String,
|
||||
accessToken: String,
|
||||
refreshToken: String,
|
||||
scope: String?,
|
||||
updateAccountWithUsername: String? = null,
|
||||
clientRegistrationInfo: ClientRegistrationInfo?
|
||||
) {
|
||||
viewModelScope.launch(coroutinesDispatcherProvider.io) {
|
||||
_loginResult.postValue(Event(UIResult.Loading()))
|
||||
|
||||
val serverInfo = serverInfo.value?.peekContent()?.getStoredData() ?: throw java.lang.IllegalArgumentException("Server info value cannot" +
|
||||
" be null")
|
||||
|
||||
// Authenticated WebFinger needed only for account creations. Logged accounts already know their instances.
|
||||
if (updateAccountWithUsername == null) {
|
||||
val qsferaInstancesAvailable = getQSferaInstancesFromAuthenticatedWebFingerUseCase(
|
||||
GetQSferaInstancesFromAuthenticatedWebFingerUseCase.Params(
|
||||
server = serverBaseUrl,
|
||||
username = username,
|
||||
accessToken = accessToken,
|
||||
)
|
||||
)
|
||||
Timber.d("Instances retrieved from authenticated webfinger: $qsferaInstancesAvailable")
|
||||
|
||||
// Multiple instances are not supported yet. Let's use the first instance we receive for the moment.
|
||||
qsferaInstancesAvailable.getDataOrNull()?.let {
|
||||
if (it.isNotEmpty()) {
|
||||
serverInfo.baseUrl = it.first()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val useCaseResult = loginOAuthAsyncUseCase(
|
||||
LoginOAuthAsyncUseCase.Params(
|
||||
serverInfo = serverInfo,
|
||||
username = username,
|
||||
authTokenType = authTokenType,
|
||||
accessToken = accessToken,
|
||||
refreshToken = refreshToken,
|
||||
scope = scope,
|
||||
updateAccountWithUsername = updateAccountWithUsername,
|
||||
clientRegistrationInfo = clientRegistrationInfo,
|
||||
)
|
||||
)
|
||||
|
||||
if (useCaseResult.isSuccess) {
|
||||
_loginResult.postValue(Event(UIResult.Success(useCaseResult.getDataOrNull())))
|
||||
} else if (useCaseResult.isError) {
|
||||
_loginResult.postValue(Event(UIResult.Error(error = useCaseResult.getThrowableOrNull())))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun supportsOAuth2(
|
||||
accountName: String
|
||||
) = runUseCaseWithResult(
|
||||
coroutineDispatcher = coroutinesDispatcherProvider.io,
|
||||
requiresConnection = false,
|
||||
liveData = _supportsOAuth2,
|
||||
useCase = supportsOAuth2UseCase,
|
||||
useCaseParams = SupportsOAuth2UseCase.Params(
|
||||
accountName = accountName
|
||||
)
|
||||
)
|
||||
|
||||
fun getBaseUrl(
|
||||
accountName: String
|
||||
) = runUseCaseWithResult(
|
||||
coroutineDispatcher = coroutinesDispatcherProvider.io,
|
||||
requiresConnection = false,
|
||||
liveData = _baseUrl,
|
||||
useCase = getBaseUrlUseCase,
|
||||
useCaseParams = GetBaseUrlUseCase.Params(
|
||||
accountName = accountName
|
||||
)
|
||||
)
|
||||
|
||||
fun registerClient(
|
||||
registrationEndpoint: String
|
||||
) {
|
||||
val registrationRequest = OAuthUtils.buildClientRegistrationRequest(
|
||||
registrationEndpoint = registrationEndpoint,
|
||||
MainApp.appContext
|
||||
)
|
||||
|
||||
runUseCaseWithResult(
|
||||
coroutineDispatcher = coroutinesDispatcherProvider.io,
|
||||
showLoading = false,
|
||||
liveData = _registerClient,
|
||||
useCase = registerClientUseCase,
|
||||
useCaseParams = RegisterClientUseCase.Params(registrationRequest)
|
||||
)
|
||||
}
|
||||
|
||||
fun requestToken(
|
||||
tokenRequest: TokenRequest
|
||||
) = runUseCaseWithResult(
|
||||
coroutineDispatcher = coroutinesDispatcherProvider.io,
|
||||
showLoading = false,
|
||||
liveData = _requestToken,
|
||||
useCase = requestTokenUseCase,
|
||||
useCaseParams = RequestTokenUseCase.Params(tokenRequest = tokenRequest)
|
||||
)
|
||||
|
||||
fun discoverAccount(accountName: String, discoveryNeeded: Boolean = false) {
|
||||
Timber.d("Account Discovery for account: $accountName needed: $discoveryNeeded")
|
||||
if (!discoveryNeeded) {
|
||||
_accountDiscovery.postValue(Event(UIResult.Success()))
|
||||
return
|
||||
}
|
||||
_accountDiscovery.postValue(Event(UIResult.Loading()))
|
||||
viewModelScope.launch(coroutinesDispatcherProvider.io) {
|
||||
// 1. Refresh capabilities for account
|
||||
refreshCapabilitiesFromServerAsyncUseCase(RefreshCapabilitiesFromServerAsyncUseCase.Params(accountName))
|
||||
val capabilities = getStoredCapabilitiesUseCase(GetStoredCapabilitiesUseCase.Params(accountName))
|
||||
|
||||
val spacesAvailableForAccount = capabilities?.isSpacesAllowed() == true
|
||||
|
||||
// 2 If Account does not support spaces we can skip this
|
||||
if (spacesAvailableForAccount) {
|
||||
refreshSpacesFromServerAsyncUseCase(RefreshSpacesFromServerAsyncUseCase.Params(accountName))
|
||||
}
|
||||
_accountDiscovery.postValue(Event(UIResult.Success()))
|
||||
}
|
||||
workManagerProvider.enqueueAccountDiscovery(accountName)
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Abel García de Prada
|
||||
* Copyright (C) 2012 Bartek Przybylski
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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/>.
|
||||
*/
|
||||
@file:JvmName("AuthenticatorConstants")
|
||||
|
||||
package eu.qsfera.android.presentation.authentication
|
||||
|
||||
import eu.qsfera.android.MainApp.Companion.accountType
|
||||
import eu.qsfera.android.lib.common.accounts.AccountTypeUtils
|
||||
|
||||
const val EXTRA_ACTION = "ACTION"
|
||||
const val EXTRA_ACCOUNT = "ACCOUNT"
|
||||
|
||||
const val ACTION_CREATE: Byte = 0
|
||||
const val ACTION_UPDATE_TOKEN: Byte = 1 // requested by the user
|
||||
const val ACTION_UPDATE_EXPIRED_TOKEN: Byte = 2 // detected by the app
|
||||
|
||||
const val KEY_AUTH_TOKEN_TYPE = "authTokenType"
|
||||
|
||||
val BASIC_TOKEN_TYPE: String = AccountTypeUtils.getAuthTokenTypePass(
|
||||
accountType
|
||||
)
|
||||
|
||||
val OAUTH_TOKEN_TYPE: String = AccountTypeUtils.getAuthTokenTypeAccessToken(
|
||||
accountType
|
||||
)
|
||||
|
||||
const val UNTRUSTED_CERT_DIALOG_TAG = "UNTRUSTED_CERT_DIALOG"
|
||||
const val WAIT_DIALOG_TAG = "WAIT_DIALOG"
|
||||
+1190
File diff suppressed because it is too large
Load Diff
+133
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David González Verdugo
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
*
|
||||
* Copyright (C) 2024 ownCloud GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.presentation.authentication.oauth
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import eu.qsfera.android.MainApp
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.data.authentication.QUERY_PARAMETER_CLIENT_ID
|
||||
import eu.qsfera.android.data.authentication.QUERY_PARAMETER_CODE_CHALLENGE
|
||||
import eu.qsfera.android.data.authentication.QUERY_PARAMETER_CODE_CHALLENGE_METHOD
|
||||
import eu.qsfera.android.data.authentication.QUERY_PARAMETER_LOGIN_HINT
|
||||
import eu.qsfera.android.data.authentication.QUERY_PARAMETER_PROMPT
|
||||
import eu.qsfera.android.data.authentication.QUERY_PARAMETER_REDIRECT_URI
|
||||
import eu.qsfera.android.data.authentication.QUERY_PARAMETER_RESPONSE_TYPE
|
||||
import eu.qsfera.android.data.authentication.QUERY_PARAMETER_SCOPE
|
||||
import eu.qsfera.android.data.authentication.QUERY_PARAMETER_STATE
|
||||
import eu.qsfera.android.data.authentication.QUERY_PARAMETER_USER
|
||||
import eu.qsfera.android.domain.authentication.oauth.model.ClientRegistrationRequest
|
||||
import java.net.URLEncoder
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
|
||||
class OAuthUtils {
|
||||
|
||||
fun generateRandomState(): String {
|
||||
val secureRandom = SecureRandom()
|
||||
val randomBytes = ByteArray(DEFAULT_STATE_ENTROPY)
|
||||
secureRandom.nextBytes(randomBytes)
|
||||
val encoding = Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE
|
||||
return Base64.encodeToString(randomBytes, encoding)
|
||||
}
|
||||
|
||||
fun generateRandomCodeVerifier(): String {
|
||||
val secureRandom = SecureRandom()
|
||||
val randomBytes = ByteArray(DEFAULT_CODE_VERIFIER_ENTROPY)
|
||||
secureRandom.nextBytes(randomBytes)
|
||||
val encoding = Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE
|
||||
return Base64.encodeToString(randomBytes, encoding)
|
||||
}
|
||||
|
||||
fun generateCodeChallenge(codeVerifier: String): String {
|
||||
val bytes = codeVerifier.toByteArray()
|
||||
val messageDigest = MessageDigest.getInstance(ALGORITHM_SHA_256)
|
||||
messageDigest.update(bytes)
|
||||
val digest = messageDigest.digest()
|
||||
val encoding = Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE
|
||||
return Base64.encodeToString(digest, encoding)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ALGORITHM_SHA_256 = "SHA-256"
|
||||
private const val CODE_CHALLENGE_METHOD = "S256"
|
||||
private const val DEFAULT_CODE_VERIFIER_ENTROPY = 64
|
||||
private const val DEFAULT_STATE_ENTROPY = 15
|
||||
|
||||
fun buildClientRegistrationRequest(
|
||||
registrationEndpoint: String,
|
||||
context: Context
|
||||
): ClientRegistrationRequest =
|
||||
ClientRegistrationRequest(
|
||||
registrationEndpoint = registrationEndpoint,
|
||||
clientName = MainApp.userAgent,
|
||||
redirectUris = listOf(buildRedirectUri(context).toString())
|
||||
)
|
||||
|
||||
fun getClientAuth(
|
||||
clientSecret: String,
|
||||
clientId: String
|
||||
): String {
|
||||
// From the OAuth2 RFC, client ID and secret should be encoded prior to concatenation and
|
||||
// conversion to Base64: https://tools.ietf.org/html/rfc6749#section-2.3.1
|
||||
val encodedClientId = URLEncoder.encode(clientId, "utf-8")
|
||||
val encodedClientSecret = URLEncoder.encode(clientSecret, "utf-8")
|
||||
val credentials = "$encodedClientId:$encodedClientSecret"
|
||||
return "Basic " + Base64.encodeToString(credentials.toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
fun buildAuthorizationRequest(
|
||||
authorizationEndpoint: Uri,
|
||||
redirectUri: String,
|
||||
clientId: String,
|
||||
responseType: String,
|
||||
scope: String,
|
||||
prompt: String,
|
||||
codeChallenge: String,
|
||||
state: String,
|
||||
username: String?,
|
||||
sendLoginHintAndUser: Boolean,
|
||||
): Uri =
|
||||
authorizationEndpoint.buildUpon().apply {
|
||||
appendQueryParameter(QUERY_PARAMETER_REDIRECT_URI, redirectUri)
|
||||
appendQueryParameter(QUERY_PARAMETER_CLIENT_ID, clientId)
|
||||
appendQueryParameter(QUERY_PARAMETER_RESPONSE_TYPE, responseType)
|
||||
appendQueryParameter(QUERY_PARAMETER_SCOPE, scope)
|
||||
appendQueryParameter(QUERY_PARAMETER_PROMPT, prompt)
|
||||
appendQueryParameter(QUERY_PARAMETER_CODE_CHALLENGE, codeChallenge)
|
||||
appendQueryParameter(QUERY_PARAMETER_CODE_CHALLENGE_METHOD, CODE_CHALLENGE_METHOD)
|
||||
appendQueryParameter(QUERY_PARAMETER_STATE, state)
|
||||
if (sendLoginHintAndUser && !username.isNullOrEmpty()) {
|
||||
appendQueryParameter(QUERY_PARAMETER_USER, username)
|
||||
appendQueryParameter(QUERY_PARAMETER_LOGIN_HINT, username)
|
||||
}
|
||||
}.build()
|
||||
|
||||
fun buildRedirectUri(context: Context): Uri =
|
||||
Uri.Builder()
|
||||
.scheme(context.getString(R.string.oauth2_redirect_uri_scheme))
|
||||
.authority(context.getString(R.string.oauth2_redirect_uri_host))
|
||||
.path(context.getString(R.string.oauth2_redirect_uri_path))
|
||||
.build()
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Abel García de Prada
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.presentation.authentication.oauth
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import eu.qsfera.android.MainApp
|
||||
import eu.qsfera.android.domain.authentication.oauth.OIDCDiscoveryUseCase
|
||||
import eu.qsfera.android.domain.authentication.oauth.RegisterClientUseCase
|
||||
import eu.qsfera.android.domain.authentication.oauth.RequestTokenUseCase
|
||||
import eu.qsfera.android.domain.authentication.oauth.model.ClientRegistrationInfo
|
||||
import eu.qsfera.android.domain.authentication.oauth.model.OIDCServerConfiguration
|
||||
import eu.qsfera.android.domain.authentication.oauth.model.TokenRequest
|
||||
import eu.qsfera.android.domain.authentication.oauth.model.TokenResponse
|
||||
import eu.qsfera.android.domain.utils.Event
|
||||
import eu.qsfera.android.extensions.ViewModelExt.runUseCaseWithResult
|
||||
import eu.qsfera.android.presentation.common.UIResult
|
||||
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
|
||||
|
||||
class OAuthViewModel(
|
||||
private val getOIDCDiscoveryUseCase: OIDCDiscoveryUseCase,
|
||||
private val requestTokenUseCase: RequestTokenUseCase,
|
||||
private val registerClientUseCase: RegisterClientUseCase,
|
||||
private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider
|
||||
) : ViewModel() {
|
||||
|
||||
val codeVerifier: String = OAuthUtils().generateRandomCodeVerifier()
|
||||
val codeChallenge: String = OAuthUtils().generateCodeChallenge(codeVerifier)
|
||||
val oidcState: String = OAuthUtils().generateRandomState()
|
||||
|
||||
private val _oidcDiscovery = MediatorLiveData<Event<UIResult<OIDCServerConfiguration>>>()
|
||||
val oidcDiscovery: LiveData<Event<UIResult<OIDCServerConfiguration>>> = _oidcDiscovery
|
||||
|
||||
private val _registerClient = MediatorLiveData<Event<UIResult<ClientRegistrationInfo>>>()
|
||||
val registerClient: LiveData<Event<UIResult<ClientRegistrationInfo>>> = _registerClient
|
||||
|
||||
private val _requestToken = MediatorLiveData<Event<UIResult<TokenResponse>>>()
|
||||
val requestToken: LiveData<Event<UIResult<TokenResponse>>> = _requestToken
|
||||
|
||||
fun getOIDCServerConfiguration(
|
||||
serverUrl: String
|
||||
) = runUseCaseWithResult(
|
||||
coroutineDispatcher = coroutinesDispatcherProvider.io,
|
||||
showLoading = false,
|
||||
liveData = _oidcDiscovery,
|
||||
useCase = getOIDCDiscoveryUseCase,
|
||||
useCaseParams = OIDCDiscoveryUseCase.Params(baseUrl = serverUrl)
|
||||
)
|
||||
|
||||
fun registerClient(
|
||||
registrationEndpoint: String
|
||||
) {
|
||||
val registrationRequest = OAuthUtils.buildClientRegistrationRequest(
|
||||
registrationEndpoint = registrationEndpoint,
|
||||
MainApp.appContext
|
||||
)
|
||||
|
||||
runUseCaseWithResult(
|
||||
coroutineDispatcher = coroutinesDispatcherProvider.io,
|
||||
showLoading = false,
|
||||
liveData = _registerClient,
|
||||
useCase = registerClientUseCase,
|
||||
useCaseParams = RegisterClientUseCase.Params(registrationRequest)
|
||||
)
|
||||
}
|
||||
|
||||
fun requestToken(
|
||||
tokenRequest: TokenRequest
|
||||
) = runUseCaseWithResult(
|
||||
coroutineDispatcher = coroutinesDispatcherProvider.io,
|
||||
showLoading = false,
|
||||
liveData = _requestToken,
|
||||
useCase = requestTokenUseCase,
|
||||
useCaseParams = RequestTokenUseCase.Params(tokenRequest = tokenRequest)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Abel García de Prada
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
* <p>
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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 eu.qsfera.android.presentation.avatar
|
||||
|
||||
import android.accounts.Account
|
||||
import android.view.MenuItem
|
||||
import android.widget.ImageView
|
||||
import eu.qsfera.android.R
|
||||
import coil.request.ErrorResult
|
||||
import coil.request.ImageRequest
|
||||
import eu.qsfera.android.MainApp.Companion.appContext
|
||||
import eu.qsfera.android.presentation.thumbnails.ThumbnailsRequester
|
||||
import kotlinx.coroutines.delay
|
||||
import org.koin.core.component.KoinComponent
|
||||
import timber.log.Timber
|
||||
|
||||
class AvatarUtils : KoinComponent {
|
||||
|
||||
/**
|
||||
* Show the avatar corresponding to the received account in an {@ImageView}.
|
||||
* <p>
|
||||
* The avatar is shown if available locally. The avatar is not
|
||||
* fetched from the server if not available.
|
||||
* <p>
|
||||
* If there is no avatar stored, a colored icon is generated with the first letter of the account username.
|
||||
* <p>
|
||||
* If this is not possible either, a predefined user icon is shown instead.
|
||||
*
|
||||
* @param account OC account which avatar will be shown.
|
||||
* @param displayRadius The radius of the circle where the avatar will be clipped into.
|
||||
* @param fetchIfNotCached When 'true', if there is no avatar stored in the cache, it's fetched from
|
||||
* the server. When 'false', server is not accessed, the fallback avatar is
|
||||
* generated instead.
|
||||
*/
|
||||
suspend fun loadAvatarForAccount(
|
||||
imageView: ImageView,
|
||||
account: Account,
|
||||
@Suppress("UnusedParameter") displayRadius: Float,
|
||||
imageLoader: coil.ImageLoader? = null
|
||||
) {
|
||||
val uri = ThumbnailsRequester.getAvatarUri(account)
|
||||
val loader = imageLoader ?: ThumbnailsRequester.getRevalidatingImageLoader(account)
|
||||
// No .target(imageView) here — using execute() with a ViewTarget can hang
|
||||
// due to Coil's internal lifecycle checks. We set the drawable manually instead.
|
||||
val request = ImageRequest.Builder(appContext)
|
||||
.data(uri)
|
||||
.transformations(coil.transform.CircleCropTransformation())
|
||||
.build()
|
||||
Timber.d("Avatar load for $uri")
|
||||
var result = loader.execute(request)
|
||||
if (result is ErrorResult) {
|
||||
// On failure, give ConnectionValidator time to refresh the token on another
|
||||
// thread, then retry once.
|
||||
Timber.d("Avatar load failed for $uri, retrying in 5s")
|
||||
delay(5_000L)
|
||||
Timber.d("Retrying avatar load for $uri")
|
||||
result = loader.execute(request)
|
||||
}
|
||||
(result as? coil.request.SuccessResult)?.let { imageView.setImageDrawable(it.drawable) }
|
||||
?: imageView.setImageResource(R.drawable.ic_account_circle)
|
||||
}
|
||||
|
||||
fun loadAvatarForAccount(
|
||||
menuItem: MenuItem,
|
||||
account: Account,
|
||||
@Suppress("UnusedParameter") displayRadius: Float
|
||||
) {
|
||||
val uri = ThumbnailsRequester.getAvatarUri(account)
|
||||
val imageLoader = ThumbnailsRequester.getRevalidatingImageLoader(account)
|
||||
val request = coil.request.ImageRequest.Builder(appContext)
|
||||
.data(uri)
|
||||
.target(
|
||||
onStart = { menuItem.setIcon(R.drawable.ic_account_circle) },
|
||||
onSuccess = { result -> menuItem.icon = result },
|
||||
onError = { menuItem.setIcon(R.drawable.ic_account_circle) }
|
||||
)
|
||||
.transformations(coil.transform.CircleCropTransformation())
|
||||
.build()
|
||||
imageLoader.enqueue(request)
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David González Verdugo
|
||||
* @author Abel García de Prada
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
*
|
||||
* Copyright (C) 2024 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.presentation.capabilities
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import eu.qsfera.android.domain.capabilities.model.OCCapability
|
||||
import eu.qsfera.android.domain.capabilities.usecases.GetCapabilitiesAsLiveDataUseCase
|
||||
import eu.qsfera.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase
|
||||
import eu.qsfera.android.domain.capabilities.usecases.RefreshCapabilitiesFromServerAsyncUseCase
|
||||
import eu.qsfera.android.domain.utils.Event
|
||||
import eu.qsfera.android.extensions.ViewModelExt.runUseCaseWithResultAndUseCachedData
|
||||
import eu.qsfera.android.presentation.common.UIResult
|
||||
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* View Model to keep a reference to the capability repository and an up-to-date capability
|
||||
*/
|
||||
class CapabilityViewModel(
|
||||
private val accountName: String,
|
||||
getCapabilitiesAsLiveDataUseCase: GetCapabilitiesAsLiveDataUseCase,
|
||||
private val refreshCapabilitiesFromServerAsyncUseCase: RefreshCapabilitiesFromServerAsyncUseCase,
|
||||
private val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase,
|
||||
private val coroutineDispatcherProvider: CoroutinesDispatcherProvider
|
||||
) : ViewModel() {
|
||||
|
||||
private val _capabilities = MediatorLiveData<Event<UIResult<OCCapability>>>()
|
||||
val capabilities: LiveData<Event<UIResult<OCCapability>>> = _capabilities
|
||||
|
||||
private var capabilitiesLiveData: LiveData<OCCapability?> = getCapabilitiesAsLiveDataUseCase(
|
||||
GetCapabilitiesAsLiveDataUseCase.Params(
|
||||
accountName = accountName
|
||||
)
|
||||
)
|
||||
|
||||
init {
|
||||
_capabilities.addSource(capabilitiesLiveData) { capabilities ->
|
||||
_capabilities.postValue(Event(UIResult.Success(capabilities)))
|
||||
}
|
||||
|
||||
refreshCapabilitiesFromNetwork()
|
||||
}
|
||||
|
||||
fun refreshCapabilitiesFromNetwork() = runUseCaseWithResultAndUseCachedData(
|
||||
coroutineDispatcher = coroutineDispatcherProvider.io,
|
||||
cachedData = capabilitiesLiveData.value,
|
||||
liveData = _capabilities,
|
||||
useCase = refreshCapabilitiesFromServerAsyncUseCase,
|
||||
useCaseParams = RefreshCapabilitiesFromServerAsyncUseCase.Params(
|
||||
accountName = accountName
|
||||
)
|
||||
)
|
||||
|
||||
fun checkMultiPersonal(): Boolean = runBlocking(CoroutinesDispatcherProvider().io) {
|
||||
val capabilities = withContext(CoroutinesDispatcherProvider().io) {
|
||||
getStoredCapabilitiesUseCase(GetStoredCapabilitiesUseCase.Params(accountName))
|
||||
}
|
||||
capabilities?.spaces?.hasMultiplePersonalSpaces == true
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Abel García de Prada
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
* @author Aitor Ballesteros Pavón
|
||||
*
|
||||
* Copyright (C) 2024 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.presentation.common
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.databinding.BottomSheetFragmentItemBinding
|
||||
|
||||
class BottomSheetFragmentItemView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyle: Int = 0
|
||||
) : ConstraintLayout(context, attrs, defStyle) {
|
||||
|
||||
private var _binding: BottomSheetFragmentItemBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
var itemIcon: Drawable?
|
||||
get() = binding.itemIcon.drawable
|
||||
set(value) {
|
||||
binding.itemIcon.setImageDrawable(value)
|
||||
}
|
||||
|
||||
var title: CharSequence?
|
||||
get() = binding.itemTitle.text
|
||||
set(value) {
|
||||
binding.itemTitle.text = value
|
||||
}
|
||||
|
||||
var itemAdditionalIcon: Drawable?
|
||||
get() = binding.itemAdditionalIcon.drawable
|
||||
set(value) {
|
||||
binding.itemAdditionalIcon.setImageDrawable(value)
|
||||
}
|
||||
|
||||
init {
|
||||
_binding = BottomSheetFragmentItemBinding.inflate(LayoutInflater.from(context), this, true)
|
||||
|
||||
val a = context.theme.obtainStyledAttributes(attrs, R.styleable.BottomSheetFragmentItemView, 0, 0)
|
||||
try {
|
||||
itemIcon = a.getDrawable(R.styleable.BottomSheetFragmentItemView_itemIcon)
|
||||
title = a.getString(R.styleable.BottomSheetFragmentItemView_title)
|
||||
} finally {
|
||||
a.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
fun setSelected(iconAdditional: Int) {
|
||||
itemAdditionalIcon = ContextCompat.getDrawable(context, iconAdditional)
|
||||
val selectedColor = ContextCompat.getColor(context, R.color.primary)
|
||||
binding.itemIcon.setColorFilter(selectedColor)
|
||||
binding.itemTitle.setTextColor(selectedColor)
|
||||
binding.itemAdditionalIcon.setColorFilter(selectedColor)
|
||||
}
|
||||
|
||||
fun removeDefaultTint() {
|
||||
binding.itemIcon.imageTintList = null
|
||||
}
|
||||
|
||||
fun addDefaultTint(tintColor: Int) {
|
||||
val itemColor = ContextCompat.getColor(context, tintColor)
|
||||
val itemColorStateList = ColorStateList.valueOf(itemColor)
|
||||
binding.itemIcon.imageTintList = itemColorStateList
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David González Verdugo
|
||||
* @author Abel García de Prada
|
||||
* @author Aitor Ballesteros Pavón
|
||||
* @author Jorge Aguado Recio
|
||||
*
|
||||
* Copyright (C) 2024 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.presentation.common
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.data.providers.LocalStorageProvider
|
||||
import eu.qsfera.android.domain.user.model.UserQuota
|
||||
import eu.qsfera.android.domain.user.usecases.GetStoredQuotaAsStreamUseCase
|
||||
import eu.qsfera.android.domain.user.usecases.GetUserQuotasUseCase
|
||||
import eu.qsfera.android.domain.utils.Event
|
||||
import eu.qsfera.android.extensions.ViewModelExt.runUseCaseWithResult
|
||||
import eu.qsfera.android.presentation.authentication.AccountUtils
|
||||
import eu.qsfera.android.providers.ContextProvider
|
||||
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
|
||||
import eu.qsfera.android.usecases.accounts.RemoveAccountUseCase
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
class DrawerViewModel(
|
||||
private val getStoredQuotaAsStreamUseCase: GetStoredQuotaAsStreamUseCase,
|
||||
private val removeAccountUseCase: RemoveAccountUseCase,
|
||||
private val getUserQuotasUseCase: GetUserQuotasUseCase,
|
||||
private val localStorageProvider: LocalStorageProvider,
|
||||
private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider,
|
||||
private val contextProvider: ContextProvider,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _userQuota = MutableStateFlow<Event<UIResult<Flow<UserQuota?>>>?>(null)
|
||||
val userQuota: StateFlow<Event<UIResult<Flow<UserQuota?>>>?> = _userQuota
|
||||
|
||||
fun getUserQuota(accountName: String) {
|
||||
runUseCaseWithResult(
|
||||
coroutineDispatcher = coroutinesDispatcherProvider.io,
|
||||
requiresConnection = false,
|
||||
showLoading = true,
|
||||
flow = _userQuota,
|
||||
useCase = getStoredQuotaAsStreamUseCase,
|
||||
useCaseParams = GetStoredQuotaAsStreamUseCase.Params(accountName = accountName),
|
||||
)
|
||||
}
|
||||
|
||||
fun getAccounts(context: Context): List<Account> =
|
||||
AccountUtils.getAccounts(context).asList()
|
||||
|
||||
fun getUsernameOfAccount(accountName: String): String =
|
||||
AccountUtils.getUsernameOfAccount(accountName)
|
||||
|
||||
fun getFeedbackMail() = contextProvider.getString(R.string.mail_feedback)
|
||||
|
||||
fun removeAccount(context: Context) {
|
||||
viewModelScope.launch(coroutinesDispatcherProvider.io) {
|
||||
val loggedAccounts = AccountUtils.getAccounts(context)
|
||||
localStorageProvider.deleteUnusedUserDirs(loggedAccounts)
|
||||
|
||||
val userQuotas = getUserQuotasUseCase(Unit)
|
||||
val loggedAccountsNames = loggedAccounts.map { it.name }
|
||||
val totalAccountsNames = userQuotas.map { it.accountName }
|
||||
val removedAccountsNames = mutableListOf<String>()
|
||||
for (accountName in totalAccountsNames) {
|
||||
if (!loggedAccountsNames.contains(accountName)) {
|
||||
removedAccountsNames.add(accountName)
|
||||
}
|
||||
}
|
||||
removedAccountsNames.forEach { removedAccountName ->
|
||||
Timber.d("$removedAccountName is being removed")
|
||||
removeAccountUseCase(
|
||||
RemoveAccountUseCase.Params(accountName = removedAccountName)
|
||||
)
|
||||
localStorageProvider.removeLocalStorageForAccount(removedAccountName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Abel García de Prada
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
* <p>
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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 eu.qsfera.android.presentation.common
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ResolveInfo
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.StringRes
|
||||
|
||||
class ShareSheetHelper {
|
||||
|
||||
fun getShareSheetIntent(
|
||||
intent: Intent,
|
||||
context: Context,
|
||||
@StringRes title: Int,
|
||||
packagesToExclude: Array<String>
|
||||
): Intent {
|
||||
|
||||
// Get excluding specific targets by component. We want to hide oC targets.
|
||||
val resInfo: List<ResolveInfo> =
|
||||
context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
|
||||
val excludeLists = ArrayList<ComponentName>()
|
||||
if (resInfo.isNotEmpty()) {
|
||||
for (info in resInfo) {
|
||||
val activityInfo = info.activityInfo
|
||||
for (packageToExclude in packagesToExclude) {
|
||||
if (activityInfo != null && activityInfo.packageName == packageToExclude) {
|
||||
excludeLists.add(ComponentName(activityInfo.packageName, activityInfo.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return a new ShareSheet intent
|
||||
return Intent.createChooser(intent, "").apply {
|
||||
putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, excludeLists.toArray(arrayOf<Parcelable>()))
|
||||
putExtra(Intent.EXTRA_TITLE, context.getString(title))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author David González Verdugo
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.presentation.common
|
||||
|
||||
sealed class UIResult<out T> {
|
||||
data class Loading<out T>(val data: T? = null) : UIResult<T>()
|
||||
data class Success<out T>(val data: T? = null) : UIResult<T>()
|
||||
data class Error<out T>(val error: Throwable? = null, val data: T? = null) : UIResult<T>()
|
||||
|
||||
val isLoading get() = this is Loading
|
||||
val isSuccess get() = this is Success
|
||||
val isError get() = this is Error
|
||||
|
||||
@Deprecated(message = "Start to use new extensions")
|
||||
fun getStoredData(): T? =
|
||||
when (this) {
|
||||
is Loading -> data
|
||||
is Success -> data
|
||||
is Error -> data // Even when there's an error we still want to show database data
|
||||
}
|
||||
|
||||
fun getThrowableOrNull(): Throwable? =
|
||||
if (this is Error) {
|
||||
error
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> UIResult<T>.onLoading(action: (data: T?) -> Unit): UIResult<T> {
|
||||
if (this is UIResult.Loading) action(data)
|
||||
return this
|
||||
}
|
||||
|
||||
fun <T> UIResult<T>.onSuccess(action: (data: T?) -> Unit): UIResult<T> {
|
||||
if (this is UIResult.Success) action(data)
|
||||
return this
|
||||
}
|
||||
|
||||
fun <T> UIResult<T>.onError(action: (error: Throwable?) -> Unit): UIResult<T> {
|
||||
if (this is UIResult.Error) action(error)
|
||||
return this
|
||||
}
|
||||
|
||||
fun <T> UIResult<T>.fold(
|
||||
onLoading: (data: T?) -> Unit,
|
||||
onSuccess: (data: T?) -> Unit,
|
||||
onFailure: (error: Throwable?) -> Unit
|
||||
) {
|
||||
when (this) {
|
||||
is UIResult.Loading -> onLoading(data)
|
||||
is UIResult.Success -> onSuccess(data)
|
||||
is UIResult.Error -> onFailure(error)
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Bartek Przybylski
|
||||
* @author David A. Velasco
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
*
|
||||
* Copyright (C) 2012 Bartek Przybylski
|
||||
* Copyright (C) 2022 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.presentation.conflicts
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import eu.qsfera.android.databinding.ActivityConflictsResolveBinding
|
||||
import eu.qsfera.android.ui.activity.enableEdgeToEdgePostSetContentView
|
||||
import eu.qsfera.android.ui.activity.enableEdgeToEdgePreSetContentView
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import timber.log.Timber
|
||||
|
||||
class ConflictsResolveActivity : AppCompatActivity(), ConflictsResolveDialogFragment.OnConflictDecisionMadeListener {
|
||||
|
||||
private var _binding: ActivityConflictsResolveBinding? = null
|
||||
val binding get() = _binding!!
|
||||
|
||||
private val conflictsResolveViewModel by viewModel<ConflictsResolveViewModel> {
|
||||
parametersOf(
|
||||
intent.getParcelableExtra(
|
||||
EXTRA_FILE
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// edge-to-edge
|
||||
enableEdgeToEdgePreSetContentView(true)
|
||||
|
||||
_binding = ActivityConflictsResolveBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
setSupportActionBar(binding.toolbar)
|
||||
|
||||
// edge-to-edge
|
||||
enableEdgeToEdgePostSetContentView { insets ->
|
||||
binding.toolbar.updatePadding(top = insets.top)
|
||||
binding.root.updatePadding(bottom = insets.bottom)
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
conflictsResolveViewModel.currentFile.collectLatest { updatedOCFile ->
|
||||
Timber.d("File ${updatedOCFile?.remotePath} from ${updatedOCFile?.owner} needs to fix a conflict with etag" +
|
||||
" in conflict ${updatedOCFile?.etagInConflict}")
|
||||
// Finish if the file does not exists or if the file is not in conflict anymore.
|
||||
updatedOCFile?.etagInConflict ?: finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ConflictsResolveDialogFragment.newInstance(onConflictDecisionMadeListener = this).showDialog(this)
|
||||
}
|
||||
|
||||
override fun conflictDecisionMade(decision: ConflictsResolveDialogFragment.Decision) {
|
||||
when (decision) {
|
||||
ConflictsResolveDialogFragment.Decision.CANCEL -> {}
|
||||
ConflictsResolveDialogFragment.Decision.KEEP_LOCAL -> {
|
||||
conflictsResolveViewModel.uploadFileInConflict()
|
||||
}
|
||||
ConflictsResolveDialogFragment.Decision.KEEP_BOTH -> {
|
||||
conflictsResolveViewModel.uploadFileFromSystem()
|
||||
}
|
||||
ConflictsResolveDialogFragment.Decision.KEEP_SERVER -> {
|
||||
conflictsResolveViewModel.downloadFile()
|
||||
}
|
||||
}
|
||||
|
||||
Timber.d("Decision to fix conflict on file ${conflictsResolveViewModel.currentFile.value?.remotePath} is ${decision.name}")
|
||||
|
||||
finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_FILE = "EXTRA_FILE"
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Bartek Przybylski
|
||||
* @author Christian Schabesberger
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
*
|
||||
* Copyright (C) 2012 Bartek Przybylski
|
||||
* Copyright (C) 2022 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.presentation.conflicts
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.app.Dialog
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.extensions.avoidScreenshotsIfNeeded
|
||||
|
||||
class ConflictsResolveDialogFragment : DialogFragment() {
|
||||
|
||||
private lateinit var listener: OnConflictDecisionMadeListener
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val dialog = AlertDialog.Builder(requireActivity())
|
||||
.setIcon(R.drawable.ic_warning)
|
||||
.setTitle(R.string.conflict_title)
|
||||
.setMessage(R.string.conflict_message)
|
||||
.setPositiveButton(R.string.conflict_use_local_version) { _, _ ->
|
||||
listener.conflictDecisionMade(Decision.KEEP_LOCAL)
|
||||
}
|
||||
.setNeutralButton(R.string.conflict_keep_both) { _, _ ->
|
||||
listener.conflictDecisionMade(Decision.KEEP_BOTH)
|
||||
}
|
||||
.setNegativeButton(R.string.conflict_use_server_version) { _, _ ->
|
||||
listener.conflictDecisionMade(Decision.KEEP_SERVER)
|
||||
}
|
||||
.create()
|
||||
|
||||
dialog.avoidScreenshotsIfNeeded()
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
override fun onCancel(dialog: DialogInterface) {
|
||||
listener.conflictDecisionMade(Decision.CANCEL)
|
||||
}
|
||||
|
||||
fun showDialog(activity: AppCompatActivity) {
|
||||
val previousFragment = activity.supportFragmentManager.findFragmentByTag("dialog")
|
||||
val fragmentTransaction = activity.supportFragmentManager.beginTransaction()
|
||||
if (previousFragment != null) {
|
||||
fragmentTransaction.remove(previousFragment)
|
||||
}
|
||||
fragmentTransaction.addToBackStack(null)
|
||||
|
||||
this.show(fragmentTransaction, "dialog")
|
||||
}
|
||||
|
||||
interface OnConflictDecisionMadeListener {
|
||||
fun conflictDecisionMade(decision: Decision)
|
||||
}
|
||||
|
||||
enum class Decision {
|
||||
CANCEL,
|
||||
KEEP_BOTH,
|
||||
KEEP_LOCAL,
|
||||
KEEP_SERVER
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance(onConflictDecisionMadeListener: OnConflictDecisionMadeListener): ConflictsResolveDialogFragment =
|
||||
ConflictsResolveDialogFragment().apply {
|
||||
listener = onConflictDecisionMadeListener
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
*
|
||||
* Copyright (C) 2023 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.presentation.conflicts
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import eu.qsfera.android.domain.files.model.OCFile
|
||||
import eu.qsfera.android.domain.files.usecases.GetFileByIdAsStreamUseCase
|
||||
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
|
||||
import eu.qsfera.android.usecases.transfers.downloads.DownloadFileUseCase
|
||||
import eu.qsfera.android.usecases.transfers.uploads.UploadFileInConflictUseCase
|
||||
import eu.qsfera.android.usecases.transfers.uploads.UploadFilesFromSystemUseCase
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ConflictsResolveViewModel(
|
||||
private val downloadFileUseCase: DownloadFileUseCase,
|
||||
private val uploadFileInConflictUseCase: UploadFileInConflictUseCase,
|
||||
private val uploadFilesFromSystemUseCase: UploadFilesFromSystemUseCase,
|
||||
getFileByIdAsStreamUseCase: GetFileByIdAsStreamUseCase,
|
||||
private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider,
|
||||
ocFile: OCFile,
|
||||
) : ViewModel() {
|
||||
|
||||
val currentFile: StateFlow<OCFile?> =
|
||||
getFileByIdAsStreamUseCase(GetFileByIdAsStreamUseCase.Params(ocFile.id!!))
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = ocFile
|
||||
)
|
||||
|
||||
fun downloadFile() {
|
||||
val fileToDownload = currentFile.value ?: return
|
||||
viewModelScope.launch(coroutinesDispatcherProvider.io) {
|
||||
downloadFileUseCase(
|
||||
DownloadFileUseCase.Params(
|
||||
accountName = fileToDownload.owner,
|
||||
file = fileToDownload
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun uploadFileInConflict() {
|
||||
val fileToUpload = currentFile.value ?: return
|
||||
viewModelScope.launch(coroutinesDispatcherProvider.io) {
|
||||
uploadFileInConflictUseCase(
|
||||
UploadFileInConflictUseCase.Params(
|
||||
accountName = fileToUpload.owner,
|
||||
localPath = fileToUpload.storagePath!!,
|
||||
uploadFolderPath = fileToUpload.getParentRemotePath(),
|
||||
spaceId = fileToUpload.spaceId,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun uploadFileFromSystem() {
|
||||
val fileToUpload = currentFile.value ?: return
|
||||
viewModelScope.launch(coroutinesDispatcherProvider.io) {
|
||||
uploadFilesFromSystemUseCase(
|
||||
UploadFilesFromSystemUseCase.Params(
|
||||
accountName = fileToUpload.owner,
|
||||
listOfLocalPaths = listOf(fileToUpload.storagePath!!),
|
||||
uploadFolderPath = fileToUpload.getParentRemotePath(),
|
||||
spaceId = fileToUpload.spaceId,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Abel García de Prada
|
||||
* Copyright (C) 2020 ownCloud GmbH.
|
||||
* <p>
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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 eu.qsfera.android.presentation.documentsprovider
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.DocumentsContract
|
||||
import eu.qsfera.android.R
|
||||
|
||||
object DocumentsProviderUtils {
|
||||
/**
|
||||
* Notify Document Provider to refresh roots
|
||||
*/
|
||||
fun notifyDocumentsProviderRoots(context: Context) {
|
||||
val authority = context.resources.getString(R.string.document_provider_authority)
|
||||
val rootsUri = DocumentsContract.buildRootsUri(authority)
|
||||
context.contentResolver.notifyChange(rootsUri, null)
|
||||
}
|
||||
}
|
||||
+812
@@ -0,0 +1,812 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Bartosz Przybylski
|
||||
* @author Christian Schabesberger
|
||||
* @author David González Verdugo
|
||||
* @author Abel García de Prada
|
||||
* @author Shashvat Kedia
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
*
|
||||
* Copyright (C) 2015 Bartosz Przybylski
|
||||
* Copyright (C) 2023 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.presentation.documentsprovider
|
||||
|
||||
import android.content.res.AssetFileDescriptor
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.graphics.Point
|
||||
import android.net.Uri
|
||||
import android.os.CancellationSignal
|
||||
import android.os.Handler
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.DocumentsProvider
|
||||
import eu.qsfera.android.MainApp
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.data.providers.SharedPreferencesProvider
|
||||
import eu.qsfera.android.domain.UseCaseResult
|
||||
import eu.qsfera.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase
|
||||
import eu.qsfera.android.domain.exceptions.NoConnectionWithServerException
|
||||
import eu.qsfera.android.domain.exceptions.validation.FileNameException
|
||||
import eu.qsfera.android.domain.files.model.OCFile
|
||||
import eu.qsfera.android.domain.files.model.OCFile.Companion.PATH_SEPARATOR
|
||||
import eu.qsfera.android.domain.files.model.OCFile.Companion.ROOT_PATH
|
||||
import eu.qsfera.android.domain.files.usecases.CopyFileUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.CreateFolderAsyncUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.GetFileByIdUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.GetFileByRemotePathUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.GetFolderContentUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.MoveFileUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.RemoveFileUseCase
|
||||
import eu.qsfera.android.domain.files.usecases.RenameFileUseCase
|
||||
import eu.qsfera.android.domain.spaces.usecases.GetPersonalAndProjectSpacesForAccountUseCase
|
||||
import eu.qsfera.android.domain.spaces.usecases.RefreshSpacesFromServerAsyncUseCase
|
||||
import eu.qsfera.android.presentation.authentication.AccountUtils
|
||||
import eu.qsfera.android.presentation.documentsprovider.cursors.FileCursor
|
||||
import eu.qsfera.android.presentation.documentsprovider.cursors.RootCursor
|
||||
import eu.qsfera.android.presentation.documentsprovider.cursors.SpaceCursor
|
||||
import eu.qsfera.android.presentation.settings.security.SettingsSecurityFragment.Companion.PREFERENCE_LOCK_ACCESS_FROM_DOCUMENT_PROVIDER
|
||||
import eu.qsfera.android.usecases.synchronization.SynchronizeFileUseCase
|
||||
import eu.qsfera.android.usecases.transfers.downloads.DownloadFileUseCase
|
||||
import eu.qsfera.android.usecases.synchronization.SynchronizeFolderUseCase
|
||||
import eu.qsfera.android.usecases.transfers.uploads.UploadFilesFromSystemUseCase
|
||||
import eu.qsfera.android.utils.FileStorageUtils
|
||||
import eu.qsfera.android.utils.NotificationUtils
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.ext.android.inject
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
import java.util.Vector
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CompletableFuture
|
||||
|
||||
class DocumentsStorageProvider : DocumentsProvider() {
|
||||
/**
|
||||
* If a directory requires to sync, it will write the id of the directory into this variable.
|
||||
* After the sync function gets triggered again over the same directory, it will see that a sync got already
|
||||
* triggered, and does not need to be triggered again. This way a endless loop is prevented.
|
||||
*/
|
||||
private var requestedFolderIdForSync: Long = -1
|
||||
private var syncRequired = true
|
||||
|
||||
private var spacesSyncRequired = true
|
||||
|
||||
private lateinit var fileToUpload: OCFile
|
||||
|
||||
// Cache to avoid redundant PROPFINDs when apps (e.g. Google Photos) call
|
||||
// openDocument many times for the same file. Two layers:
|
||||
// 1. In-flight dedup: concurrent calls for the same file share one PROPFIND via
|
||||
// a CompletableFuture. The first caller does the actual work, others wait.
|
||||
// 2. TTL cache: after a sync completes, skip re-checking the same file for a
|
||||
// few seconds to handle rapid sequential calls.
|
||||
private val inFlightSyncs = ConcurrentHashMap<Long, CompletableFuture<SynchronizeFileUseCase.SyncType?>>()
|
||||
private val inFlightDownloads = ConcurrentHashMap<Long, CompletableFuture<Boolean>>()
|
||||
private var propfindCacheFileId: Long? = null
|
||||
private var propfindCacheTimestamp: Long = 0
|
||||
|
||||
override fun openDocument(
|
||||
documentId: String,
|
||||
mode: String,
|
||||
signal: CancellationSignal?,
|
||||
): ParcelFileDescriptor? {
|
||||
Timber.d("Trying to open $documentId in mode $mode")
|
||||
|
||||
// If documentId == NONEXISTENT_DOCUMENT_ID only Upload is needed because file does not exist in our database yet.
|
||||
var ocFile: OCFile
|
||||
val uploadOnly: Boolean = documentId == NONEXISTENT_DOCUMENT_ID || documentId == "null"
|
||||
|
||||
var accessMode: Int = ParcelFileDescriptor.parseMode(mode)
|
||||
val isWrite: Boolean = mode.contains("w")
|
||||
|
||||
if (!uploadOnly) {
|
||||
ocFile = getFileByIdOrException(documentId.toInt())
|
||||
|
||||
if (!ocFile.isAvailableLocally) {
|
||||
// File has never been downloaded. Enqueue the download directly —
|
||||
// no need for a PROPFIND since we already know we need the file.
|
||||
// Apps like Google Photos call openDocument concurrently for the
|
||||
// same file — dedup so only one download is enqueued.
|
||||
if (!downloadFileCoalesced(ocFile.id!!, ocFile, documentId.toInt(), signal)) {
|
||||
return null
|
||||
}
|
||||
ocFile = getFileByIdOrException(documentId.toInt())
|
||||
if (!ocFile.isAvailableLocally) {
|
||||
return null
|
||||
}
|
||||
// Seed the TTL cache — the file was just downloaded, so there's no need
|
||||
// for a PROPFIND if Google Photos immediately calls openDocument again.
|
||||
propfindCacheFileId = ocFile.id
|
||||
propfindCacheTimestamp = System.currentTimeMillis()
|
||||
} else if (!isWrite) {
|
||||
// File is available locally and opened for reading. Check with the server
|
||||
// (PROPFIND) whether a newer version exists, and download it if so.
|
||||
//
|
||||
// Apps like Google Photos call openDocument many times concurrently for
|
||||
// the same file. Without dedup, each call does its own PROPFIND, and due
|
||||
// to the synchronized lock in QSferaClient.executeHttpMethod they
|
||||
// serialize — causing 10+ second waits per extra call. We handle this
|
||||
// with two layers:
|
||||
// 1. TTL cache: skip if we just confirmed this file is up-to-date.
|
||||
// 2. In-flight dedup: concurrent calls share one PROPFIND result.
|
||||
val fileId = ocFile.id!!
|
||||
val now = System.currentTimeMillis()
|
||||
if (fileId == propfindCacheFileId && now - propfindCacheTimestamp <= PROPFIND_CACHE_TTL_MS) {
|
||||
Timber.d("Skipping PROPFIND for file $fileId, recently synced ${now - propfindCacheTimestamp}ms ago")
|
||||
} else {
|
||||
val syncResult = syncFileWithServerCoalesced(ocFile)
|
||||
|
||||
when (syncResult) {
|
||||
is SynchronizeFileUseCase.SyncType.AlreadySynchronized -> {
|
||||
// File is up to date, nothing to wait for.
|
||||
}
|
||||
is SynchronizeFileUseCase.SyncType.DownloadEnqueued -> {
|
||||
// A newer version exists. SynchronizeFileUseCase only enqueues
|
||||
// a WorkManager download, it does not wait for it to finish.
|
||||
if (!waitForDownload(syncResult.workerId, documentId.toInt(), signal)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
is SynchronizeFileUseCase.SyncType.ConflictDetected -> {
|
||||
// File changed both locally and remotely. Notify the user and
|
||||
// serve the local version (same behavior as before).
|
||||
context?.let {
|
||||
NotificationUtils.notifyConflict(fileInConflict = ocFile, context = it)
|
||||
}
|
||||
}
|
||||
is SynchronizeFileUseCase.SyncType.FileNotFound -> {
|
||||
return null
|
||||
}
|
||||
is SynchronizeFileUseCase.SyncType.UploadEnqueued -> {
|
||||
// Local file is newer, upload was enqueued. Serve the local version.
|
||||
}
|
||||
null -> {
|
||||
// Sync failed, serve the local version anyway.
|
||||
}
|
||||
}
|
||||
|
||||
propfindCacheFileId = fileId
|
||||
propfindCacheTimestamp = System.currentTimeMillis()
|
||||
|
||||
// Re-read the file from DB to get the updated state after download.
|
||||
ocFile = getFileByIdOrException(documentId.toInt())
|
||||
if (!ocFile.isAvailableLocally) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ocFile = fileToUpload
|
||||
accessMode = accessMode or ParcelFileDescriptor.MODE_CREATE
|
||||
}
|
||||
|
||||
val fileToOpen = File(ocFile.storagePath)
|
||||
|
||||
return if (!isWrite) {
|
||||
ParcelFileDescriptor.open(fileToOpen, accessMode)
|
||||
} else {
|
||||
val handler = Handler(MainApp.appContext.mainLooper)
|
||||
// Attach a close listener if the document is opened in write mode.
|
||||
try {
|
||||
ParcelFileDescriptor.open(fileToOpen, accessMode, handler) {
|
||||
// Update the file with the cloud server. The client is done writing.
|
||||
Timber.d("A file with id $documentId has been closed! Time to synchronize it with server.")
|
||||
// If only needs to upload that file
|
||||
if (uploadOnly) {
|
||||
ocFile.length = fileToOpen.length()
|
||||
val uploadFilesUseCase: UploadFilesFromSystemUseCase by inject()
|
||||
val uploadFilesUseCaseParams = UploadFilesFromSystemUseCase.Params(
|
||||
accountName = ocFile.owner,
|
||||
listOfLocalPaths = listOf(fileToOpen.path),
|
||||
uploadFolderPath = ocFile.remotePath.substringBeforeLast(PATH_SEPARATOR).plus(PATH_SEPARATOR),
|
||||
spaceId = ocFile.spaceId,
|
||||
)
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
uploadFilesUseCase(uploadFilesUseCaseParams)
|
||||
}
|
||||
} else {
|
||||
syncFileWithServerAsync(ocFile)
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Timber.e(e, "Couldn't open document")
|
||||
throw FileNotFoundException("Failed to open document with id $documentId and mode $mode")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun queryChildDocuments(
|
||||
parentDocumentId: String,
|
||||
projection: Array<String>?,
|
||||
sortOrder: String?,
|
||||
): Cursor {
|
||||
val resultCursor: MatrixCursor
|
||||
|
||||
val folderId = try {
|
||||
parentDocumentId.toLong()
|
||||
} catch (numberFormatException: NumberFormatException) {
|
||||
null
|
||||
}
|
||||
|
||||
// Folder id is null, so at this point we need to list the spaces for the account.
|
||||
if (folderId == null) {
|
||||
resultCursor = SpaceCursor(projection)
|
||||
|
||||
val getPersonalAndProjectSpacesForAccountUseCase: GetPersonalAndProjectSpacesForAccountUseCase by inject()
|
||||
val getFileByRemotePathUseCase: GetFileByRemotePathUseCase by inject()
|
||||
|
||||
getPersonalAndProjectSpacesForAccountUseCase(
|
||||
GetPersonalAndProjectSpacesForAccountUseCase.Params(
|
||||
accountName = parentDocumentId,
|
||||
)
|
||||
).forEach { space ->
|
||||
if (!space.isDisabled) {
|
||||
getFileByRemotePathUseCase(
|
||||
GetFileByRemotePathUseCase.Params(
|
||||
owner = space.accountName,
|
||||
remotePath = ROOT_PATH,
|
||||
spaceId = space.id,
|
||||
)
|
||||
).getDataOrNull()?.let { rootFolder ->
|
||||
resultCursor.addSpace(space, rootFolder, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This will start syncing the spaces. User will only see this after updating his view with a
|
||||
* pull down, or by accessing the spaces folder.
|
||||
*/
|
||||
if (spacesSyncRequired) {
|
||||
syncSpacesWithServer(parentDocumentId)
|
||||
resultCursor.setMoreToSync(true)
|
||||
}
|
||||
|
||||
spacesSyncRequired = true
|
||||
} else {
|
||||
// Folder id is not null, so this is a regular folder
|
||||
resultCursor = FileCursor(projection)
|
||||
|
||||
// Create result cursor before syncing folder again, in order to enable faster loading
|
||||
getFolderContent(folderId.toInt()).forEach { file -> resultCursor.addFile(file) }
|
||||
|
||||
/**
|
||||
* This will start syncing the current folder. User will only see this after updating his view with a
|
||||
* pull down, or by accessing the folder again.
|
||||
*/
|
||||
if (requestedFolderIdForSync != folderId && syncRequired) {
|
||||
// register for sync
|
||||
syncDirectoryWithServer(parentDocumentId)
|
||||
requestedFolderIdForSync = folderId
|
||||
resultCursor.setMoreToSync(true)
|
||||
} else {
|
||||
requestedFolderIdForSync = -1
|
||||
}
|
||||
|
||||
syncRequired = true
|
||||
}
|
||||
|
||||
// Create notification listener
|
||||
val notifyUri: Uri = toNotifyUri(toUri(parentDocumentId))
|
||||
resultCursor.setNotificationUri(context?.contentResolver, notifyUri)
|
||||
|
||||
return resultCursor
|
||||
|
||||
}
|
||||
|
||||
override fun queryDocument(documentId: String, projection: Array<String>?): Cursor {
|
||||
Timber.d("Query Document: $documentId")
|
||||
if (documentId == NONEXISTENT_DOCUMENT_ID) return FileCursor(projection).apply {
|
||||
addFile(fileToUpload)
|
||||
}
|
||||
|
||||
val fileId = try {
|
||||
documentId.toInt()
|
||||
} catch (numberFormatException: NumberFormatException) {
|
||||
null
|
||||
}
|
||||
|
||||
return if (fileId != null) {
|
||||
// file id is not null, this is a regular file.
|
||||
FileCursor(projection).apply {
|
||||
addFile(getFileByIdOrException(fileId))
|
||||
}
|
||||
} else {
|
||||
// file id is null, so at this point this is the root folder for spaces supported account.
|
||||
SpaceCursor(projection).apply {
|
||||
addRootForSpaces(context = context, accountName = documentId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(): Boolean = true
|
||||
|
||||
override fun queryRoots(projection: Array<String>?): Cursor {
|
||||
val result = RootCursor(projection)
|
||||
val contextApp = context ?: return result
|
||||
val accounts = AccountUtils.getAccounts(contextApp)
|
||||
|
||||
// If access from document provider is not allowed, return empty cursor
|
||||
val preferences: SharedPreferencesProvider by inject()
|
||||
val lockAccessFromDocumentProvider = preferences.getBoolean(PREFERENCE_LOCK_ACCESS_FROM_DOCUMENT_PROVIDER, false)
|
||||
return if (lockAccessFromDocumentProvider && accounts.isNotEmpty()) {
|
||||
result.apply { addProtectedRoot(contextApp) }
|
||||
} else {
|
||||
for (account in accounts) {
|
||||
val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase by inject()
|
||||
val capabilities = getStoredCapabilitiesUseCase(
|
||||
GetStoredCapabilitiesUseCase.Params(
|
||||
accountName = account.name
|
||||
)
|
||||
)
|
||||
val spacesFeatureAllowedForAccount = AccountUtils.isSpacesFeatureAllowedForAccount(contextApp, account, capabilities)
|
||||
|
||||
result.addRoot(account, contextApp, spacesFeatureAllowedForAccount)
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
override fun openDocumentThumbnail(
|
||||
documentId: String,
|
||||
sizeHint: Point?,
|
||||
signal: CancellationSignal?
|
||||
): AssetFileDescriptor {
|
||||
// To do: Show thumbnail for spaces
|
||||
val file = getFileByIdOrException(documentId.toInt())
|
||||
|
||||
val realFile = File(file.storagePath)
|
||||
|
||||
return AssetFileDescriptor(
|
||||
ParcelFileDescriptor.open(realFile, ParcelFileDescriptor.MODE_READ_ONLY), 0, AssetFileDescriptor.UNKNOWN_LENGTH
|
||||
)
|
||||
}
|
||||
|
||||
override fun querySearchDocuments(
|
||||
rootId: String,
|
||||
query: String,
|
||||
projection: Array<String>?
|
||||
): Cursor {
|
||||
val result = FileCursor(projection)
|
||||
|
||||
val root = getFileByPathOrException(ROOT_PATH, AccountUtils.getCurrentQSferaAccount(context).name)
|
||||
|
||||
for (f in findFiles(root, query)) {
|
||||
result.addFile(f)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
override fun createDocument(
|
||||
parentDocumentId: String,
|
||||
mimeType: String,
|
||||
displayName: String,
|
||||
): String {
|
||||
Timber.d("Create Document ParentID $parentDocumentId Type $mimeType DisplayName $displayName")
|
||||
val parentDocument = getFileByIdOrException(parentDocumentId.toInt())
|
||||
|
||||
return if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
|
||||
createFolder(parentDocument, displayName)
|
||||
} else {
|
||||
createFile(parentDocument, mimeType, displayName)
|
||||
}
|
||||
}
|
||||
|
||||
override fun renameDocument(documentId: String, displayName: String): String? {
|
||||
Timber.d("Trying to rename $documentId to $displayName")
|
||||
|
||||
val file = getFileByIdOrException(documentId.toInt())
|
||||
|
||||
val renameFileUseCase: RenameFileUseCase by inject()
|
||||
renameFileUseCase(RenameFileUseCase.Params(file, displayName)).also {
|
||||
checkUseCaseResult(
|
||||
it, file.parentId.toString()
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
override fun deleteDocument(documentId: String) {
|
||||
Timber.d("Trying to delete $documentId")
|
||||
val file = getFileByIdOrException(documentId.toInt())
|
||||
|
||||
val removeFileUseCase: RemoveFileUseCase by inject()
|
||||
removeFileUseCase(RemoveFileUseCase.Params(listOf(file), false)).also {
|
||||
checkUseCaseResult(
|
||||
it, file.parentId.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun copyDocument(sourceDocumentId: String, targetParentDocumentId: String): String {
|
||||
Timber.d("Trying to copy $sourceDocumentId to $targetParentDocumentId")
|
||||
|
||||
val sourceFile = getFileByIdOrException(sourceDocumentId.toInt())
|
||||
val targetParentFile = getFileByIdOrException(targetParentDocumentId.toInt())
|
||||
|
||||
val copyFileUseCase: CopyFileUseCase by inject()
|
||||
|
||||
copyFileUseCase(
|
||||
CopyFileUseCase.Params(
|
||||
listOfFilesToCopy = listOf(sourceFile),
|
||||
targetFolder = targetParentFile,
|
||||
replace = listOf(false),
|
||||
isUserLogged = AccountUtils.getCurrentQSferaAccount(context) != null,
|
||||
)
|
||||
).also { result ->
|
||||
syncRequired = false
|
||||
checkUseCaseResult(result, targetParentFile.id.toString())
|
||||
// Returns the document id of the document copied at the target destination
|
||||
var newPath = targetParentFile.remotePath + sourceFile.fileName
|
||||
if (sourceFile.isFolder) newPath += File.separator
|
||||
val newFile = getFileByPathOrException(newPath, targetParentFile.owner)
|
||||
return newFile.id.toString()
|
||||
}
|
||||
}
|
||||
|
||||
override fun moveDocument(
|
||||
sourceDocumentId: String,
|
||||
sourceParentDocumentId: String,
|
||||
targetParentDocumentId: String,
|
||||
): String {
|
||||
Timber.d("Trying to move $sourceDocumentId to $targetParentDocumentId")
|
||||
|
||||
val sourceFile = getFileByIdOrException(sourceDocumentId.toInt())
|
||||
val targetParentFile = getFileByIdOrException(targetParentDocumentId.toInt())
|
||||
|
||||
val moveFileUseCase: MoveFileUseCase by inject()
|
||||
|
||||
moveFileUseCase(
|
||||
MoveFileUseCase.Params(
|
||||
listOfFilesToMove = listOf(sourceFile),
|
||||
targetFolder = targetParentFile,
|
||||
replace = listOf(false),
|
||||
isUserLogged = AccountUtils.getCurrentQSferaAccount(context) != null,
|
||||
)
|
||||
).also { result ->
|
||||
syncRequired = false
|
||||
checkUseCaseResult(result, targetParentFile.id.toString())
|
||||
// Returns the document id of the document moved to the target destination
|
||||
var newPath = targetParentFile.remotePath + sourceFile.fileName
|
||||
if (sourceFile.isFolder) newPath += File.separator
|
||||
val newFile = getFileByPathOrException(newPath, targetParentFile.owner)
|
||||
return newFile.id.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkUseCaseResult(result: UseCaseResult<Any>, folderToNotify: String) {
|
||||
if (!result.isSuccess) {
|
||||
Timber.e(result.getThrowableOrNull()!!)
|
||||
if (result.getThrowableOrNull() is FileNameException) {
|
||||
throw UnsupportedOperationException("Operation contains at least one invalid character")
|
||||
}
|
||||
if (result.getThrowableOrNull() !is NoConnectionWithServerException) {
|
||||
notifyChangeInFolder(folderToNotify)
|
||||
}
|
||||
throw FileNotFoundException("Remote Operation failed")
|
||||
}
|
||||
syncRequired = false
|
||||
notifyChangeInFolder(folderToNotify)
|
||||
}
|
||||
|
||||
private fun createFolder(parentDocument: OCFile, displayName: String): String {
|
||||
Timber.d("Trying to create a new folder with name $displayName and parent ${parentDocument.remotePath}")
|
||||
|
||||
val createFolderAsyncUseCase: CreateFolderAsyncUseCase by inject()
|
||||
|
||||
createFolderAsyncUseCase(CreateFolderAsyncUseCase.Params(displayName, parentDocument)).run {
|
||||
checkUseCaseResult(this, parentDocument.id.toString())
|
||||
val newPath = parentDocument.remotePath + displayName + File.separator
|
||||
val newFolder = getFileByPathOrException(newPath, parentDocument.owner, parentDocument.spaceId)
|
||||
return newFolder.id.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createFile(
|
||||
parentDocument: OCFile,
|
||||
mimeType: String,
|
||||
displayName: String,
|
||||
): String {
|
||||
// We just need to return a Document ID, so we'll return an empty one. File does not exist in our db yet.
|
||||
// File will be created at [openDocument] method.
|
||||
val tempDir = File(FileStorageUtils.getTemporalPath(parentDocument.owner, parentDocument.spaceId))
|
||||
val newFile = File(tempDir, displayName)
|
||||
newFile.parentFile?.mkdirs()
|
||||
fileToUpload = OCFile(
|
||||
remotePath = parentDocument.remotePath + displayName,
|
||||
mimeType = mimeType,
|
||||
parentId = parentDocument.id,
|
||||
owner = parentDocument.owner,
|
||||
spaceId = parentDocument.spaceId
|
||||
).apply {
|
||||
storagePath = newFile.path
|
||||
}
|
||||
|
||||
return NONEXISTENT_DOCUMENT_ID
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize a file with the server, coalescing concurrent requests.
|
||||
*
|
||||
* If another thread is already syncing this file, we wait for its result instead of
|
||||
* starting a second PROPFIND. This avoids the serialized lock contention in
|
||||
* QSferaClient.executeHttpMethod when multiple binder threads call openDocument
|
||||
* for the same file simultaneously.
|
||||
*
|
||||
* The future is always removed from [inFlightSyncs] when done (via finally),
|
||||
* so errors or timeouts cannot leave stale entries that would block future syncs.
|
||||
*/
|
||||
private fun syncFileWithServerCoalesced(fileToSync: OCFile): SynchronizeFileUseCase.SyncType? {
|
||||
val fileId = fileToSync.id!!
|
||||
val newFuture = CompletableFuture<SynchronizeFileUseCase.SyncType?>()
|
||||
val existingFuture = inFlightSyncs.putIfAbsent(fileId, newFuture)
|
||||
|
||||
if (existingFuture != null) {
|
||||
// Another thread is already syncing this file. Wait for its result.
|
||||
Timber.d("Sync for file $fileId already in flight, waiting for result")
|
||||
return try {
|
||||
existingFuture.get()
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "In-flight sync for file $fileId failed, serving local version")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// We are the first thread — do the actual PROPFIND.
|
||||
return try {
|
||||
val result = syncFileWithServerBlocking(fileToSync)
|
||||
newFuture.complete(result)
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
newFuture.completeExceptionally(e)
|
||||
throw e
|
||||
} finally {
|
||||
inFlightSyncs.remove(fileId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file, deduplicating concurrent requests for the same file.
|
||||
*
|
||||
* Same pattern as [syncFileWithServerCoalesced]: the first thread enqueues the
|
||||
* WorkManager download and waits; concurrent threads wait on the same future.
|
||||
* This prevents apps like Google Photos (which call openDocument 4+ times
|
||||
* concurrently) from enqueuing 4 separate download workers for the same file.
|
||||
*
|
||||
* @return true if the download succeeded, false otherwise.
|
||||
*/
|
||||
private fun downloadFileCoalesced(fileId: Long, ocFile: OCFile, docId: Int, signal: CancellationSignal?): Boolean {
|
||||
val newFuture = CompletableFuture<Boolean>()
|
||||
val existingFuture = inFlightDownloads.putIfAbsent(fileId, newFuture)
|
||||
|
||||
if (existingFuture != null) {
|
||||
Timber.d("Download for file $fileId already in flight, waiting")
|
||||
return try { existingFuture.get() } catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
return try {
|
||||
val downloadFileUseCase: DownloadFileUseCase by inject()
|
||||
val workerId = downloadFileUseCase(
|
||||
DownloadFileUseCase.Params(accountName = ocFile.owner, file = ocFile)
|
||||
)
|
||||
val ok = waitForDownload(workerId, docId, signal)
|
||||
newFuture.complete(ok)
|
||||
ok
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Download for file $fileId failed")
|
||||
newFuture.complete(false)
|
||||
false
|
||||
} finally {
|
||||
inFlightDownloads.remove(fileId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize a file with the server and return the result.
|
||||
* Runs synchronously on the calling thread (blocks until the PROPFIND completes).
|
||||
* Note: if a download is needed, this only *enqueues* it — use [waitForDownload] to
|
||||
* wait for the actual download to finish.
|
||||
*/
|
||||
private fun syncFileWithServerBlocking(fileToSync: OCFile): SynchronizeFileUseCase.SyncType? {
|
||||
Timber.d("Trying to sync file ${fileToSync.id} with server (blocking)")
|
||||
|
||||
val synchronizeFileUseCase: SynchronizeFileUseCase by inject()
|
||||
val useCaseResult = synchronizeFileUseCase(
|
||||
SynchronizeFileUseCase.Params(fileToSynchronize = fileToSync)
|
||||
)
|
||||
Timber.d("${fileToSync.remotePath} from ${fileToSync.owner} synced with result: $useCaseResult")
|
||||
|
||||
return useCaseResult.getDataOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget sync: used in the close handler after writes,
|
||||
* where we don't need to wait for the result.
|
||||
*/
|
||||
private fun syncFileWithServerAsync(fileToSync: OCFile) {
|
||||
Timber.d("Trying to sync file ${fileToSync.id} with server (async)")
|
||||
|
||||
val synchronizeFileUseCase: SynchronizeFileUseCase by inject()
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val useCaseResult = synchronizeFileUseCase(
|
||||
SynchronizeFileUseCase.Params(fileToSynchronize = fileToSync)
|
||||
)
|
||||
Timber.d("${fileToSync.remotePath} from ${fileToSync.owner} synced with result: $useCaseResult")
|
||||
|
||||
if (useCaseResult.getDataOrNull() is SynchronizeFileUseCase.SyncType.ConflictDetected) {
|
||||
context?.let {
|
||||
NotificationUtils.notifyConflict(fileInConflict = fileToSync, context = it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a download to finish.
|
||||
*
|
||||
* If [workerId] is non-null, we use WorkManager to wait directly for that specific job.
|
||||
* If [workerId] is null, it means a download for this file was already in progress
|
||||
* (enqueued by a previous call), so we fall back to polling the DB until the file
|
||||
* becomes available locally.
|
||||
*
|
||||
* Note: openDocument can be called concurrently on multiple binder threads for the
|
||||
* same file (e.g. the calling app retries or requests the file multiple times).
|
||||
* The first call enqueues the download and gets a workerId; subsequent concurrent
|
||||
* calls get null (DownloadFileUseCase deduplicates) and use the polling fallback.
|
||||
*
|
||||
* @return true if the file is ready, false if cancelled.
|
||||
*/
|
||||
private fun waitForDownload(workerId: UUID?, fileId: Int, signal: CancellationSignal?): Boolean {
|
||||
if (workerId != null) {
|
||||
// Poll WorkManager until this specific job reaches a terminal state.
|
||||
// Note: getWorkInfoById().get() returns the *current* state immediately,
|
||||
// it does NOT block until the work finishes.
|
||||
Timber.d("Waiting for download worker $workerId to finish")
|
||||
val workManager = WorkManager.getInstance(context!!)
|
||||
do {
|
||||
if (!waitOrGetCancelled(signal)) {
|
||||
return false
|
||||
}
|
||||
val workInfo = workManager.getWorkInfoById(workerId).get()
|
||||
Timber.d("Download worker $workerId state: ${workInfo.state}")
|
||||
when (workInfo.state) {
|
||||
WorkInfo.State.SUCCEEDED -> return true
|
||||
WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> return false
|
||||
else -> { /* ENQUEUED, RUNNING, BLOCKED — keep waiting */ }
|
||||
}
|
||||
} while (true)
|
||||
}
|
||||
|
||||
// workerId is null — a download was already in progress from a previous request.
|
||||
// Poll until the file appears locally, checking for cancellation each second.
|
||||
Timber.d("Download already in progress for file $fileId, polling until available")
|
||||
do {
|
||||
if (!waitOrGetCancelled(signal)) {
|
||||
return false
|
||||
}
|
||||
val file = getFileByIdOrException(fileId)
|
||||
if (file.isAvailableLocally) return true
|
||||
} while (true)
|
||||
}
|
||||
|
||||
private fun syncDirectoryWithServer(parentDocumentId: String) {
|
||||
Timber.d("Trying to sync $parentDocumentId with server")
|
||||
val folderToSync = getFileByIdOrException(parentDocumentId.toInt())
|
||||
|
||||
val synchronizeFolderUseCase: SynchronizeFolderUseCase by inject()
|
||||
val synchronizeFolderUseCaseParams = SynchronizeFolderUseCase.Params(
|
||||
remotePath = folderToSync.remotePath,
|
||||
accountName = folderToSync.owner,
|
||||
spaceId = folderToSync.spaceId,
|
||||
syncMode = SynchronizeFolderUseCase.SyncFolderMode.REFRESH_FOLDER,
|
||||
)
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val useCaseResult = synchronizeFolderUseCase(synchronizeFolderUseCaseParams)
|
||||
Timber.d("${folderToSync.remotePath} from ${folderToSync.owner} was synced with server with result: $useCaseResult")
|
||||
|
||||
if (useCaseResult.isSuccess) {
|
||||
notifyChangeInFolder(parentDocumentId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncSpacesWithServer(parentDocumentId: String) {
|
||||
Timber.d("Trying to sync spaces from account $parentDocumentId with server")
|
||||
|
||||
val refreshSpacesFromServerAsyncUseCase: RefreshSpacesFromServerAsyncUseCase by inject()
|
||||
val refreshSpacesFromServerAsyncUseCaseParams = RefreshSpacesFromServerAsyncUseCase.Params(
|
||||
accountName = parentDocumentId,
|
||||
)
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val useCaseResult = refreshSpacesFromServerAsyncUseCase(refreshSpacesFromServerAsyncUseCaseParams)
|
||||
Timber.d("Spaces from account were synced with server with result: $useCaseResult")
|
||||
|
||||
if (useCaseResult.isSuccess) {
|
||||
notifyChangeInFolder(parentDocumentId)
|
||||
}
|
||||
spacesSyncRequired = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun waitOrGetCancelled(cancellationSignal: CancellationSignal?): Boolean {
|
||||
try {
|
||||
Thread.sleep(1000)
|
||||
} catch (e: InterruptedException) {
|
||||
return false
|
||||
}
|
||||
|
||||
return cancellationSignal == null || !cancellationSignal.isCanceled
|
||||
}
|
||||
|
||||
private fun findFiles(root: OCFile, query: String): Vector<OCFile> {
|
||||
val result = Vector<OCFile>()
|
||||
|
||||
val folderContent = getFolderContent(root.id!!.toInt())
|
||||
folderContent.forEach {
|
||||
if (it.fileName.contains(query)) {
|
||||
result.add(it)
|
||||
if (it.isFolder) result.addAll(findFiles(it, query))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun notifyChangeInFolder(folderToNotify: String) {
|
||||
context?.contentResolver?.notifyChange(toNotifyUri(toUri(folderToNotify)), null)
|
||||
}
|
||||
|
||||
private fun toNotifyUri(uri: Uri): Uri = DocumentsContract.buildDocumentUri(
|
||||
context?.resources?.getString(R.string.document_provider_authority), uri.toString()
|
||||
)
|
||||
|
||||
private fun toUri(documentId: String): Uri = Uri.parse(documentId)
|
||||
|
||||
private fun getFileByIdOrException(id: Int): OCFile {
|
||||
val getFileByIdUseCase: GetFileByIdUseCase by inject()
|
||||
val result = getFileByIdUseCase(GetFileByIdUseCase.Params(id.toLong()))
|
||||
return result.getDataOrNull() ?: throw FileNotFoundException("File $id not found")
|
||||
}
|
||||
|
||||
private fun getFileByPathOrException(remotePath: String, accountName: String, spaceId: String? = null): OCFile {
|
||||
val getFileByRemotePathUseCase: GetFileByRemotePathUseCase by inject()
|
||||
val result =
|
||||
getFileByRemotePathUseCase(GetFileByRemotePathUseCase.Params(owner = accountName, remotePath = remotePath, spaceId = spaceId))
|
||||
return result.getDataOrNull() ?: throw FileNotFoundException("File $remotePath not found")
|
||||
}
|
||||
|
||||
private fun getFolderContent(id: Int): List<OCFile> {
|
||||
val getFolderContentUseCase: GetFolderContentUseCase by inject()
|
||||
val result = getFolderContentUseCase(GetFolderContentUseCase.Params(id.toLong()))
|
||||
return result.getDataOrNull() ?: throw FileNotFoundException("Folder $id not found")
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NONEXISTENT_DOCUMENT_ID = "-1"
|
||||
const val PROPFIND_CACHE_TTL_MS = 3000L
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* qsfera Android client application
|
||||
*
|
||||
* @author Bartosz Przybylski
|
||||
* @author Abel García de Prada
|
||||
* @author Juan Carlos Garrote Gascón
|
||||
*
|
||||
* Copyright (C) 2015 Bartosz Przybylski
|
||||
* Copyright (C) 2023 ownCloud GmbH.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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 eu.qsfera.android.presentation.documentsprovider.cursors
|
||||
|
||||
import android.database.MatrixCursor
|
||||
import android.os.Bundle
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.DocumentsContract.Document
|
||||
import eu.qsfera.android.domain.files.model.OCFile
|
||||
import eu.qsfera.android.utils.MimetypeIconUtil
|
||||
|
||||
class FileCursor(projection: Array<String>?) : MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) {
|
||||
private var cursorExtras = Bundle.EMPTY
|
||||
|
||||
override fun getExtras(): Bundle = cursorExtras
|
||||
|
||||
fun setMoreToSync(hasMoreToSync: Boolean) {
|
||||
cursorExtras = Bundle().apply { putBoolean(DocumentsContract.EXTRA_LOADING, hasMoreToSync) }
|
||||
}
|
||||
|
||||
fun addFile(file: OCFile) {
|
||||
val iconRes = MimetypeIconUtil.getFileTypeIconId(file.mimeType, file.fileName)
|
||||
val mimeType = if (file.isFolder) Document.MIME_TYPE_DIR else file.mimeType
|
||||
val imagePath = if (file.isImage && file.isAvailableLocally) file.storagePath else null
|
||||
var flags = if (imagePath != null) Document.FLAG_SUPPORTS_THUMBNAIL else 0
|
||||
|
||||
flags = flags or Document.FLAG_SUPPORTS_DELETE
|
||||
flags = flags or Document.FLAG_SUPPORTS_RENAME
|
||||
flags = flags or Document.FLAG_SUPPORTS_COPY
|
||||
flags = flags or Document.FLAG_SUPPORTS_MOVE
|
||||
|
||||
if (mimeType != Document.MIME_TYPE_DIR) { // If it is a file
|
||||
flags = flags or Document.FLAG_SUPPORTS_WRITE
|
||||
} else if (file.hasAddFilePermission && file.hasAddSubdirectoriesPermission) { // If it is a folder with writing permissions
|
||||
flags = flags or Document.FLAG_DIR_SUPPORTS_CREATE
|
||||
}
|
||||
|
||||
newRow()
|
||||
.add(Document.COLUMN_DOCUMENT_ID, file.id.toString())
|
||||
.add(Document.COLUMN_DISPLAY_NAME, file.fileName)
|
||||
.add(Document.COLUMN_LAST_MODIFIED, file.modificationTimestamp)
|
||||
.add(Document.COLUMN_SIZE, file.length)
|
||||
.add(Document.COLUMN_FLAGS, flags)
|
||||
.add(Document.COLUMN_ICON, iconRes)
|
||||
.add(Document.COLUMN_MIME_TYPE, mimeType)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val DEFAULT_DOCUMENT_PROJECTION = arrayOf(
|
||||
Document.COLUMN_DOCUMENT_ID,
|
||||
Document.COLUMN_DISPLAY_NAME,
|
||||
Document.COLUMN_MIME_TYPE,
|
||||
Document.COLUMN_SIZE,
|
||||
Document.COLUMN_FLAGS,
|
||||
Document.COLUMN_LAST_MODIFIED
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user