mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge branch 'main' of github.com:omnivore-app/omnivore into feat/1079
This commit is contained in:
commit
fc0e18be12
47 changed files with 1143 additions and 597 deletions
4
android/Omnivore/app/.gitignore
vendored
4
android/Omnivore/app/.gitignore
vendored
|
|
@ -1 +1,3 @@
|
|||
/build
|
||||
/build
|
||||
keystore.properties
|
||||
*.keystore
|
||||
|
|
@ -6,6 +6,10 @@ plugins {
|
|||
id 'com.apollographql.apollo3' version '3.5.0'
|
||||
}
|
||||
|
||||
def keystorePropertiesFile = rootProject.file("app/external/keystore.properties");
|
||||
def keystoreProperties = new Properties()
|
||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||
|
||||
android {
|
||||
compileSdk 33
|
||||
|
||||
|
|
@ -13,8 +17,8 @@ android {
|
|||
applicationId "app.omnivore.omnivore"
|
||||
minSdk 23
|
||||
targetSdk 32
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
versionCode 2
|
||||
versionName "0.0.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
|
|
@ -22,9 +26,32 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
signingConfigs{
|
||||
release{
|
||||
keyAlias 'key0'
|
||||
storeFile file('external/omnivore-prod.keystore')
|
||||
storePassword keystoreProperties['prodStorePassword']
|
||||
keyPassword keystoreProperties['prodKeyPassword']
|
||||
}
|
||||
debug{
|
||||
keyAlias 'androiddebugkey'
|
||||
storeFile file('external/omnivore-demo.keystore')
|
||||
storePassword keystoreProperties['demoStorePassword']
|
||||
keyPassword keystoreProperties['demoKeyPassword']
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug{
|
||||
signingConfig signingConfigs.debug
|
||||
buildConfigField("String", "OMNIVORE_API_URL", "\"https://api-demo.omnivore.app\"")
|
||||
buildConfigField("String", "OMNIVORE_GAUTH_SERVER_CLIENT_ID", "\"267918240109-eu2ar09unac3lqqigluknhk7t0021b54.apps.googleusercontent.com\"")
|
||||
}
|
||||
release {
|
||||
minifyEnabled false
|
||||
signingConfig signingConfigs.release
|
||||
buildConfigField("String", "OMNIVORE_API_URL", "\"https://api-prod.omnivore.app\"")
|
||||
buildConfigField("String", "OMNIVORE_GAUTH_SERVER_CLIENT_ID", "\"687911924401-lq8j1e97n0sv3khhb8g8n368lk4dqkbp.apps.googleusercontent.com\"")
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
|
|
@ -128,6 +155,7 @@ dependencies {
|
|||
implementation 'androidx.compose.material3:material3-window-size-class:1.0.0-alpha16'
|
||||
|
||||
implementation 'com.google.android.gms:play-services-auth:20.2.0'
|
||||
implementation "com.google.accompanist:accompanist-systemuicontroller:0.25.1"
|
||||
}
|
||||
|
||||
apollo {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".NewFlowActivity"
|
||||
android:name=".ui.save.NewFlowActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.AppCompat.Translucent">
|
||||
<intent-filter>
|
||||
|
|
@ -38,26 +38,5 @@
|
|||
<data android:mimeType="text/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!--
|
||||
<activity
|
||||
android:name=".SaveActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="application/pdf" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
-->
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package app.omnivore.omnivore
|
||||
|
||||
object Constants {
|
||||
const val demoProdURL = "https://api-demo.omnivore.app"
|
||||
const val apiURL = BuildConfig.OMNIVORE_API_URL
|
||||
const val dataStoreName = "omnivore-datastore"
|
||||
}
|
||||
|
||||
|
|
@ -9,3 +9,11 @@ object DatastoreKeys {
|
|||
const val omnivoreAuthToken = "omnivoreAuthToken"
|
||||
const val omnivoreAuthCookieString = "omnivoreAuthCookieString"
|
||||
}
|
||||
|
||||
object AppleConstants {
|
||||
const val clientId = "app.omnivore"
|
||||
const val redirectURI = "https://api-demo.omnivore.app/api/auth/vercel/apple-redirect"
|
||||
const val scope = "name%20email"
|
||||
const val authUrl = "https://appleid.apple.com/auth/authorize"
|
||||
const val tokenUrl = "https://appleid.apple.com/auth/token"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,46 +1,17 @@
|
|||
package app.omnivore.omnivore
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.omnivore.omnivore.ui.theme.OmnivoreTheme
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignIn
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignInClient
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
import com.google.android.gms.common.GooglePlayServicesUtil.isGooglePlayServicesAvailable
|
||||
import com.google.android.gms.tasks.Task
|
||||
import app.omnivore.omnivore.ui.auth.LoginViewModel
|
||||
import app.omnivore.omnivore.ui.root.RootView
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
|
@ -51,11 +22,7 @@ class MainActivity : ComponentActivity() {
|
|||
|
||||
setContent {
|
||||
OmnivoreTheme {
|
||||
// A surface container using the 'background' color from the theme
|
||||
ScreenMain(viewModel = viewModel)
|
||||
// Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
// WelcomeView(viewModel)
|
||||
// }
|
||||
RootView(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -69,170 +36,3 @@ class MainActivity : ComponentActivity() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WelcomeView(viewModel: LoginViewModel) {
|
||||
val hasAuthToken: Boolean by viewModel.hasAuthTokenLiveData.observeAsState(false)
|
||||
|
||||
if (hasAuthToken) {
|
||||
LoggedInView(viewModel)
|
||||
} else {
|
||||
LoginView(viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoginView(viewModel: LoginViewModel) {
|
||||
val isGoogleAuthAvailable: Boolean = GoogleApiAvailability
|
||||
.getInstance()
|
||||
.isGooglePlayServicesAvailable(LocalContext.current) == 0
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
if (isGoogleAuthAvailable) {
|
||||
GoogleAuthButton(viewModel)
|
||||
}
|
||||
EmailLoginView(viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoggedInView(viewModel: LoginViewModel) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Text("You have a valid auth token. Nice. Go save something in Chrome!")
|
||||
|
||||
Button(onClick = {
|
||||
viewModel.logout()
|
||||
}) {
|
||||
Text(text = "Logout")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("CoroutineCreationDuringComposition")
|
||||
@Composable
|
||||
fun EmailLoginView(viewModel: LoginViewModel) {
|
||||
var email by rememberSaveable { mutableStateOf("") }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
val focusManager = LocalFocusManager.current
|
||||
val snackBarHostState = remember { SnackbarHostState() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.fillMaxSize()
|
||||
.clickable { focusManager.clearFocus() }
|
||||
) {
|
||||
LoginFields(
|
||||
email,
|
||||
password,
|
||||
onEmailChange = { email = it },
|
||||
onPasswordChange = { password = it },
|
||||
onLoginClick = { viewModel.login(email, password) }
|
||||
)
|
||||
|
||||
// TODO: add a activity indicator (maybe after a delay?)
|
||||
if (viewModel.isLoading) {
|
||||
Text("Loading...")
|
||||
}
|
||||
|
||||
if (viewModel.errorMessage != null) {
|
||||
coroutineScope.launch {
|
||||
val result = snackBarHostState
|
||||
.showSnackbar(
|
||||
viewModel.errorMessage!!,
|
||||
actionLabel = "Dismiss",
|
||||
duration = SnackbarDuration.Indefinite
|
||||
)
|
||||
when (result) {
|
||||
SnackbarResult.ActionPerformed -> viewModel.resetErrorMessage()
|
||||
}
|
||||
}
|
||||
|
||||
SnackbarHost(hostState = snackBarHostState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LoginFields(
|
||||
email: String,
|
||||
password: String,
|
||||
onEmailChange: (String) -> Unit,
|
||||
onPasswordChange: (String) -> Unit,
|
||||
onLoginClick: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(25.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("Never miss a great read")
|
||||
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
placeholder = { Text(text = "user@email.com") },
|
||||
label = { Text(text = "email") },
|
||||
onValueChange = onEmailChange,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
placeholder = { Text(text = "password") },
|
||||
label = { Text(text = "password") },
|
||||
onValueChange = onPasswordChange,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
|
||||
)
|
||||
|
||||
Button(onClick = {
|
||||
if (email.isNotBlank() && password.isNotBlank()) {
|
||||
onLoginClick()
|
||||
focusManager.clearFocus()
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Please enter an email address and password.",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}) {
|
||||
Text(text = "Login")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//@Preview(
|
||||
// uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
// showBackground = true,
|
||||
// name = "Dark Mode"
|
||||
//)
|
||||
//@Preview(showBackground = true)
|
||||
//@Composable
|
||||
//fun DefaultPreview() {
|
||||
// OmnivoreTheme {
|
||||
// LoginView()
|
||||
// }
|
||||
//}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ interface AuthProviderLoginSubmit {
|
|||
|
||||
object RetrofitHelper {
|
||||
fun getInstance(): Retrofit {
|
||||
return Retrofit.Builder().baseUrl(Constants.demoProdURL)
|
||||
return Retrofit.Builder().baseUrl(Constants.apiURL)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package app.omnivore.omnivore
|
||||
|
||||
sealed class Routes(val route: String) {
|
||||
object Splash : Routes("Splash")
|
||||
object EmailLogin : Routes("EmailLogin")
|
||||
object Root : Routes("Root")
|
||||
object Home : Routes("Home")
|
||||
object Welcome : Routes("Welcome")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
package app.omnivore.omnivore
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import app.omnivore.omnivore.screen.EmailLoginPage
|
||||
import app.omnivore.omnivore.screen.SplashPage
|
||||
|
||||
@Composable
|
||||
fun ScreenMain(viewModel: LoginViewModel){
|
||||
val navController = rememberNavController()
|
||||
|
||||
NavHost(navController = navController, startDestination = Routes.Splash.route) {
|
||||
composable(Routes.Splash.route) {
|
||||
SplashPage(viewModel = viewModel, navController = navController)
|
||||
}
|
||||
composable(Routes.EmailLogin.route) {
|
||||
EmailLoginPage(viewModel = viewModel, navController = navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
package app.omnivore.omnivore.screen
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavHostController
|
||||
import app.omnivore.omnivore.EmailLoginView
|
||||
import app.omnivore.omnivore.LoginViewModel
|
||||
|
||||
|
||||
@Composable
|
||||
fun EmailLoginPage(viewModel: LoginViewModel, navController: NavHostController) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
EmailLoginView(viewModel)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
package app.omnivore.omnivore.screen
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.core.content.ContextCompat.startActivity
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavHostController
|
||||
import app.omnivore.omnivore.EmailLoginView
|
||||
import app.omnivore.omnivore.GoogleAuthButton
|
||||
import app.omnivore.omnivore.LoginViewModel
|
||||
import app.omnivore.omnivore.Routes
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun SplashPage(viewModel: LoginViewModel, navController: NavHostController) {
|
||||
val isGoogleAuthAvailable: Boolean = GoogleApiAvailability
|
||||
.getInstance()
|
||||
.isGooglePlayServicesAvailable(LocalContext.current) == 0
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
Text("Never miss a great read")
|
||||
MoreInfoButton()
|
||||
|
||||
if (isGoogleAuthAvailable) {
|
||||
GoogleAuthButton(viewModel)
|
||||
}
|
||||
|
||||
ContinueWithEmailButton(navController)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MoreInfoButton() {
|
||||
val context = LocalContext.current
|
||||
val intent = remember { Intent(Intent.ACTION_VIEW, Uri.parse("https://omnivore.app/about")) }
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("Learn More ->"),
|
||||
onClick = {
|
||||
context.startActivity(intent)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ContinueWithEmailButton(navController: NavHostController) {
|
||||
ClickableText(
|
||||
text = AnnotatedString("Continue with Email ->"),
|
||||
onClick = {
|
||||
navController.navigate(Routes.EmailLogin.route)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
package app.omnivore.omnivore.ui.auth
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.TopAppBar
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import app.omnivore.omnivore.AppleConstants
|
||||
import app.omnivore.omnivore.R
|
||||
import java.util.*
|
||||
|
||||
@Composable
|
||||
fun AppleAuthButton(viewModel: LoginViewModel) {
|
||||
val showDialog = remember { mutableStateOf(false) }
|
||||
|
||||
LoadingButtonWithIcon(
|
||||
text = "Continue with Apple",
|
||||
loadingText = "Signing in...",
|
||||
isLoading = viewModel.isLoading,
|
||||
icon = painterResource(id = R.drawable.ic_logo_apple),
|
||||
modifier = Modifier.padding(vertical = 6.dp),
|
||||
onClick = { showDialog.value = true }
|
||||
)
|
||||
|
||||
if (showDialog.value) {
|
||||
AppleAuthDialog(onDismiss = {
|
||||
showDialog.value = false
|
||||
Log.i("Apple payload: ", it ?: "null")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppleAuthDialog(onDismiss: (String?) -> Unit) {
|
||||
Dialog(onDismissRequest = { onDismiss(null) }) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = Color.White
|
||||
) {
|
||||
AppleAuthWebContainerView(onDismiss)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
||||
@Composable
|
||||
fun AppleAuthWebContainerView(onDismiss: (String?) -> Unit) {
|
||||
Scaffold(
|
||||
topBar = { TopAppBar(title = { Text("WebView", color = Color.White) }, backgroundColor = Color(0xff0f9d58)) },
|
||||
content = { AppleAuthWebView(onDismiss) }
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
fun AppleAuthWebView(onDismiss: (String?) -> Unit) {
|
||||
val url = AppleConstants.authUrl +
|
||||
"?client_id=" +
|
||||
AppleConstants.clientId +
|
||||
"&redirect_uri=" +
|
||||
AppleConstants.redirectURI +
|
||||
"&response_type=code%20id_token&scope=" +
|
||||
AppleConstants.scope +
|
||||
"&response_mode=form_post&state=android:login"
|
||||
|
||||
// clientId="app.omnivore"
|
||||
// scope="name email"
|
||||
// state="web:login"
|
||||
// redirectURI={appleAuthRedirectURI}
|
||||
// responseMode="form_post"
|
||||
// responseType="code id_token"
|
||||
// designProp={{
|
||||
// color: 'black',
|
||||
|
||||
// Adding a WebView inside AndroidView
|
||||
// with layout as full screen
|
||||
AndroidView(factory = {
|
||||
WebView(it).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
// webViewClient = WebViewClient()
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||
Log.i("Apple payload one: ", request?.url.toString() ?: "null")
|
||||
if (request?.url.toString().startsWith(AppleConstants.redirectURI)) {
|
||||
// handleUrl(request?.url.toString())
|
||||
onDismiss(request?.url.toString())
|
||||
// Close the dialog after getting the authorization code
|
||||
if (request?.url.toString().contains("success=")) {
|
||||
onDismiss(null)
|
||||
}
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
settings.javaScriptEnabled = true
|
||||
loadUrl(url)
|
||||
}
|
||||
}, update = {
|
||||
it.loadUrl(url)
|
||||
})
|
||||
}
|
||||
|
||||
//// A client to know about WebView navigation
|
||||
//// For API 21 and above
|
||||
//class AppleWebViewClient : WebViewClient() {
|
||||
// @TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
// override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||
// if (request?.url.toString().startsWith(AppleConstants.redirectURI)) {
|
||||
// handleUrl(request?.url.toString())
|
||||
// // Close the dialog after getting the authorization code
|
||||
// if (request.url.toString().contains("success=")) {
|
||||
//// appledialog.dismiss()
|
||||
// }
|
||||
// return true
|
||||
// }
|
||||
// return true
|
||||
// }
|
||||
|
||||
// // For API 19 and below
|
||||
// override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
|
||||
// if (url.startsWith(AppleConstants.redirectURI)) {
|
||||
// handleUrl(url)
|
||||
// // Close the dialog after getting the authorization code
|
||||
// if (url.contains("success=")) {
|
||||
//// appledialog.dismiss()
|
||||
// }
|
||||
// return true
|
||||
// }
|
||||
// return false
|
||||
// }
|
||||
|
||||
// @SuppressLint("ClickableViewAccessibility")
|
||||
// override fun onPageFinished(view: WebView?, url: String?) {
|
||||
// super.onPageFinished(view, url)
|
||||
// // retrieve display dimensions
|
||||
// val displayRectangle = Rect()
|
||||
// val window = this@AppleWebViewClient.w
|
||||
// window.decorView.getWindowVisibleDisplayFrame(displayRectangle)
|
||||
// // Set height of the Dialog to 90% of the screen
|
||||
// val layoutParams = view?.layoutParams
|
||||
// layoutParams?.height = (displayRectangle.height() * 0.9f).toInt()
|
||||
// view?.layoutParams = layoutParams
|
||||
// }
|
||||
|
||||
// // Check WebView url for access token code or error
|
||||
// @SuppressLint("LongLogTag")
|
||||
// private fun handleUrl(url: String) {
|
||||
// val uri = Uri.parse(url)
|
||||
// val success = uri.getQueryParameter("success")
|
||||
// if (success == "true") {
|
||||
// // Get the Authorization Code from the URL
|
||||
//// appleAuthCode = uri.getQueryParameter("code") ?: ""
|
||||
//// Log.i("Apple Code: ", appleAuthCode)
|
||||
// // Get the Client Secret from the URL
|
||||
//// appleClientSecret = uri.getQueryParameter("client_secret") ?: ""
|
||||
//// Log.i("Apple Client Secret: ", appleClientSecret)
|
||||
// //Check if user gave access to the app for the first time by checking if the url contains their email
|
||||
// if (url.contains("email")) {
|
||||
// //Get user's First Name
|
||||
// val firstName = uri.getQueryParameter("first_name")
|
||||
// Log.i("Apple User First Name: ", firstName ?: "")
|
||||
// //Get user's Middle Name
|
||||
// val middleName = uri.getQueryParameter("middle_name")
|
||||
// Log.i("Apple User Middle Name: ", middleName ?: "")
|
||||
// //Get user's Last Name
|
||||
// val lastName = uri.getQueryParameter("last_name")
|
||||
// Log.i("Apple User Last Name: ", lastName ?: "")
|
||||
// //Get user's email
|
||||
// val email = uri.getQueryParameter("email")
|
||||
// Log.i("Apple User Email: ", email ?: "Not exists")
|
||||
// }
|
||||
// // Exchange the Auth Code for Access Token
|
||||
//// requestForAccessToken(appleAuthCode, appleClientSecret)
|
||||
// } else if (success == "false") {
|
||||
// Log.e("ERROR", "We couldn't get the Auth Code")
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
package app.omnivore.omnivore.ui.auth
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
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.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@SuppressLint("CoroutineCreationDuringComposition")
|
||||
@Composable
|
||||
fun EmailLoginView(viewModel: LoginViewModel, onAuthProviderButtonTap: () -> Unit) {
|
||||
var email by rememberSaveable { mutableStateOf("") }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1.0F))
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
LoginFields(
|
||||
email,
|
||||
password,
|
||||
onEmailChange = { email = it },
|
||||
onPasswordChange = { password = it },
|
||||
onLoginClick = { viewModel.login(email, password) }
|
||||
)
|
||||
|
||||
// TODO: add a activity indicator (maybe after a delay?)
|
||||
if (viewModel.isLoading) {
|
||||
Text("Loading...")
|
||||
}
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("Return to Social Login"),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
|
||||
onClick = { onAuthProviderButtonTap() }
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1.0F))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LoginFields(
|
||||
email: String,
|
||||
password: String,
|
||||
onEmailChange: (String) -> Unit,
|
||||
onPasswordChange: (String) -> Unit,
|
||||
onLoginClick: () -> 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 = email,
|
||||
placeholder = { Text(text = "user@email.com") },
|
||||
label = { Text(text = "Email") },
|
||||
onValueChange = onEmailChange,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
placeholder = { Text(text = "Password") },
|
||||
label = { Text(text = "Password") },
|
||||
onValueChange = onPasswordChange,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
|
||||
)
|
||||
|
||||
Button(onClick = {
|
||||
if (email.isNotBlank() && password.isNotBlank()) {
|
||||
onLoginClick()
|
||||
focusManager.clearFocus()
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Please enter an email address and password.",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}, colors = ButtonDefaults.buttonColors(
|
||||
contentColor = Color(0xFF3D3D3D),
|
||||
containerColor = Color(0xffffd234)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "Login",
|
||||
modifier = Modifier.padding(horizontal = 100.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +1,27 @@
|
|||
package app.omnivore.omnivore
|
||||
package app.omnivore.omnivore.ui.auth
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.omnivore.omnivore.BuildConfig
|
||||
import app.omnivore.omnivore.R
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignIn
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
|
||||
import com.google.android.gms.tasks.Task
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun GoogleAuthButton(viewModel: LoginViewModel) {
|
||||
val context = LocalContext.current
|
||||
|
||||
|
||||
val signInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
|
||||
.requestIdToken(BuildConfig.OMNIVORE_GAUTH_SERVER_CLIENT_ID)
|
||||
.requestEmail()
|
||||
.requestIdToken(stringResource(R.string.gcp_id))
|
||||
.requestId()
|
||||
.requestProfile()
|
||||
.build()
|
||||
|
||||
val startForResult =
|
||||
|
|
@ -37,17 +32,30 @@ fun GoogleAuthButton(viewModel: LoginViewModel) {
|
|||
val task: Task<GoogleSignInAccount> = GoogleSignIn.getSignedInAccountFromIntent(intent)
|
||||
viewModel.handleGoogleAuthTask(task)
|
||||
}
|
||||
} else {
|
||||
viewModel.showGoogleErrorMessage()
|
||||
}
|
||||
}
|
||||
|
||||
GoogleSignInButton(
|
||||
LoadingButtonWithIcon(
|
||||
text = "Continue with Google",
|
||||
loadingText = "Signing in...",
|
||||
isLoading = viewModel.isLoading,
|
||||
icon = painterResource(id = R.drawable.ic_logo_google),
|
||||
onClick = {
|
||||
val googleSignIn = GoogleSignIn.getClient(context, signInOptions)
|
||||
startForResult.launch(googleSignIn.signInIntent)
|
||||
|
||||
googleSignIn.silentSignIn()
|
||||
.addOnCompleteListener { task ->
|
||||
if (task.isSuccessful) {
|
||||
viewModel.handleGoogleAuthTask(task)
|
||||
} else {
|
||||
startForResult.launch(googleSignIn.signInIntent)
|
||||
}
|
||||
}
|
||||
.addOnFailureListener {
|
||||
startForResult.launch(googleSignIn.signInIntent)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
package app.omnivore.omnivore
|
||||
package app.omnivore.omnivore.ui.auth
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -14,21 +13,21 @@ import androidx.compose.ui.graphics.Shape
|
|||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@ExperimentalMaterialApi
|
||||
@Composable
|
||||
fun GoogleSignInButton(
|
||||
fun LoadingButtonWithIcon(
|
||||
text: String,
|
||||
loadingText: String = "Signing in...",
|
||||
loadingText: String,
|
||||
icon: Painter,
|
||||
isLoading: Boolean = false,
|
||||
shape: Shape = Shapes().medium,
|
||||
borderColor: Color = Color.LightGray,
|
||||
backgroundColor: Color = MaterialTheme.colors.surface,
|
||||
progressIndicatorColor: Color = MaterialTheme.colors.primary,
|
||||
backgroundColor: Color = MaterialTheme.colorScheme.surface,
|
||||
progressIndicatorColor: Color = MaterialTheme.colorScheme.primary,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.clickable(
|
||||
modifier = modifier.clickable(
|
||||
enabled = !isLoading,
|
||||
onClick = onClick
|
||||
),
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
package app.omnivore.omnivore
|
||||
package app.omnivore.omnivore.ui.auth
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.lifecycle.*
|
||||
import app.omnivore.omnivore.*
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
|
||||
import com.google.android.gms.common.api.ApiException
|
||||
import com.google.android.gms.tasks.Task
|
||||
|
|
@ -16,6 +15,12 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
|||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
enum class RegistrationState {
|
||||
AuthProviderButtons,
|
||||
EmailSignIn
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class LoginViewModel @Inject constructor(
|
||||
private val datastoreRepo: DatastoreRepository
|
||||
|
|
@ -73,13 +78,21 @@ class LoginViewModel @Inject constructor(
|
|||
errorMessage = null
|
||||
}
|
||||
|
||||
fun handleGoogleAuthTask(task: Task<GoogleSignInAccount>) {
|
||||
val googleIdToken = task?.getResult(ApiException::class.java).idToken
|
||||
Log.d(ContentValues.TAG, "Google Result?: $googleIdToken")
|
||||
// TODO: submit id token to backend
|
||||
// If token is missing then set the error message
|
||||
fun showGoogleErrorMessage() {
|
||||
errorMessage = "Failed to authenticate with Google."
|
||||
}
|
||||
|
||||
fun handleGoogleAuthTask(task: Task<GoogleSignInAccount>) {
|
||||
val result = task?.getResult(ApiException::class.java)
|
||||
Log.d(ContentValues.TAG, "server auth code?: ${result.serverAuthCode}")
|
||||
Log.d(ContentValues.TAG, "is Expired?: ${result.isExpired}")
|
||||
Log.d(ContentValues.TAG, "granted Scopes?: ${result.grantedScopes}")
|
||||
val googleIdToken = result.idToken
|
||||
Log.d(ContentValues.TAG, "Google id token?: $googleIdToken")
|
||||
|
||||
// If token is missing then set the error message
|
||||
if (googleIdToken == null) {
|
||||
errorMessage = "No authentication token found."
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -98,7 +111,7 @@ class LoginViewModel @Inject constructor(
|
|||
if (result.body()?.authToken != null) {
|
||||
datastoreRepo.putString(DatastoreKeys.omnivoreAuthToken, result.body()?.authToken!!)
|
||||
} else {
|
||||
errorMessage = "Something went wrong. Please check your email/password and try again"
|
||||
errorMessage = "Something went wrong. Please check your credentials and try again"
|
||||
}
|
||||
|
||||
if (result.body()?.authCookieString != null) {
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
package app.omnivore.omnivore.ui.auth
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.omnivore.omnivore.R
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun WelcomeScreen(viewModel: LoginViewModel) {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = Color(0xFFFCEBA8)) {
|
||||
WelcomeScreenContent(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("CoroutineCreationDuringComposition")
|
||||
@Composable
|
||||
fun WelcomeScreenContent(viewModel: LoginViewModel) {
|
||||
var registrationState by rememberSaveable { mutableStateOf(RegistrationState.AuthProviderButtons) }
|
||||
|
||||
val onRegistrationStateChange = { state: RegistrationState ->
|
||||
registrationState = state
|
||||
}
|
||||
|
||||
val snackBarHostState = remember { SnackbarHostState() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceAround,
|
||||
horizontalAlignment = Alignment.Start,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 16.dp)
|
||||
.clickable { focusManager.clearFocus() }
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(50.dp))
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_omnivore_name_logo),
|
||||
contentDescription = "Omnivore Icon with Name"
|
||||
)
|
||||
Spacer(modifier = Modifier.height(50.dp))
|
||||
|
||||
when(registrationState) {
|
||||
RegistrationState.EmailSignIn -> {
|
||||
EmailLoginView(
|
||||
viewModel = viewModel,
|
||||
onAuthProviderButtonTap = {
|
||||
onRegistrationStateChange(RegistrationState.AuthProviderButtons)
|
||||
}
|
||||
)
|
||||
}
|
||||
RegistrationState.AuthProviderButtons -> {
|
||||
Text(
|
||||
text = stringResource(id = R.string.welcome_title),
|
||||
style = MaterialTheme.typography.headlineLarge
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.welcome_subtitle),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
MoreInfoButton()
|
||||
|
||||
Spacer(modifier = Modifier.height(50.dp))
|
||||
|
||||
AuthProviderView(
|
||||
viewModel = viewModel,
|
||||
onEmailButtonTap = { onRegistrationStateChange(RegistrationState.EmailSignIn) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1.0F))
|
||||
}
|
||||
|
||||
if (viewModel.errorMessage != null) {
|
||||
coroutineScope.launch {
|
||||
val result = snackBarHostState
|
||||
.showSnackbar(
|
||||
viewModel.errorMessage!!,
|
||||
actionLabel = "Dismiss",
|
||||
duration = SnackbarDuration.Indefinite
|
||||
)
|
||||
when (result) {
|
||||
SnackbarResult.ActionPerformed -> viewModel.resetErrorMessage()
|
||||
}
|
||||
}
|
||||
|
||||
SnackbarHost(hostState = snackBarHostState)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AuthProviderView(
|
||||
viewModel: LoginViewModel,
|
||||
onEmailButtonTap: () -> Unit
|
||||
) {
|
||||
val isGoogleAuthAvailable: Boolean = GoogleApiAvailability
|
||||
.getInstance()
|
||||
.isGooglePlayServicesAvailable(LocalContext.current) == 0
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1.0F))
|
||||
Column(
|
||||
// verticalArrangement = Arrangement.Center,
|
||||
// horizontalAlignment = Alignment.CenterHorizontally
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (isGoogleAuthAvailable) {
|
||||
GoogleAuthButton(viewModel)
|
||||
}
|
||||
|
||||
// AppleAuthButton(viewModel)
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("Continue with Email"),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
|
||||
onClick = { onEmailButtonTap() }
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1.0F))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MoreInfoButton() {
|
||||
val context = LocalContext.current
|
||||
val intent = remember { Intent(Intent.ACTION_VIEW, Uri.parse("https://omnivore.app/about")) }
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString(
|
||||
stringResource(id = R.string.learn_more),
|
||||
),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
|
||||
onClick = {
|
||||
context.startActivity(intent)
|
||||
},
|
||||
modifier = Modifier.padding(vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package app.omnivore.omnivore.ui.home
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavHostController
|
||||
import app.omnivore.omnivore.ui.auth.LoginViewModel
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignIn
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
|
||||
|
||||
@Composable
|
||||
fun HomeView(viewModel: LoginViewModel) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 6.dp)
|
||||
) {
|
||||
Text("You have a valid auth token. Nice. Go save something in Chrome!")
|
||||
|
||||
Button(onClick = {
|
||||
// Sign out google users
|
||||
val signInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
|
||||
.build()
|
||||
|
||||
val googleSignIn = GoogleSignIn.getClient(context, signInOptions)
|
||||
googleSignIn.signOut()
|
||||
|
||||
viewModel.logout()
|
||||
}) {
|
||||
Text(text = "Logout")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package app.omnivore.omnivore.ui.root
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import app.omnivore.omnivore.ui.auth.LoginViewModel
|
||||
import app.omnivore.omnivore.ui.auth.WelcomeScreen
|
||||
import app.omnivore.omnivore.ui.home.HomeView
|
||||
|
||||
@Composable
|
||||
fun RootView(viewModel: LoginViewModel) {
|
||||
val hasAuthToken: Boolean by viewModel.hasAuthTokenLiveData.observeAsState(false)
|
||||
|
||||
if (hasAuthToken) {
|
||||
HomeView(viewModel = viewModel)
|
||||
} else {
|
||||
WelcomeScreen(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package app.omnivore.omnivore
|
||||
package app.omnivore.omnivore.ui.save
|
||||
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import app.omnivore.omnivore.ui.save.SaveSheetActivity
|
||||
|
||||
// Not sure why we need this class, but directly opening SaveSheetActivity
|
||||
// causes the app to crash.
|
||||
|
|
@ -8,9 +8,11 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.MaterialTheme.colors
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.omnivore.omnivore.ui.save.SaveViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
|
|
@ -32,7 +34,12 @@ fun SaveContent(viewModel: SaveViewModel, modalBottomSheetState: ModalBottomShee
|
|||
coroutineScope.launch {
|
||||
modalBottomSheetState.hide()
|
||||
}
|
||||
}) {
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
contentColor = Color(0xFF3D3D3D),
|
||||
backgroundColor = Color(0xffffd234)
|
||||
)
|
||||
) {
|
||||
Text(text = "Dismiss")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package app.omnivore.omnivore
|
||||
package app.omnivore.omnivore.ui.save
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Intent
|
||||
|
|
@ -18,6 +18,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.omnivore.omnivore.SaveContent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package app.omnivore.omnivore
|
||||
package app.omnivore.omnivore.ui.save
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.util.Log
|
||||
|
|
@ -7,6 +7,9 @@ import androidx.compose.runtime.mutableStateOf
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.omnivore.omnivore.Constants
|
||||
import app.omnivore.omnivore.DatastoreKeys
|
||||
import app.omnivore.omnivore.DatastoreRepository
|
||||
import app.omnivore.omnivore.graphql.generated.SaveUrlMutation
|
||||
import app.omnivore.omnivore.graphql.generated.type.SaveUrlInput
|
||||
import com.apollographql.apollo3.ApolloClient
|
||||
|
|
@ -44,7 +47,7 @@ class SaveViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
val apolloClient = ApolloClient.Builder()
|
||||
.serverUrl("${Constants.demoProdURL}/api/graphql")
|
||||
.serverUrl("${Constants.apiURL}/api/graphql")
|
||||
.addHttpHeader("Authorization", value = apiKey)
|
||||
.build()
|
||||
|
||||
|
|
@ -82,7 +82,7 @@ fun OmnivoreTheme(
|
|||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
// typography = Typography,
|
||||
typography = Typography,
|
||||
// shapes = Shapes,
|
||||
content = content
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,28 +1,25 @@
|
|||
package app.omnivore.omnivore.ui.theme
|
||||
|
||||
import androidx.compose.material.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.material3.Typography
|
||||
|
||||
val Typography = Typography()
|
||||
|
||||
// ex: https://github.com/android/compose-samples/blob/main/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Typography.kt
|
||||
//val Typography = Typography(
|
||||
//displayLarge: TextStyle = TypographyTokens.DisplayLarge,
|
||||
//displayMedium: TextStyle = TypographyTokens.DisplayMedium,
|
||||
//displaySmall: TextStyle = TypographyTokens.DisplaySmall,
|
||||
//headlineLarge: TextStyle = TypographyTokens.HeadlineLarge,
|
||||
//headlineMedium: TextStyle = TypographyTokens.HeadlineMedium,
|
||||
//headlineSmall: TextStyle = TypographyTokens.HeadlineSmall,
|
||||
//titleLarge: TextStyle = TypographyTokens.TitleLarge,
|
||||
//titleMedium: TextStyle = TypographyTokens.TitleMedium,
|
||||
//titleSmall: TextStyle = TypographyTokens.TitleSmall,
|
||||
//bodyLarge: TextStyle = TypographyTokens.BodyLarge,
|
||||
//bodyMedium: TextStyle = TypographyTokens.BodyMedium,
|
||||
//bodySmall: TextStyle = TypographyTokens.BodySmall,
|
||||
//labelLarge: TextStyle = TypographyTokens.LabelLarge,
|
||||
//labelMedium: TextStyle = TypographyTokens.LabelMedium,
|
||||
//labelSmall: TextStyle = TypographyTokens.LabelSmall
|
||||
//)
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
body1 = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
button = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.W500,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
caption = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M21.281,18.424C20.933,19.228 20.521,19.967 20.044,20.647C19.394,21.574 18.861,22.216 18.451,22.572C17.815,23.157 17.134,23.457 16.404,23.474C15.88,23.474 15.248,23.325 14.513,23.022C13.775,22.721 13.097,22.572 12.477,22.572C11.827,22.572 11.129,22.721 10.383,23.022C9.636,23.325 9.035,23.482 8.574,23.498C7.875,23.528 7.177,23.22 6.481,22.572C6.037,22.185 5.481,21.52 4.815,20.579C4.101,19.574 3.513,18.408 3.053,17.08C2.56,15.644 2.313,14.254 2.313,12.909C2.313,11.367 2.646,10.038 3.313,8.924C3.838,8.029 4.535,7.323 5.408,6.805C6.281,6.287 7.224,6.023 8.24,6.006C8.796,6.006 9.525,6.178 10.431,6.516C11.334,6.855 11.914,7.027 12.168,7.027C12.358,7.027 13.002,6.826 14.095,6.425C15.127,6.053 15.999,5.899 16.713,5.96C18.648,6.116 20.102,6.879 21.069,8.253C19.338,9.302 18.482,10.77 18.499,12.654C18.515,14.122 19.047,15.343 20.094,16.313C20.568,16.763 21.097,17.111 21.687,17.358C21.559,17.728 21.424,18.083 21.281,18.424ZM16.843,0.96C16.843,2.11 16.422,3.184 15.585,4.178C14.574,5.36 13.351,6.043 12.025,5.935C12.008,5.797 11.999,5.652 11.999,5.5C11.999,4.395 12.479,3.213 13.333,2.247C13.759,1.758 14.301,1.351 14.959,1.027C15.615,0.707 16.235,0.53 16.818,0.5C16.835,0.654 16.843,0.808 16.843,0.96V0.96Z"
|
||||
android:fillColor="#000000"/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="129dp"
|
||||
android:height="26dp"
|
||||
android:viewportWidth="129"
|
||||
android:viewportHeight="26">
|
||||
<path
|
||||
android:pathData="M43.71,13.11C43.71,9.58 41.52,7.57 38.66,7.57C35.78,7.57 33.61,9.58 33.61,13.11C33.61,16.63 35.78,18.66 38.66,18.66C41.52,18.66 43.71,16.64 43.71,13.11ZM41.4,13.11C41.4,15.4 40.31,16.64 38.66,16.64C37,16.64 35.92,15.4 35.92,13.11C35.92,10.83 37,9.59 38.66,9.59C40.31,9.59 41.4,10.83 41.4,13.11Z"
|
||||
android:fillColor="#3D3D3D"/>
|
||||
<path
|
||||
android:pathData="M47.63,7.72V18.51H49.84V11.46H49.93L52.72,18.46H54.23L57.02,11.49H57.11V18.51H59.32V7.72H56.51L53.54,14.97H53.41L50.44,7.72H47.63Z"
|
||||
android:fillColor="#3D3D3D"/>
|
||||
<path
|
||||
android:pathData="M72.45,7.72H70.18V14.5H70.09L65.43,7.72H63.43V18.51H65.71V11.72H65.79L70.48,18.51H72.45V7.72Z"
|
||||
android:fillColor="#3D3D3D"/>
|
||||
<path
|
||||
android:pathData="M78.84,7.72H76.56V18.51H78.84V7.72Z"
|
||||
android:fillColor="#3D3D3D"/>
|
||||
<path
|
||||
android:pathData="M84.9,7.72H82.36L86.09,18.51H89.03L92.75,7.72H90.22L87.6,15.92H87.5L84.9,7.72Z"
|
||||
android:fillColor="#3D3D3D"/>
|
||||
<path
|
||||
android:pathData="M105.58,13.11C105.58,9.58 103.39,7.57 100.52,7.57C97.65,7.57 95.47,9.58 95.47,13.11C95.47,16.63 97.65,18.66 100.52,18.66C103.39,18.66 105.58,16.64 105.58,13.11ZM103.26,13.11C103.26,15.4 102.18,16.64 100.52,16.64C98.86,16.64 97.78,15.4 97.78,13.11C97.78,10.83 98.86,9.59 100.52,9.59C102.18,9.59 103.26,10.83 103.26,13.11Z"
|
||||
android:fillColor="#3D3D3D"/>
|
||||
<path
|
||||
android:pathData="M109.49,18.51H111.77V14.68H113.44L115.48,18.51H118L115.71,14.31C116.94,13.79 117.62,12.72 117.62,11.24C117.62,9.1 116.2,7.72 113.75,7.72H109.49V18.51ZM111.77,12.85V9.58H113.31C114.63,9.58 115.27,10.17 115.27,11.24C115.27,12.31 114.63,12.85 113.32,12.85H111.77Z"
|
||||
android:fillColor="#3D3D3D"/>
|
||||
<path
|
||||
android:pathData="M121.45,18.51H128.75V16.63H123.74V14.05H128.35V12.17H123.74V9.6H128.73V7.72H121.45V18.51Z"
|
||||
android:fillColor="#3D3D3D"/>
|
||||
<path
|
||||
android:pathData="M8.72,17.73V10.37C8.72,9.74 9.46,9.39 9.95,9.82L12.22,13.14C12.69,13.52 13.34,13.52 13.81,13.14L16.03,9.85C16.52,9.44 17.26,9.77 17.26,10.4V14.29C17.26,16.17 18.52,17.71 20.41,17.71H20.46C22.07,17.71 23.47,16.61 23.85,15.05C24.04,14.23 24.21,13.38 24.21,12.73C24.18,6.29 18.74,1.42 12.22,1.86C6.69,2.24 2.23,6.7 1.85,12.23C1.41,18.75 6.56,24.19 13.02,24.19"
|
||||
android:strokeWidth="2.18182"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#3D3D3D"/>
|
||||
</vector>
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
<resources>
|
||||
<string name="app_name">Omnivore</string>
|
||||
<string name="gcp_id">590528454690-mpmbvcb4c3ifmnojmrultt37eo8f64at.apps.googleusercontent.com</string>
|
||||
<!-- <string name="web_client_id">590528454690-g535h6sd708andjrvrmqgaj1sergtqi9.apps.googleusercontent.com</string>-->
|
||||
<string name="welcome_title">Never miss a great read</string>
|
||||
<string name="learn_more">Learn More</string>
|
||||
<string name="welcome_subtitle">Save articles and read them later in our distraction-free reader.</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.Omnivore" parent="android:Theme.Material.Light">
|
||||
|
||||
<style name="Theme.Omnivore" parent="android:Theme.Material.Light.NoActionBar">
|
||||
</style>
|
||||
|
||||
<style name="Theme.AppCompat.Translucent" parent="Theme.AppCompat.NoActionBar">
|
||||
|
|
|
|||
|
|
@ -88,6 +88,26 @@ public struct MiniPlayer: View {
|
|||
.padding(.top, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Button(
|
||||
action: {
|
||||
let shareActivity = UIActivityViewController(activityItems: [self.audioSession.localAudioUrl], applicationActivities: nil)
|
||||
if let vc = UIApplication.shared.windows.first?.rootViewController {
|
||||
shareActivity.popoverPresentationController?.sourceView = vc.view
|
||||
// Setup share activity position on screen on bottom center
|
||||
shareActivity.popoverPresentationController?.sourceRect = CGRect(x: UIScreen.main.bounds.width / 2, y: UIScreen.main.bounds.height, width: 0, height: 0)
|
||||
shareActivity.popoverPresentationController?.permittedArrowDirections = UIPopoverArrowDirection.down
|
||||
vc.present(shareActivity, animated: true, completion: nil)
|
||||
}
|
||||
},
|
||||
label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.appCallout)
|
||||
.tint(.appGrayText)
|
||||
}
|
||||
)
|
||||
.padding(.top, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
|
||||
Capsule()
|
||||
.fill(.gray)
|
||||
.frame(width: 60, height: 4)
|
||||
|
|
@ -246,7 +266,7 @@ public struct MiniPlayer: View {
|
|||
.padding(EdgeInsets(top: 0, leading: expanded ? 24 : 6, bottom: 0, trailing: expanded ? 24 : 6))
|
||||
.background(
|
||||
Color.systemBackground
|
||||
.shadow(color: expanded ? .clear : .gray /* .opacity(0.33) */, radius: 8, x: 0, y: 4)
|
||||
.shadow(color: expanded ? .clear : .gray.opacity(0.33), radius: 8, x: 0, y: 4)
|
||||
.mask(Rectangle().padding(.top, -20))
|
||||
)
|
||||
.onTapGesture {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import Views
|
|||
|
||||
struct FeedCardNavigationLink: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var audioSession: AudioSession
|
||||
|
||||
let item: LinkedItem
|
||||
|
||||
|
|
@ -33,7 +34,7 @@ struct FeedCardNavigationLink: View {
|
|||
.opacity(0)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.onAppear {
|
||||
Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
|
||||
Task { await viewModel.itemAppeared(item: item, dataService: dataService, audioSession: audioSession) }
|
||||
}
|
||||
FeedCard(item: item) {
|
||||
viewModel.selectedLinkItem = item.objectID
|
||||
|
|
@ -44,6 +45,7 @@ struct FeedCardNavigationLink: View {
|
|||
|
||||
struct GridCardNavigationLink: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var audioSession: AudioSession
|
||||
|
||||
@State private var scale = 1.0
|
||||
|
||||
|
|
@ -86,7 +88,7 @@ struct GridCardNavigationLink: View {
|
|||
withAnimation { tapAction() }
|
||||
})
|
||||
.onAppear {
|
||||
Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
|
||||
Task { await viewModel.itemAppeared(item: item, dataService: dataService, audioSession: audioSession) }
|
||||
}
|
||||
}
|
||||
.aspectRatio(1.8, contentMode: .fill)
|
||||
|
|
|
|||
|
|
@ -11,11 +11,13 @@ import Views
|
|||
|
||||
struct HomeFeedContainerView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var audioSession: AudioSession
|
||||
|
||||
@AppStorage(UserDefaultKey.homeFeedlayoutPreference.rawValue) var prefersListLayout = false
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
func loadItems(isRefresh: Bool) {
|
||||
Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) }
|
||||
Task { await viewModel.loadItems(dataService: dataService, audioSession: audioSession, isRefresh: isRefresh) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -339,6 +341,7 @@ import Views
|
|||
|
||||
struct HomeFeedGridView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var audioSession: AudioSession
|
||||
|
||||
@State private var itemToRemove: LinkedItem?
|
||||
@State private var confirmationShown = false
|
||||
|
|
@ -361,7 +364,7 @@ import Views
|
|||
}
|
||||
|
||||
func loadItems(isRefresh: Bool) {
|
||||
Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) }
|
||||
Task { await viewModel.loadItems(dataService: dataService, audioSession: audioSession, isRefresh: isRefresh) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
|
|
|||
|
|
@ -40,14 +40,14 @@ import Views
|
|||
var searchIdx = 0
|
||||
var receivedIdx = 0
|
||||
|
||||
func itemAppeared(item: LinkedItem, dataService: DataService) async {
|
||||
func itemAppeared(item: LinkedItem, dataService: DataService, audioSession: AudioSession) async {
|
||||
if isLoading { return }
|
||||
let itemIndex = items.firstIndex(where: { $0.id == item.id })
|
||||
let thresholdIndex = items.index(items.endIndex, offsetBy: -5)
|
||||
|
||||
// Check if user has scrolled to the last five items in the list
|
||||
if let itemIndex = itemIndex, itemIndex > thresholdIndex, items.count < thresholdIndex + 10 {
|
||||
await loadItems(dataService: dataService, isRefresh: false)
|
||||
await loadItems(dataService: dataService, audioSession: audioSession, isRefresh: false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -55,7 +55,7 @@ import Views
|
|||
items.insert(item, at: 0)
|
||||
}
|
||||
|
||||
func loadItems(dataService: DataService, isRefresh: Bool) async {
|
||||
func loadItems(dataService: DataService, audioSession: AudioSession, isRefresh: Bool) async {
|
||||
let syncStartTime = Date()
|
||||
let thisSearchIdx = searchIdx
|
||||
searchIdx += 1
|
||||
|
|
@ -123,6 +123,7 @@ import Views
|
|||
cursor = queryResult.cursor
|
||||
if let username = dataService.currentViewer?.username {
|
||||
await dataService.prefetchPages(itemIDs: newItems.map(\.unwrappedID), username: username)
|
||||
await audioSession.preload(itemIDs: newItems.map(\.unwrappedID))
|
||||
}
|
||||
} else {
|
||||
updateFetchController(dataService: dataService)
|
||||
|
|
|
|||
|
|
@ -66,6 +66,54 @@ public class AudioSession: NSObject, ObservableObject, AVAudioPlayerDelegate {
|
|||
downloadTask?.cancel()
|
||||
}
|
||||
|
||||
public func preload(itemIDs: [String], retryCount: Int = 0) async {
|
||||
var pendingList = [String]()
|
||||
|
||||
for pageId in itemIDs {
|
||||
let permFile = pathForAudioFile(pageId: pageId)
|
||||
if FileManager.default.fileExists(atPath: permFile.path) {
|
||||
print("audio file already downloaded: ", permFile)
|
||||
continue
|
||||
}
|
||||
|
||||
// Attempt to fetch the file if not downloaded already
|
||||
let result = try? await downloadAudioFile(pageId: pageId)
|
||||
if result == nil {
|
||||
print("audio file had error downloading: ", pageId)
|
||||
pendingList.append(pageId)
|
||||
}
|
||||
|
||||
if let result = result, result.pending {
|
||||
print("audio file is pending download: ", pageId)
|
||||
pendingList.append(pageId)
|
||||
}
|
||||
}
|
||||
|
||||
print("audio files pending download: ", pendingList)
|
||||
if pendingList.isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
if retryCount > 5 {
|
||||
print("reached max preload depth, stopping preloading")
|
||||
return
|
||||
}
|
||||
|
||||
let retryDelayInNanoSeconds = UInt64(retryCount * 2 * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: retryDelayInNanoSeconds)
|
||||
|
||||
await preload(itemIDs: pendingList, retryCount: retryCount + 1)
|
||||
}
|
||||
|
||||
public var localAudioUrl: URL? {
|
||||
if let pageId = item?.id {
|
||||
return FileManager.default
|
||||
.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent(pageId + ".mp3")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public var scrubState: PlayerScrubState = .reset {
|
||||
didSet {
|
||||
switch scrubState {
|
||||
|
|
@ -80,7 +128,7 @@ public class AudioSession: NSObject, ObservableObject, AVAudioPlayerDelegate {
|
|||
}
|
||||
|
||||
public var currentVoice: String {
|
||||
"en-CA-ClaraNeural"
|
||||
"en-US-JennyNeural"
|
||||
}
|
||||
|
||||
public func isLoadingItem(item: LinkedItem) -> Bool {
|
||||
|
|
@ -103,6 +151,16 @@ public class AudioSession: NSObject, ObservableObject, AVAudioPlayerDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
public func fileNameForAudioFile(_ pageId: String) -> String {
|
||||
pageId + "-" + currentVoice + ".mp3"
|
||||
}
|
||||
|
||||
public func pathForAudioFile(pageId: String) -> URL {
|
||||
FileManager.default
|
||||
.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent(fileNameForAudioFile(pageId))
|
||||
}
|
||||
|
||||
public func startAudio() {
|
||||
state = .loading
|
||||
setupNotifications()
|
||||
|
|
@ -110,19 +168,24 @@ public class AudioSession: NSObject, ObservableObject, AVAudioPlayerDelegate {
|
|||
let pageId = item!.unwrappedID
|
||||
|
||||
downloadTask = Task {
|
||||
do {
|
||||
_ = try await downloadAudioFile(pageId: pageId)
|
||||
if Task.isCancelled { return }
|
||||
DispatchQueue.main.async {
|
||||
self.startDownloadedAudioFile(pageId: pageId)
|
||||
}
|
||||
} catch {
|
||||
// TODO: display a failure toast here
|
||||
let result = try? await downloadAudioFile(pageId: pageId)
|
||||
if Task.isCancelled { return }
|
||||
|
||||
if result == nil {
|
||||
DispatchQueue.main.async {
|
||||
NSNotification.operationSuccess(message: "Error generating audio.")
|
||||
self.stop()
|
||||
}
|
||||
print("FAILED TO DOWNLOAD AUDIO URL")
|
||||
print(error)
|
||||
}
|
||||
|
||||
if let result = result, result.pending {
|
||||
DispatchQueue.main.async {
|
||||
NSNotification.operationSuccess(message: "Your audio is being generated.")
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.startDownloadedAudioFile(pageId: pageId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -136,10 +199,7 @@ public class AudioSession: NSObject, ObservableObject, AVAudioPlayerDelegate {
|
|||
|
||||
// TODO: Maybe check if app is active so it doesn't end up playing later?
|
||||
|
||||
let audioUrl = FileManager.default
|
||||
.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent(pageId + ".mp3")
|
||||
|
||||
let audioUrl = pathForAudioFile(pageId: pageId)
|
||||
if !FileManager.default.fileExists(atPath: audioUrl.path) {
|
||||
stop()
|
||||
return
|
||||
|
|
@ -236,8 +296,8 @@ public class AudioSession: NSObject, ObservableObject, AVAudioPlayerDelegate {
|
|||
|
||||
if let item = item {
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = [
|
||||
MPMediaItemPropertyTitle: NSString(string: item.title!),
|
||||
MPMediaItemPropertyArtist: NSString(string: item.author!),
|
||||
MPMediaItemPropertyTitle: NSString(string: item.title ?? "Your Omnivore Article"),
|
||||
MPMediaItemPropertyArtist: NSString(string: item.author ?? "Omnivore"),
|
||||
MPMediaItemPropertyPlaybackDuration: NSNumber(value: duration),
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: NSNumber(value: timeElapsed)
|
||||
]
|
||||
|
|
@ -294,21 +354,18 @@ public class AudioSession: NSObject, ObservableObject, AVAudioPlayerDelegate {
|
|||
commandCenter.changePlaybackPositionCommand.addTarget { event -> MPRemoteCommandHandlerStatus in
|
||||
if let event = event as? MPChangePlaybackPositionCommandEvent {
|
||||
self.player?.currentTime = event.positionTime
|
||||
return .success
|
||||
}
|
||||
return .commandFailed
|
||||
}
|
||||
}
|
||||
|
||||
func downloadAudioFile(pageId: String) async throws -> URL? {
|
||||
let audioUrl = FileManager.default
|
||||
.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent(pageId + ".mp3")
|
||||
func downloadAudioFile(pageId: String) async throws -> (pending: Bool, url: URL?) {
|
||||
let audioUrl = pathForAudioFile(pageId: pageId)
|
||||
|
||||
// if FileManager.default.fileExists(atPath: audioUrl.path) {
|
||||
// // Prevent re-download
|
||||
// // TODO: We aren't doing this very safely, we should be verifying a checksum
|
||||
// return audioUrl
|
||||
// }
|
||||
if FileManager.default.fileExists(atPath: audioUrl.path) {
|
||||
return (pending: false, url: audioUrl)
|
||||
}
|
||||
|
||||
guard let url = URL(string: "/api/article/\(pageId)/mp3/\(currentVoice)", relativeTo: appEnvironment.serverBaseURL) else {
|
||||
throw BasicError.message(messageText: "Invalid audio URL")
|
||||
|
|
@ -327,10 +384,7 @@ public class AudioSession: NSObject, ObservableObject, AVAudioPlayerDelegate {
|
|||
}
|
||||
print("httpResponse: ", httpResponse)
|
||||
if let httpResponse = result?.1 as? HTTPURLResponse, httpResponse.statusCode == 202 {
|
||||
print("Tell the user the download has been queued")
|
||||
DispatchQueue.main.async {
|
||||
NSNotification.operationSuccess(message: "Your audio is being created.")
|
||||
}
|
||||
return (pending: true, nil)
|
||||
}
|
||||
|
||||
guard let data = result?.0 else {
|
||||
|
|
@ -359,7 +413,7 @@ public class AudioSession: NSObject, ObservableObject, AVAudioPlayerDelegate {
|
|||
throw BasicError.message(messageText: errorMessage)
|
||||
}
|
||||
|
||||
return audioUrl
|
||||
return (pending: false, url: audioUrl)
|
||||
}
|
||||
|
||||
public func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully _: Bool) {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xA8",
|
||||
"green" : "0xEB",
|
||||
"green" : "0xEA",
|
||||
"red" : "0xFB"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ import { env } from '../env'
|
|||
import { ReportType } from '../generated/graphql'
|
||||
import express from 'express'
|
||||
|
||||
export const createPubSubClient = (): PubsubClient => {
|
||||
const client = new PubSub()
|
||||
const client = new PubSub()
|
||||
|
||||
export const createPubSubClient = (): PubsubClient => {
|
||||
const publish = (topicName: string, msg: Buffer): Promise<void> => {
|
||||
if (env.dev.isLocal) {
|
||||
console.log(`Publishing ${topicName}`)
|
||||
|
|
|
|||
|
|
@ -194,9 +194,10 @@ export const createPage = async (
|
|||
refresh: ctx.refresh,
|
||||
})
|
||||
|
||||
page.id = body._id as string
|
||||
await ctx.pubsub.entityCreated<Page>(EntityType.PAGE, page, ctx.uid)
|
||||
|
||||
return body._id as string
|
||||
return page.id
|
||||
} catch (e) {
|
||||
console.error('failed to create a page in elastic', JSON.stringify(e))
|
||||
return undefined
|
||||
|
|
@ -317,6 +318,8 @@ export const getPageByParam = async <K extends keyof ParamSet>(
|
|||
|
||||
export const getPageById = async (id: string): Promise<Page | undefined> => {
|
||||
try {
|
||||
if (!id) return undefined
|
||||
|
||||
const { body } = await client.get({
|
||||
index: INDEX_ALIAS,
|
||||
id,
|
||||
|
|
|
|||
|
|
@ -215,6 +215,7 @@ export interface Page {
|
|||
taskName?: string
|
||||
language?: string
|
||||
readAt?: Date
|
||||
listenedAt?: Date
|
||||
}
|
||||
|
||||
export interface SearchItem {
|
||||
|
|
@ -246,15 +247,7 @@ export interface SearchItem {
|
|||
highlights?: Highlight[]
|
||||
}
|
||||
|
||||
const keys = [
|
||||
'_id',
|
||||
'url',
|
||||
'slug',
|
||||
'userId',
|
||||
'uploadFileId',
|
||||
'state',
|
||||
'id',
|
||||
] as const
|
||||
const keys = ['_id', 'url', 'slug', 'userId', 'uploadFileId', 'state'] as const
|
||||
|
||||
export type ParamSet = PickTuple<Page, typeof keys>
|
||||
|
||||
|
|
|
|||
|
|
@ -17,10 +17,11 @@ import { env } from '../env'
|
|||
import { Claims } from '../resolvers/types'
|
||||
import { getRepository } from '../entity/utils'
|
||||
import { Speech, SpeechState } from '../entity/speech'
|
||||
import { getPageById } from '../elastic/pages'
|
||||
import { getPageById, updatePage } from '../elastic/pages'
|
||||
import { generateDownloadSignedUrl } from '../utils/uploads'
|
||||
import { enqueueTextToSpeech } from '../utils/createTask'
|
||||
import { UserPersonalization } from '../entity/user_personalization'
|
||||
import { createPubSubClient } from '../datalayer/pubsub'
|
||||
|
||||
const logger = buildLogger('app.dispatch')
|
||||
|
||||
|
|
@ -118,6 +119,13 @@ export function articleRouter() {
|
|||
audioUrl: existingSpeech.audioFileName,
|
||||
speechMarksUrl: existingSpeech.speechMarksFileName,
|
||||
})
|
||||
await updatePage(
|
||||
existingSpeech.elasticPageId,
|
||||
{
|
||||
listenedAt: new Date(),
|
||||
},
|
||||
{ uid, pubsub: createPubSubClient() }
|
||||
)
|
||||
return res.redirect(await redirectUrl(existingSpeech, outputFormat))
|
||||
}
|
||||
if (existingSpeech.state === SpeechState.INITIALIZED) {
|
||||
|
|
@ -142,7 +150,7 @@ export function articleRouter() {
|
|||
user: { id: uid },
|
||||
elasticPageId: articleId,
|
||||
state: SpeechState.INITIALIZED,
|
||||
voice: voice || userPersonalization?.speechVoice,
|
||||
voice: voice || userPersonalization?.speechVoice || 'en-US-JennyNeural',
|
||||
})
|
||||
// enqueue a task to convert text to speech
|
||||
const taskName = await enqueueTextToSpeech(uid, speech.id)
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ export const validateGoogleUser = async (
|
|||
|
||||
const iosClientId = env.google.auth.iosClientId
|
||||
const webClientId = env.google.auth.clientId
|
||||
const androidClientId = env.google.auth.androidClientId
|
||||
|
||||
const googleWebClient = new OAuth2Client(webClientId)
|
||||
const googleIOSClient = new OAuth2Client(iosClientId)
|
||||
|
|
@ -72,7 +73,7 @@ export async function decodeGoogleToken(
|
|||
|
||||
const loginTicket = await googleMobileClient.verifyIdToken({
|
||||
idToken,
|
||||
audience: isAndroid ? webClientId : iosClientId,
|
||||
audience: [iosClientId, webClientId, androidClientId],
|
||||
})
|
||||
|
||||
const email = loginTicket.getPayload()?.email
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import { corsConfig } from '../../utils/corsConfig'
|
||||
import { getRepository } from '../../entity/utils'
|
||||
import { getPageById } from '../../elastic/pages'
|
||||
import { synthesizeTextToSpeech } from '../../utils/textToSpeech'
|
||||
import { Speech, SpeechState } from '../../entity/speech'
|
||||
import { buildLogger } from '../../utils/logger'
|
||||
import { getClaimsByToken } from '../../utils/auth'
|
||||
import { setSpeechFailure } from '../../services/speech'
|
||||
|
||||
const logger = buildLogger('app.dispatch')
|
||||
|
||||
export function speechServiceRouter() {
|
||||
const router = express.Router()
|
||||
|
||||
router.options('/', cors<express.Request>({ ...corsConfig, maxAge: 600 }))
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
router.post('/', async (req, res) => {
|
||||
logger.info('Speech svc request', {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
body: req.body,
|
||||
})
|
||||
const token = req.query.token as string
|
||||
try {
|
||||
if (!(await getClaimsByToken(token))) {
|
||||
logger.info('Unauthorized request', { token })
|
||||
return res.status(200).send('UNAUTHORIZED')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Unauthorized request', { token, error })
|
||||
return res.status(200).send('UNAUTHORIZED')
|
||||
}
|
||||
|
||||
const { userId, speechId } = req.body as {
|
||||
userId: string
|
||||
speechId: string
|
||||
}
|
||||
if (!userId || !speechId) {
|
||||
return res.status(200).send('Invalid data')
|
||||
}
|
||||
|
||||
logger.info(`Create article speech`, {
|
||||
body: {
|
||||
userId,
|
||||
speechId,
|
||||
},
|
||||
labels: {
|
||||
source: 'CreateArticleSpeech',
|
||||
},
|
||||
})
|
||||
const speech = await getRepository(Speech).findOneBy({
|
||||
id: speechId,
|
||||
user: { id: userId },
|
||||
})
|
||||
if (!speech) {
|
||||
return res.status(200).send('Speech not found')
|
||||
}
|
||||
|
||||
const page = await getPageById(speech.elasticPageId)
|
||||
if (!page) {
|
||||
await setSpeechFailure(speech.id)
|
||||
return res.status(200).send('Page not found')
|
||||
}
|
||||
|
||||
try {
|
||||
const startTime = Date.now()
|
||||
const speechOutput = await synthesizeTextToSpeech({
|
||||
id: speech.id,
|
||||
text: page.content,
|
||||
languageCode: page.language,
|
||||
voice: speech.voice,
|
||||
textType: 'ssml',
|
||||
})
|
||||
logger.info('Created speech', {
|
||||
audioFileName: speechOutput.audioFileName,
|
||||
speechMarksFileName: speechOutput.speechMarksFileName,
|
||||
duration: Date.now() - startTime,
|
||||
})
|
||||
|
||||
// set state to completed
|
||||
await getRepository(Speech).update(speech.id, {
|
||||
audioFileName: speechOutput.audioFileName,
|
||||
speechMarksFileName: speechOutput.speechMarksFileName,
|
||||
state: SpeechState.COMPLETED,
|
||||
})
|
||||
|
||||
res.status(200).send('OK')
|
||||
} catch (error) {
|
||||
logger.error(`Error creating article speech`, { error })
|
||||
await setSpeechFailure(speech.id)
|
||||
res.status(500).send('Error creating article speech')
|
||||
}
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
137
packages/api/src/routers/svc/text_to_speech.ts
Normal file
137
packages/api/src/routers/svc/text_to_speech.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/* eslint-disable @typescript-eslint/no-misused-promises */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import { corsConfig } from '../../utils/corsConfig'
|
||||
import { getRepository } from '../../entity/utils'
|
||||
import { getPageById } from '../../elastic/pages'
|
||||
import { Speech, SpeechState } from '../../entity/speech'
|
||||
import { buildLogger } from '../../utils/logger'
|
||||
import { getClaimsByToken } from '../../utils/auth'
|
||||
import {
|
||||
setSpeechFailure,
|
||||
shouldSynthesize,
|
||||
synthesize,
|
||||
} from '../../services/speech'
|
||||
import { readPushSubscription } from '../../datalayer/pubsub'
|
||||
|
||||
const logger = buildLogger('app.dispatch')
|
||||
|
||||
export function speechServiceRouter() {
|
||||
const router = express.Router()
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
router.post('/auto-synthesize', async (req, res) => {
|
||||
logger.info('auto-synthesize')
|
||||
const { message: msgStr, expired } = readPushSubscription(req)
|
||||
|
||||
if (!msgStr) {
|
||||
return res.status(400).send('Bad Request')
|
||||
}
|
||||
|
||||
if (expired) {
|
||||
logger.info('discarding expired message')
|
||||
return res.status(200).send('Expired')
|
||||
}
|
||||
|
||||
try {
|
||||
const data: { userId: string; type: string; id: string; state: string } =
|
||||
JSON.parse(msgStr)
|
||||
const { userId, type, id, state } = data
|
||||
if (!userId || !type || !id) {
|
||||
logger.info('Invalid data')
|
||||
return res.status(400).send('Bad Request')
|
||||
}
|
||||
|
||||
if (type.toUpperCase() !== 'PAGE' || state !== 'SUCCEEDED') {
|
||||
logger.info('Not a page or not succeeded')
|
||||
return res.status(200).send('Not a page or not succeeded')
|
||||
}
|
||||
|
||||
const page = await getPageById(id)
|
||||
if (!page) {
|
||||
logger.info('No page found', { id })
|
||||
return res.status(200).send('No page found')
|
||||
}
|
||||
|
||||
// checks if this page needs to be synthesized automatically
|
||||
if (await shouldSynthesize(userId, page)) {
|
||||
logger.info('page needs to be synthesized')
|
||||
// initialize state
|
||||
const speech = await getRepository(Speech).save({
|
||||
user: { id: userId },
|
||||
elasticPageId: id,
|
||||
state: SpeechState.INITIALIZED,
|
||||
voice: 'en-US-JennyNeural',
|
||||
})
|
||||
await synthesize(page, speech)
|
||||
logger.info('page synthesized')
|
||||
}
|
||||
|
||||
res.status(200).send('Page should not synthesize')
|
||||
} catch (err) {
|
||||
logger.error('Auto synthesize failed', err)
|
||||
res.status(500).send(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.options('/', cors<express.Request>({ ...corsConfig, maxAge: 600 }))
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
router.post('/', async (req, res) => {
|
||||
logger.info('Synthesize svc request', {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
body: req.body,
|
||||
})
|
||||
const token = req.query.token as string
|
||||
try {
|
||||
if (!(await getClaimsByToken(token))) {
|
||||
logger.info('Unauthorized request', { token })
|
||||
return res.status(200).send('UNAUTHORIZED')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Unauthorized request', { token, error })
|
||||
return res.status(200).send('UNAUTHORIZED')
|
||||
}
|
||||
|
||||
const { userId, speechId } = req.body as {
|
||||
userId: string
|
||||
speechId: string
|
||||
}
|
||||
if (!userId || !speechId) {
|
||||
return res.status(200).send('Invalid data')
|
||||
}
|
||||
|
||||
logger.info(`Create article speech`, {
|
||||
body: {
|
||||
userId,
|
||||
speechId,
|
||||
},
|
||||
labels: {
|
||||
source: 'CreateArticleSpeech',
|
||||
},
|
||||
})
|
||||
const speech = await getRepository(Speech).findOneBy({
|
||||
id: speechId,
|
||||
user: { id: userId },
|
||||
})
|
||||
if (!speech) {
|
||||
return res.status(200).send('Speech not found')
|
||||
}
|
||||
|
||||
const page = await getPageById(speech.elasticPageId)
|
||||
if (!page) {
|
||||
await setSpeechFailure(speech.id)
|
||||
return res.status(200).send('Page not found')
|
||||
}
|
||||
|
||||
try {
|
||||
await synthesize(page, speech)
|
||||
} catch (error) {
|
||||
logger.error(`Error synthesizing article`, { error })
|
||||
res.status(500).send('Error synthesizing article')
|
||||
}
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ import { uploadServiceRouter } from './routers/svc/upload'
|
|||
import rateLimit from 'express-rate-limit'
|
||||
import { webhooksServiceRouter } from './routers/svc/webhooks'
|
||||
import { integrationsServiceRouter } from './routers/svc/integrations'
|
||||
import { speechServiceRouter } from './routers/svc/speech'
|
||||
import { speechServiceRouter } from './routers/svc/text_to_speech'
|
||||
|
||||
const PORT = process.env.PORT || 4000
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { getRepository } from '../entity/utils'
|
||||
import { Speech, SpeechState } from '../entity/speech'
|
||||
import { searchPages } from '../elastic/pages'
|
||||
import { Page, PageType } from '../elastic/types'
|
||||
import { SortBy, SortOrder } from '../utils/search'
|
||||
import { synthesizeTextToSpeech } from '../utils/textToSpeech'
|
||||
|
||||
export const setSpeechFailure = async (id: string) => {
|
||||
// update state
|
||||
|
|
@ -7,3 +11,82 @@ export const setSpeechFailure = async (id: string) => {
|
|||
state: SpeechState.FAILED,
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* We should not synthesize the page when:
|
||||
** 1. User has no recent listens the last 30 days
|
||||
** 2. User has a recent listen but the page was saved after the listen
|
||||
*/
|
||||
export const shouldSynthesize = async (
|
||||
userId: string,
|
||||
page: Page
|
||||
): Promise<boolean> => {
|
||||
return Promise.resolve(false)
|
||||
// if (page.pageType === PageType.File || !page.content) {
|
||||
// // we don't synthesize files for now
|
||||
// return false
|
||||
// }
|
||||
|
||||
// if (process.env.TEXT_TO_SPEECH_BETA_TEST) {
|
||||
// return true
|
||||
// }
|
||||
|
||||
// const [recentListenedPage, count] = (await searchPages(
|
||||
// {
|
||||
// dateFilters: [
|
||||
// {
|
||||
// field: 'listenedAt',
|
||||
// startDate: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
|
||||
// },
|
||||
// ],
|
||||
// sort: {
|
||||
// by: SortBy.LISTENED,
|
||||
// order: SortOrder.DESCENDING,
|
||||
// },
|
||||
// size: 1,
|
||||
// },
|
||||
// userId
|
||||
// )) || [[], 0]
|
||||
// if (count === 0) {
|
||||
// return false
|
||||
// }
|
||||
// return (
|
||||
// !!recentListenedPage[0].listenedAt &&
|
||||
// page.savedAt < recentListenedPage[0].listenedAt
|
||||
// )
|
||||
}
|
||||
|
||||
export const synthesize = async (page: Page, speech: Speech): Promise<void> => {
|
||||
try {
|
||||
if (page.pageType === PageType.File || !page.content) {
|
||||
// we don't synthesize files for now
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Start synthesizing', { pageId: page.id, speechId: speech.id })
|
||||
const startTime = Date.now()
|
||||
const speechOutput = await synthesizeTextToSpeech({
|
||||
id: speech.id,
|
||||
text: page.content,
|
||||
languageCode: page.language,
|
||||
voice: speech.voice,
|
||||
textType: 'ssml',
|
||||
})
|
||||
console.log('Synthesized article', {
|
||||
audioFileName: speechOutput.audioFileName,
|
||||
speechMarksFileName: speechOutput.speechMarksFileName,
|
||||
duration: Date.now() - startTime,
|
||||
})
|
||||
|
||||
// set state to completed
|
||||
await getRepository(Speech).update(speech.id, {
|
||||
audioFileName: speechOutput.audioFileName,
|
||||
speechMarksFileName: speechOutput.speechMarksFileName,
|
||||
state: SpeechState.COMPLETED,
|
||||
})
|
||||
} catch (error) {
|
||||
console.log('Error synthesize article', error)
|
||||
await setSpeechFailure(speech.id)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ export enum SortBy {
|
|||
SCORE = '_score',
|
||||
PUBLISHED = 'publishedAt',
|
||||
READ = 'readAt',
|
||||
LISTENED = 'listenedAt',
|
||||
}
|
||||
|
||||
export enum SortOrder {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export interface TextToSpeechInput {
|
|||
textType?: 'text' | 'ssml'
|
||||
rate?: number
|
||||
volume?: number
|
||||
complimentaryVoice?: string
|
||||
}
|
||||
|
||||
export interface TextToSpeechOutput {
|
||||
|
|
@ -167,24 +168,31 @@ export const synthesizeTextToSpeech = async (
|
|||
}
|
||||
} else {
|
||||
const document = parseHTML(input.text).document
|
||||
const elements = document.querySelectorAll('h1, h2, h3, p, ul, ol')
|
||||
const elements = document.querySelectorAll(
|
||||
'h1, h2, h3, p, ul, ol, blockquote'
|
||||
)
|
||||
// convert html elements to the ssml document
|
||||
for (const e of Array.from(elements)) {
|
||||
const htmlElement = e as HTMLElement
|
||||
if (htmlElement.innerText) {
|
||||
const ssml = htmlElementToSsml(
|
||||
e,
|
||||
input.languageCode || 'en-US',
|
||||
input.voice || 'en-US-JennyNeural',
|
||||
input.rate || 1,
|
||||
input.volume || 100
|
||||
)
|
||||
// use complimentary voice for blockquote, hardcoded for now
|
||||
const voice =
|
||||
htmlElement.tagName.toLowerCase() === 'blockquote'
|
||||
? input.complimentaryVoice || 'en-US-AriaNeural'
|
||||
: input.voice
|
||||
const ssml = htmlElementToSsml({
|
||||
htmlElement: e,
|
||||
language: input.languageCode,
|
||||
rate: input.rate,
|
||||
volume: input.volume,
|
||||
voice,
|
||||
})
|
||||
logger.debug(`synthesizing ${ssml}`)
|
||||
const result = await speakSsmlAsyncPromise(ssml)
|
||||
if (result.reason === ResultReason.Canceled) {
|
||||
synthesizer.close()
|
||||
throw new Error(result.errorDetails)
|
||||
}
|
||||
// if (result.reason === ResultReason.Canceled) {
|
||||
// synthesizer.close()
|
||||
// throw new Error(result.errorDetails)
|
||||
// }
|
||||
timeOffset = timeOffset + result.audioDuration
|
||||
// characterOffset = characterOffset + htmlElement.innerText.length
|
||||
}
|
||||
|
|
@ -208,13 +216,19 @@ export const synthesizeTextToSpeech = async (
|
|||
}
|
||||
}
|
||||
|
||||
export const htmlElementToSsml = (
|
||||
htmlElement: Element,
|
||||
export const htmlElementToSsml = ({
|
||||
htmlElement,
|
||||
language = 'en-US',
|
||||
voice = 'en-US-JennyNeural',
|
||||
rate = 1,
|
||||
volume = 100
|
||||
): string => {
|
||||
volume = 100,
|
||||
}: {
|
||||
htmlElement: Element
|
||||
language?: string
|
||||
voice?: string
|
||||
rate?: number
|
||||
volume?: number
|
||||
}): string => {
|
||||
const replaceElement = (newElement: Element, oldElement: Element) => {
|
||||
const id = oldElement.getAttribute('data-omnivore-anchor-idx')
|
||||
if (id) {
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ describe('textToSpeech', () => {
|
|||
const htmlElement = parseHTML(
|
||||
`<p data-omnivore-anchor-idx="1">Marry had a little lamb</p>`
|
||||
).document.documentElement
|
||||
const ssml = htmlElementToSsml(htmlElement)
|
||||
const ssml = htmlElementToSsml({ htmlElement })
|
||||
expect(ssml).to.equal(
|
||||
`<speak xml:lang="en-US" xmlns="http://www.w3.org/2001/10/synthesis" version="1.0"><voice name="en-US-JennyNeural"><prosody volume="100" rate="1"><bookmark mark="data-omnivore-anchor-idx-1"></bookmark><p data-omnivore-anchor-idx="1">Marry had a little lamb</p></prosody></voice></speak>`
|
||||
)
|
||||
|
|
|
|||
|
|
@ -148,6 +148,9 @@
|
|||
},
|
||||
"readAt": {
|
||||
"type": "date"
|
||||
},
|
||||
"listenedAt": {
|
||||
"type": "date"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue