Merge branch 'main' of github.com:omnivore-app/omnivore into feat/1079

This commit is contained in:
Rupin Khandelwal 2022-08-25 19:56:38 -05:00
commit fc0e18be12
47 changed files with 1143 additions and 597 deletions

View file

@ -1 +1,3 @@
/build
/build
keystore.properties
*.keystore

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -82,7 +82,7 @@ fun OmnivoreTheme(
MaterialTheme(
colorScheme = colorScheme,
// typography = Typography,
typography = Typography,
// shapes = Shapes,
content = content
)

View file

@ -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
)
*/
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@
"components" : {
"alpha" : "1.000",
"blue" : "0xA8",
"green" : "0xEB",
"green" : "0xEA",
"red" : "0xFB"
}
},

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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
}

View file

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

View file

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

View file

@ -63,6 +63,7 @@ export enum SortBy {
SCORE = '_score',
PUBLISHED = 'publishedAt',
READ = 'readAt',
LISTENED = 'listenedAt',
}
export enum SortOrder {

View file

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

View file

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

View file

@ -148,6 +148,9 @@
},
"readAt": {
"type": "date"
},
"listenedAt": {
"type": "date"
}
}
}