From f81e238f89d9d0464ac2b57ec95291172f8972b9 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Tue, 3 Oct 2023 17:16:25 +0800 Subject: [PATCH] Add self hosted settings to Android app --- .../java/app/omnivore/omnivore/Constants.kt | 2 + .../app/omnivore/omnivore/RESTNetworker.kt | 6 +- .../omnivore/omnivore/networking/Networker.kt | 8 +- .../omnivore/ui/auth/LoginViewModel.kt | 47 ++++- .../omnivore/ui/auth/SelfHostedView.kt | 176 ++++++++++++++++++ .../omnivore/ui/auth/WelcomeScreen.kt | 14 ++ android/Omnivore/gradle.properties | 1 + 7 files changed, 243 insertions(+), 11 deletions(-) create mode 100644 android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/SelfHostedView.kt diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/Constants.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/Constants.kt index fe5d75097..27b2829f9 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/Constants.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/Constants.kt @@ -6,6 +6,8 @@ object Constants { } object DatastoreKeys { + const val omnivoreSelfHostedAPIServer = "omnivoreSelfHostedAPIServer" + const val omnivoreSelfHostedWebServer = "omnivoreSelfHostedWebServer" const val omnivoreAuthToken = "omnivoreAuthToken" const val omnivoreAuthCookieString = "omnivoreAuthCookieString" const val omnivorePendingUserToken = "omnivorePendingUserToken" diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/RESTNetworker.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/RESTNetworker.kt index 11be50365..f1ade8dbd 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/RESTNetworker.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/RESTNetworker.kt @@ -1,5 +1,6 @@ package app.omnivore.omnivore +import app.omnivore.omnivore.networking.Networker import retrofit2.Response import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory @@ -81,8 +82,9 @@ interface CreateEmailAccountSubmit { } object RetrofitHelper { - fun getInstance(): Retrofit { - return Retrofit.Builder().baseUrl(Constants.apiURL) + suspend fun getInstance(networker: Networker): Retrofit { + val baseUrl = networker.baseUrl() + return Retrofit.Builder().baseUrl(baseUrl) .addConverterFactory(GsonConverterFactory.create()) .build() } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/Networker.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/Networker.kt index 926195dd2..4b6a811d3 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/Networker.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/Networker.kt @@ -9,15 +9,17 @@ import javax.inject.Inject class Networker @Inject constructor( private val datastoreRepo: DatastoreRepository ) { - private val serverUrl = "${Constants.apiURL}/api/graphql" + suspend fun baseUrl() = datastoreRepo.getString(DatastoreKeys.omnivoreSelfHostedAPIServer) ?: Constants.apiURL + + suspend fun serverUrl() = "${baseUrl()}/api/graphql" private suspend fun authToken() = datastoreRepo.getString(DatastoreKeys.omnivoreAuthToken) ?: "" suspend fun publicApolloClient() = ApolloClient.Builder() - .serverUrl(serverUrl) + .serverUrl(serverUrl()) .build() suspend fun authenticatedApolloClient() = ApolloClient.Builder() - .serverUrl(serverUrl) + .serverUrl(serverUrl()) .addHttpHeader("Authorization", value = authToken()) .build() } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/LoginViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/LoginViewModel.kt index 25b9a559a..e451c2b30 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/LoginViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/LoginViewModel.kt @@ -1,5 +1,7 @@ package app.omnivore.omnivore.ui.auth +import android.content.Context +import android.widget.Toast import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -24,7 +26,8 @@ enum class RegistrationState { SocialLogin, EmailSignIn, EmailSignUp, - PendingUser + PendingUser, + SelfHosted } data class PendingEmailUserCreds( @@ -67,6 +70,31 @@ class LoginViewModel @Inject constructor( datastoreRepo.getString(DatastoreKeys.omnivoreAuthCookieString) } + fun setSelfHostingDetails(context: Context, apiServer: String, webServer: String) { + viewModelScope.launch { + datastoreRepo.putString(DatastoreKeys.omnivoreSelfHostedAPIServer, apiServer) + datastoreRepo.putString(DatastoreKeys.omnivoreSelfHostedWebServer, webServer) + Toast.makeText( + context, + "Self-hosting settings updated.", + Toast.LENGTH_SHORT + ).show() + } + } + + fun resetSelfHostingDetails(context: Context) { + viewModelScope.launch { + datastoreRepo.clearValue(DatastoreKeys.omnivoreSelfHostedAPIServer) + datastoreRepo.clearValue(DatastoreKeys.omnivoreSelfHostedWebServer) + Toast.makeText( + context, + "Self-hosting settings reset.", + Toast.LENGTH_SHORT + ).show() + } + + + } fun showSocialLogin() { resetState() registrationStateLiveData.value = RegistrationState.SocialLogin @@ -83,6 +111,11 @@ class LoginViewModel @Inject constructor( registrationStateLiveData.value = RegistrationState.EmailSignUp } + fun showSelfHostedSettings(pendingCreds: PendingEmailUserCreds? = null) { + resetState() + registrationStateLiveData.value = RegistrationState.SelfHosted + } + fun cancelNewUserSignUp() { resetState() viewModelScope.launch { @@ -162,9 +195,10 @@ class LoginViewModel @Inject constructor( } fun login(email: String, password: String) { - val emailLogin = RetrofitHelper.getInstance().create(EmailLoginSubmit::class.java) viewModelScope.launch { + val emailLogin = RetrofitHelper.getInstance(networker).create(EmailLoginSubmit::class.java) + isLoading = true errorMessage = null @@ -200,7 +234,7 @@ class LoginViewModel @Inject constructor( name: String, ) { viewModelScope.launch { - val request = RetrofitHelper.getInstance().create(CreateEmailAccountSubmit::class.java) + val request = RetrofitHelper.getInstance(networker).create(CreateEmailAccountSubmit::class.java) isLoading = true errorMessage = null @@ -230,7 +264,7 @@ class LoginViewModel @Inject constructor( fun submitProfile(username: String, name: String) { viewModelScope.launch { - val request = RetrofitHelper.getInstance().create(CreateAccountSubmit::class.java) + val request = RetrofitHelper.getInstance(networker).create(CreateAccountSubmit::class.java) isLoading = true errorMessage = null @@ -300,9 +334,10 @@ class LoginViewModel @Inject constructor( } private fun submitAuthProviderPayload(params: SignInParams) { - val login = RetrofitHelper.getInstance().create(AuthProviderLoginSubmit::class.java) viewModelScope.launch { + val login = RetrofitHelper.getInstance(networker).create(AuthProviderLoginSubmit::class.java) + isLoading = true errorMessage = null @@ -340,7 +375,7 @@ class LoginViewModel @Inject constructor( isLoading = true errorMessage = null - val request = RetrofitHelper.getInstance().create(PendingUserSubmit::class.java) + val request = RetrofitHelper.getInstance(networker).create(PendingUserSubmit::class.java) val result = request.submitPendingUser(params) isLoading = false diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/SelfHostedView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/SelfHostedView.kt new file mode 100644 index 000000000..8df9fe148 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/SelfHostedView.kt @@ -0,0 +1,176 @@ +package app.omnivore.omnivore.ui.auth + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.view.ViewGroup +import android.webkit.CookieManager +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Toast +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import app.omnivore.omnivore.BuildConfig +import app.omnivore.omnivore.DatastoreKeys + +@SuppressLint("CoroutineCreationDuringComposition") +@Composable +fun SelfHostedView(viewModel: LoginViewModel) { + var apiServer by rememberSaveable { mutableStateOf("") } + var webServer by rememberSaveable { mutableStateOf("") } + var context = LocalContext.current + + Row( + horizontalArrangement = Arrangement.Center + ) { + Spacer(modifier = Modifier.weight(1.0F)) + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + SelfHostedFields( + apiServer, + webServer, + onAPIServerChange = { apiServer = it }, + onWebServerChange = { webServer = it }, + onSaveClick = { + viewModel.setSelfHostingDetails(context, apiServer, webServer) + } + ) + + // TODO: add a activity indicator (maybe after a delay?) + if (viewModel.isLoading) { + Text("Loading...") + } + + Row( + horizontalArrangement = Arrangement.Center, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + ClickableText( + text = AnnotatedString("Reset"), + style = MaterialTheme.typography.titleMedium + .plus(TextStyle(textDecoration = TextDecoration.Underline)), + onClick = { viewModel.resetSelfHostingDetails(context) }, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + ClickableText( + text = AnnotatedString("Back"), + style = MaterialTheme.typography.titleMedium + .plus(TextStyle(textDecoration = TextDecoration.Underline)), + onClick = { viewModel.showSocialLogin() }, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + Spacer(modifier = Modifier.weight(1.0F)) +// Text("Omnivore is a free and open-source software project and allows self hosting. \n\n" + +// "If you have chosen to deploy your own server instance, fill in the above fields to " + +// "your private self-hosted instance.\n\n" +// ) + ClickableText( + text = AnnotatedString("Learn more about self-hosting Omnivore"), + style = MaterialTheme.typography.titleMedium + .plus(TextStyle(textDecoration = TextDecoration.Underline)), + onClick = { + val uri = Uri.parse("https://docs.omnivore.app/self-hosting/self-hosting.html") + val browserIntent = Intent(Intent.ACTION_VIEW, uri) + ContextCompat.startActivity(context, browserIntent, null) + }, + modifier = Modifier.padding(vertical = 10.dp) + ) + } + } + } + Spacer(modifier = Modifier.weight(1.0F)) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SelfHostedFields( + apiServer: String, + webServer: String, + onAPIServerChange: (String) -> Unit, + onWebServerChange: (String) -> Unit, + onSaveClick: () -> Unit +) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + + Column( + modifier = Modifier + .fillMaxWidth() + .height(300.dp), + verticalArrangement = Arrangement.spacedBy(25.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedTextField( + value = apiServer, + placeholder = { Text(text = "https://api-prod.omnivore.app/") }, + label = { Text(text = "API Server") }, + onValueChange = onAPIServerChange, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + keyboardType = KeyboardType.Uri, + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) + ) + + OutlinedTextField( + value = webServer, + placeholder = { Text(text = "https://omnivore.app/") }, + label = { Text(text = "Web Server") }, + onValueChange = onWebServerChange, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + keyboardType = KeyboardType.Uri, + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) + ) + + Button(onClick = { + if (apiServer.isNotBlank() && webServer.isNotBlank()) { + onSaveClick() + focusManager.clearFocus() + } else { + Toast.makeText( + context, + "Please enter API Server and Web server addresses.", + Toast.LENGTH_SHORT + ).show() + } + }, colors = ButtonDefaults.buttonColors( + contentColor = Color(0xFF3D3D3D), + containerColor = Color(0xffffd234) + ) + ) { + Text( + text = "Save", + modifier = Modifier.padding(horizontal = 100.dp) + ) + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/WelcomeScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/WelcomeScreen.kt index 49685aa69..efe3bfe75 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/WelcomeScreen.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/WelcomeScreen.kt @@ -68,6 +68,9 @@ fun WelcomeScreenContent(viewModel: LoginViewModel) { RegistrationState.EmailSignUp -> { EmailSignUpView(viewModel = viewModel) } + RegistrationState.SelfHosted -> { + SelfHostedView(viewModel = viewModel) + } RegistrationState.SocialLogin -> { Text( text = stringResource(id = R.string.welcome_title), @@ -137,6 +140,17 @@ fun AuthProviderView(viewModel: LoginViewModel) { .plus(TextStyle(textDecoration = TextDecoration.Underline)), onClick = { viewModel.showEmailSignIn() } ) + + Spacer(modifier = Modifier.weight(1.0F)) + + ClickableText( + text = AnnotatedString("Self-hosting options"), + style = MaterialTheme.typography.titleMedium + .plus(TextStyle(textDecoration = TextDecoration.Underline)), + onClick = { viewModel.showSelfHostedSettings() }, + modifier = Modifier + .padding(vertical = 10.dp) + ) } Spacer(modifier = Modifier.weight(1.0F)) } diff --git a/android/Omnivore/gradle.properties b/android/Omnivore/gradle.properties index 3c7a8bd3a..b5987b651 100644 --- a/android/Omnivore/gradle.properties +++ b/android/Omnivore/gradle.properties @@ -15,6 +15,7 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # Android operating system, and which are packaged with your app"s APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true +android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the