Merge pull request #2844 from omnivore-app/feat/android-self-hosting-options

Add self hosted settings to Android app
This commit is contained in:
Jackson Harper
2023-10-03 20:55:01 +08:00
committed by GitHub
7 changed files with 243 additions and 11 deletions

View File

@ -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"

View File

@ -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()
}

View File

@ -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()
}

View File

@ -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

View File

@ -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)
)
}
}
}

View File

@ -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))
}

View File

@ -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