Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
@@ -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"
}
}
@@ -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) }
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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"
}
}
@@ -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) }
}
}
@@ -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))
}
}
@@ -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"
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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())
}
}
@@ -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()))
}
}
@@ -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
)
)
)
)
}
}
@@ -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))
)
}
}
@@ -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)))
}
}
@@ -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))
}
}
@@ -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())))
}
}
@@ -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?) {
}
}
@@ -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)
}
@@ -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
)
)
@@ -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)
}
}
@@ -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")
}
}
}
@@ -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)))
}
@@ -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)
}
}
@@ -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
}
}
}
@@ -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()) }
}
@@ -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
}
@@ -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)
}
@@ -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
}
@@ -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)
}
@@ -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
}
}
@@ -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")
}
}
}
@@ -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);
}
}
@@ -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)
}
}
@@ -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 }
}
}
}
@@ -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
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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)
);
}
}
}
}
}
@@ -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)
}
}
@@ -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"
@@ -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()
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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)
}
}
}
}
@@ -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)
}
}
@@ -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"
}
}
@@ -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
}
}
}
@@ -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,
)
)
}
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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