朗读引擎添加登录功能

pull/1395/head
gedoor 3 years ago
parent 682af16d51
commit fa6807e9a6
  1. 5
      app/src/main/assets/defaultData/httpTTS.json
  2. 4
      app/src/main/java/io/legado/app/help/JsExtensions.kt
  3. 23
      app/src/main/java/io/legado/app/help/http/OkHttpUtils.kt
  4. 12
      app/src/main/java/io/legado/app/lib/webdav/WebDav.kt
  5. 32
      app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt
  6. 12
      app/src/main/java/io/legado/app/service/HttpReadAloudService.kt
  7. 4
      app/src/main/java/io/legado/app/ui/association/ImportBookSourceViewModel.kt
  8. 4
      app/src/main/java/io/legado/app/ui/association/ImportReplaceRuleViewModel.kt
  9. 4
      app/src/main/java/io/legado/app/ui/association/ImportRssSourceViewModel.kt
  10. 8
      app/src/main/java/io/legado/app/ui/association/OnLineImportViewModel.kt
  11. 4
      app/src/main/java/io/legado/app/ui/book/read/config/BgTextConfigDialog.kt
  12. 4
      app/src/main/java/io/legado/app/ui/book/read/config/SpeakEngineViewModel.kt
  13. 4
      app/src/main/java/io/legado/app/ui/book/read/config/TocRegexViewModel.kt
  14. 4
      app/src/main/java/io/legado/app/ui/browser/WebViewModel.kt
  15. 4
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt
  16. 4
      app/src/main/java/io/legado/app/ui/rss/read/ReadRssViewModel.kt

@ -12,6 +12,9 @@
{ {
"id": -29, "id": -29,
"name": "3.阿里云语音", "name": "3.阿里云语音",
"url": "<js>/*播音人Aiting可改其他https://cdn.jsdelivr.net/gh/Celeter/build/.github/scripts/speaker.json,详见https://ai.aliyun.com/nls/tts*/;eval(''+java.ajax('https://cdn.jsdelivr.net/gh/Celeter/build/.github/scripts/ttsDemo.js'));ttsDemo(speakText,speakSpeed,'Aiting')</js>" "url": "https://nls-gateway.cn-shanghai.aliyuncs.com/stream/v1/tts,{\"method\": \"POST\",\"body\", {\"appkey\":\"31f932fb\",\"text\":\"{{speakText}}\",\"format\":\"mp3\"}}",
"loginUrl": "",
"loginUi": "",
"loginCheckJs": ""
} }
] ]

@ -376,7 +376,7 @@ interface JsExtensions {
fun getZipByteArrayContent(url: String, path: String): ByteArray? { fun getZipByteArrayContent(url: String, path: String): ByteArray? {
val bytes = if (url.startsWith("http://") || url.startsWith("https://")) { val bytes = if (url.startsWith("http://") || url.startsWith("https://")) {
runBlocking { runBlocking {
return@runBlocking okHttpClient.newCall { url(url) }.bytes() return@runBlocking okHttpClient.newCallResponseBody { url(url) }.bytes()
} }
} else { } else {
StringUtils.hexStringToByte(url) StringUtils.hexStringToByte(url)
@ -421,7 +421,7 @@ interface JsExtensions {
str.isAbsUrl() -> runBlocking { str.isAbsUrl() -> runBlocking {
var x = CacheManager.getByteArray(key) var x = CacheManager.getByteArray(key)
if (x == null) { if (x == null) {
x = okHttpClient.newCall { url(str) }.bytes() x = okHttpClient.newCallResponseBody { url(str) }.bytes()
x.let { x.let {
CacheManager.put(key, it) CacheManager.put(key, it)
} }

@ -16,7 +16,26 @@ import java.nio.charset.Charset
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
suspend fun OkHttpClient.newCall( suspend fun OkHttpClient.newCallResponse(
retry: Int = 0,
builder: Request.Builder.() -> Unit
): Response {
return withContext(IO) {
val requestBuilder = Request.Builder()
requestBuilder.header(AppConst.UA_NAME, AppConfig.userAgent)
requestBuilder.apply(builder)
var response: Response? = null
for (i in 0..retry) {
response = this@newCallResponse.newCall(requestBuilder.build()).await()
if (response.isSuccessful) {
return@withContext response
}
}
return@withContext response!!
}
}
suspend fun OkHttpClient.newCallResponseBody(
retry: Int = 0, retry: Int = 0,
builder: Request.Builder.() -> Unit builder: Request.Builder.() -> Unit
): ResponseBody { ): ResponseBody {
@ -26,7 +45,7 @@ suspend fun OkHttpClient.newCall(
requestBuilder.apply(builder) requestBuilder.apply(builder)
var response: Response? = null var response: Response? = null
for (i in 0..retry) { for (i in 0..retry) {
response = this@newCall.newCall(requestBuilder.build()).await() response = this@newCallResponseBody.newCall(requestBuilder.build()).await()
if (response.isSuccessful) { if (response.isSuccessful) {
return@withContext response.body!! return@withContext response.body!!
} }

@ -1,6 +1,6 @@
package io.legado.app.lib.webdav package io.legado.app.lib.webdav
import io.legado.app.help.http.newCall import io.legado.app.help.http.newCallResponseBody
import io.legado.app.help.http.okHttpClient import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.text import io.legado.app.help.http.text
import io.legado.app.utils.printOnDebug import io.legado.app.utils.printOnDebug
@ -92,7 +92,7 @@ class WebDav(urlStr: String) {
val auth = HttpAuth.auth val auth = HttpAuth.auth
if (url != null && auth != null) { if (url != null && auth != null) {
return kotlin.runCatching { return kotlin.runCatching {
okHttpClient.newCall { okHttpClient.newCallResponseBody {
url(url) url(url)
addHeader("Authorization", Credentials.basic(auth.user, auth.pass)) addHeader("Authorization", Credentials.basic(auth.user, auth.pass))
addHeader("Depth", "1") addHeader("Depth", "1")
@ -152,7 +152,7 @@ class WebDav(urlStr: String) {
if (url != null && auth != null) { if (url != null && auth != null) {
//防止报错 //防止报错
return kotlin.runCatching { return kotlin.runCatching {
okHttpClient.newCall { okHttpClient.newCallResponseBody {
url(url) url(url)
method("MKCOL", null) method("MKCOL", null)
addHeader("Authorization", Credentials.basic(auth.user, auth.pass)) addHeader("Authorization", Credentials.basic(auth.user, auth.pass))
@ -198,7 +198,7 @@ class WebDav(urlStr: String) {
val auth = HttpAuth.auth val auth = HttpAuth.auth
if (url != null && auth != null) { if (url != null && auth != null) {
return kotlin.runCatching { return kotlin.runCatching {
okHttpClient.newCall { okHttpClient.newCallResponseBody {
url(url) url(url)
put(fileBody) put(fileBody)
addHeader("Authorization", Credentials.basic(auth.user, auth.pass)) addHeader("Authorization", Credentials.basic(auth.user, auth.pass))
@ -215,7 +215,7 @@ class WebDav(urlStr: String) {
val auth = HttpAuth.auth val auth = HttpAuth.auth
if (url != null && auth != null) { if (url != null && auth != null) {
return kotlin.runCatching { return kotlin.runCatching {
okHttpClient.newCall { okHttpClient.newCallResponseBody {
url(url) url(url)
put(fileBody) put(fileBody)
addHeader("Authorization", Credentials.basic(auth.user, auth.pass)) addHeader("Authorization", Credentials.basic(auth.user, auth.pass))
@ -230,7 +230,7 @@ class WebDav(urlStr: String) {
val auth = HttpAuth.auth val auth = HttpAuth.auth
if (url != null && auth != null) { if (url != null && auth != null) {
return kotlin.runCatching { return kotlin.runCatching {
okHttpClient.newCall { okHttpClient.newCallResponseBody {
url(url) url(url)
addHeader("Authorization", Credentials.basic(auth.user, auth.pass)) addHeader("Authorization", Credentials.basic(auth.user, auth.pass))
}.byteStream() }.byteStream()

@ -16,6 +16,7 @@ import io.legado.app.help.JsExtensions
import io.legado.app.help.http.* import io.legado.app.help.http.*
import io.legado.app.model.ConcurrentException import io.legado.app.model.ConcurrentException
import io.legado.app.utils.* import io.legado.app.utils.*
import okhttp3.Response
import java.net.URLEncoder import java.net.URLEncoder
import java.util.* import java.util.*
import java.util.regex.Pattern import java.util.regex.Pattern
@ -244,6 +245,12 @@ class AnalyzeUrl(
return SCRIPT_ENGINE.eval(jsStr, bindings) return SCRIPT_ENGINE.eval(jsStr, bindings)
} }
fun upHeader() {
source?.getHeaderMap(true)?.let {
headerMap.putAll(it)
}
}
fun put(key: String, value: String): String { fun put(key: String, value: String): String {
chapter?.putVariable(key, value) chapter?.putVariable(key, value)
?: ruleData?.putVariable(key, value) ?: ruleData?.putVariable(key, value)
@ -373,6 +380,29 @@ class AnalyzeUrl(
} }
} }
/**
* 访问网站,返回Response
*/
suspend fun getResponse(): Response {
judgmentConcurrent()
setCookie(source?.getKey())
@Suppress("BlockingMethodInNonBlockingContext")
return getProxyClient(proxy).newCallResponse(retry) {
addHeaders(headerMap)
when (method) {
RequestMethod.POST -> {
url(urlNoQuery)
if (fieldMap.isNotEmpty() || body.isNullOrBlank()) {
postForm(fieldMap, true)
} else {
postJson(body)
}
}
else -> get(urlNoQuery, fieldMap, true)
}
}
}
/** /**
* 访问网站,返回ByteArray * 访问网站,返回ByteArray
*/ */
@ -380,7 +410,7 @@ class AnalyzeUrl(
judgmentConcurrent() judgmentConcurrent()
setCookie(source?.getKey()) setCookie(source?.getKey())
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
return getProxyClient(proxy).newCall(retry) { return getProxyClient(proxy).newCallResponseBody(retry) {
addHeaders(headerMap) addHeaders(headerMap)
when (method) { when (method) {
RequestMethod.POST -> { RequestMethod.POST -> {

@ -11,6 +11,7 @@ import io.legado.app.model.ReadBook
import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.utils.* import io.legado.app.utils.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import okhttp3.Response
import java.io.File import java.io.File
import java.io.FileDescriptor import java.io.FileDescriptor
import java.io.FileInputStream import java.io.FileInputStream
@ -103,13 +104,20 @@ class HttpReadAloudService : BaseReadAloudService(),
} else { //没有下载并且没有缓存文件 } else { //没有下载并且没有缓存文件
try { try {
createSpeakCacheFile(fileName) createSpeakCacheFile(fileName)
AnalyzeUrl( val analyzeUrl = AnalyzeUrl(
httpTts.url, httpTts.url,
speakText = item, speakText = item,
speakSpeed = AppConfig.ttsSpeechRate, speakSpeed = AppConfig.ttsSpeechRate,
source = httpTts, source = httpTts,
headerMapF = httpTts.getHeaderMap(true) headerMapF = httpTts.getHeaderMap(true)
).getByteArray().let { bytes -> )
var response = analyzeUrl.getResponse()
httpTts.loginCheckJs?.let { checkJs ->
if (checkJs.isNotBlank()) {
response = analyzeUrl.evalJS(checkJs, response) as Response
}
}
response.body!!.bytes().let { bytes ->
ensureActive() ensureActive()
val file = getSpeakFileAsMd5IfNotExist(fileName) val file = getSpeakFileAsMd5IfNotExist(fileName)
file.writeBytes(bytes) file.writeBytes(bytes)

@ -12,7 +12,7 @@ import io.legado.app.help.AppConfig
import io.legado.app.help.BookSourceAnalyzer import io.legado.app.help.BookSourceAnalyzer
import io.legado.app.help.ContentProcessor import io.legado.app.help.ContentProcessor
import io.legado.app.help.SourceHelp import io.legado.app.help.SourceHelp
import io.legado.app.help.http.newCall import io.legado.app.help.http.newCallResponseBody
import io.legado.app.help.http.okHttpClient import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.text import io.legado.app.help.http.text
import io.legado.app.model.NoStackTraceException import io.legado.app.model.NoStackTraceException
@ -126,7 +126,7 @@ class ImportBookSourceViewModel(app: Application) : BaseViewModel(app) {
} }
private suspend fun importSourceUrl(url: String) { private suspend fun importSourceUrl(url: String) {
okHttpClient.newCall { okHttpClient.newCallResponseBody {
url(url) url(url)
}.text("utf-8").let { body -> }.text("utf-8").let { body ->
when { when {

@ -7,7 +7,7 @@ import io.legado.app.constant.AppPattern
import io.legado.app.data.appDb import io.legado.app.data.appDb
import io.legado.app.data.entities.ReplaceRule import io.legado.app.data.entities.ReplaceRule
import io.legado.app.help.AppConfig import io.legado.app.help.AppConfig
import io.legado.app.help.http.newCall import io.legado.app.help.http.newCallResponseBody
import io.legado.app.help.http.okHttpClient import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.text import io.legado.app.help.http.text
import io.legado.app.help.storage.OldReplace import io.legado.app.help.storage.OldReplace
@ -84,7 +84,7 @@ class ImportReplaceRuleViewModel(app: Application) : BaseViewModel(app) {
fun import(text: String) { fun import(text: String) {
execute { execute {
if (text.isAbsUrl()) { if (text.isAbsUrl()) {
okHttpClient.newCall { okHttpClient.newCallResponseBody {
url(text) url(text)
}.text("utf-8").let { }.text("utf-8").let {
val rules = OldReplace.jsonToReplaceRules(it) val rules = OldReplace.jsonToReplaceRules(it)

@ -10,7 +10,7 @@ import io.legado.app.data.appDb
import io.legado.app.data.entities.RssSource import io.legado.app.data.entities.RssSource
import io.legado.app.help.AppConfig import io.legado.app.help.AppConfig
import io.legado.app.help.SourceHelp import io.legado.app.help.SourceHelp
import io.legado.app.help.http.newCall import io.legado.app.help.http.newCallResponseBody
import io.legado.app.help.http.okHttpClient import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.text import io.legado.app.help.http.text
import io.legado.app.model.NoStackTraceException import io.legado.app.model.NoStackTraceException
@ -122,7 +122,7 @@ class ImportRssSourceViewModel(app: Application) : BaseViewModel(app) {
} }
private suspend fun importSourceUrl(url: String) { private suspend fun importSourceUrl(url: String) {
okHttpClient.newCall { okHttpClient.newCallResponseBody {
url(url) url(url)
}.text("utf-8").let { body -> }.text("utf-8").let { body ->
val items: List<Map<String, Any>> = jsonPath.parse(body).read("$") val items: List<Map<String, Any>> = jsonPath.parse(body).read("$")

@ -9,7 +9,7 @@ import io.legado.app.data.entities.HttpTTS
import io.legado.app.data.entities.TxtTocRule import io.legado.app.data.entities.TxtTocRule
import io.legado.app.help.ReadBookConfig import io.legado.app.help.ReadBookConfig
import io.legado.app.help.ThemeConfig import io.legado.app.help.ThemeConfig
import io.legado.app.help.http.newCall import io.legado.app.help.http.newCallResponseBody
import io.legado.app.help.http.okHttpClient import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.text import io.legado.app.help.http.text
import io.legado.app.model.NoStackTraceException import io.legado.app.model.NoStackTraceException
@ -25,7 +25,7 @@ class OnLineImportViewModel(app: Application) : BaseViewModel(app) {
fun getText(url: String, success: (text: String) -> Unit) { fun getText(url: String, success: (text: String) -> Unit) {
execute { execute {
okHttpClient.newCall { okHttpClient.newCallResponseBody {
url(url) url(url)
}.text("utf-8") }.text("utf-8")
}.onSuccess { }.onSuccess {
@ -40,7 +40,7 @@ class OnLineImportViewModel(app: Application) : BaseViewModel(app) {
fun getBytes(url: String, success: (bytes: ByteArray) -> Unit) { fun getBytes(url: String, success: (bytes: ByteArray) -> Unit) {
execute { execute {
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
okHttpClient.newCall { okHttpClient.newCallResponseBody {
url(url) url(url)
}.bytes() }.bytes()
}.onSuccess { }.onSuccess {
@ -140,7 +140,7 @@ class OnLineImportViewModel(app: Application) : BaseViewModel(app) {
fun determineType(url: String, finally: (title: String, msg: String) -> Unit) { fun determineType(url: String, finally: (title: String, msg: String) -> Unit) {
execute { execute {
val rs = okHttpClient.newCall { val rs = okHttpClient.newCallResponseBody {
url(url) url(url)
} }
when (rs.contentType()) { when (rs.contentType()) {

@ -16,7 +16,7 @@ import io.legado.app.databinding.DialogReadBgTextBinding
import io.legado.app.databinding.ItemBgImageBinding import io.legado.app.databinding.ItemBgImageBinding
import io.legado.app.help.DefaultData import io.legado.app.help.DefaultData
import io.legado.app.help.ReadBookConfig import io.legado.app.help.ReadBookConfig
import io.legado.app.help.http.newCall import io.legado.app.help.http.newCallResponseBody
import io.legado.app.help.http.okHttpClient import io.legado.app.help.http.okHttpClient
import io.legado.app.lib.dialogs.SelectItem import io.legado.app.lib.dialogs.SelectItem
import io.legado.app.lib.dialogs.alert import io.legado.app.lib.dialogs.alert
@ -295,7 +295,7 @@ class BgTextConfigDialog : BaseDialogFragment(R.layout.dialog_read_bg_text) {
private fun importNetConfig(url: String) { private fun importNetConfig(url: String) {
execute { execute {
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
okHttpClient.newCall { okHttpClient.newCallResponseBody {
url(url) url(url)
}.bytes().let { }.bytes().let {
importConfig(it) importConfig(it)

@ -7,7 +7,7 @@ import io.legado.app.base.BaseViewModel
import io.legado.app.data.appDb import io.legado.app.data.appDb
import io.legado.app.data.entities.HttpTTS import io.legado.app.data.entities.HttpTTS
import io.legado.app.help.DefaultData import io.legado.app.help.DefaultData
import io.legado.app.help.http.newCall import io.legado.app.help.http.newCallResponseBody
import io.legado.app.help.http.okHttpClient import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.text import io.legado.app.help.http.text
import io.legado.app.model.NoStackTraceException import io.legado.app.model.NoStackTraceException
@ -25,7 +25,7 @@ class SpeakEngineViewModel(application: Application) : BaseViewModel(application
fun importOnLine(url: String) { fun importOnLine(url: String) {
execute { execute {
okHttpClient.newCall { okHttpClient.newCallResponseBody {
url(url) url(url)
}.text("utf-8").let { json -> }.text("utf-8").let { json ->
import(json) import(json)

@ -5,7 +5,7 @@ import io.legado.app.base.BaseViewModel
import io.legado.app.data.appDb import io.legado.app.data.appDb
import io.legado.app.data.entities.TxtTocRule import io.legado.app.data.entities.TxtTocRule
import io.legado.app.help.DefaultData import io.legado.app.help.DefaultData
import io.legado.app.help.http.newCall import io.legado.app.help.http.newCallResponseBody
import io.legado.app.help.http.okHttpClient import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.text import io.legado.app.help.http.text
import io.legado.app.utils.GSON import io.legado.app.utils.GSON
@ -30,7 +30,7 @@ class TocRegexViewModel(application: Application) : BaseViewModel(application) {
fun importOnLine(url: String, finally: (msg: String) -> Unit) { fun importOnLine(url: String, finally: (msg: String) -> Unit) {
execute { execute {
okHttpClient.newCall { okHttpClient.newCallResponseBody {
url(url) url(url)
}.text("utf-8").let { json -> }.text("utf-8").let { json ->
GSON.fromJsonArray<TxtTocRule>(json)?.let { GSON.fromJsonArray<TxtTocRule>(json)?.let {

@ -9,7 +9,7 @@ import androidx.documentfile.provider.DocumentFile
import io.legado.app.base.BaseViewModel import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppConst import io.legado.app.constant.AppConst
import io.legado.app.help.IntentData import io.legado.app.help.IntentData
import io.legado.app.help.http.newCall import io.legado.app.help.http.newCallResponseBody
import io.legado.app.help.http.okHttpClient import io.legado.app.help.http.okHttpClient
import io.legado.app.model.NoStackTraceException import io.legado.app.model.NoStackTraceException
import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.model.analyzeRule.AnalyzeUrl
@ -70,7 +70,7 @@ class WebViewModel(application: Application) : BaseViewModel(application) {
private suspend fun webData2bitmap(data: String): ByteArray? { private suspend fun webData2bitmap(data: String): ByteArray? {
return if (URLUtil.isValidUrl(data)) { return if (URLUtil.isValidUrl(data)) {
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
okHttpClient.newCall { okHttpClient.newCallResponseBody {
url(data) url(data)
}.bytes() }.bytes()
} else { } else {

@ -6,7 +6,7 @@ import io.legado.app.base.BaseViewModel
import io.legado.app.data.appDb import io.legado.app.data.appDb
import io.legado.app.data.entities.Book import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSource
import io.legado.app.help.http.newCall import io.legado.app.help.http.newCallResponseBody
import io.legado.app.help.http.okHttpClient import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.text import io.legado.app.help.http.text
import io.legado.app.model.NoStackTraceException import io.legado.app.model.NoStackTraceException
@ -87,7 +87,7 @@ class BookshelfViewModel(application: Application) : BaseViewModel(application)
val text = str.trim() val text = str.trim()
when { when {
text.isAbsUrl() -> { text.isAbsUrl() -> {
okHttpClient.newCall { okHttpClient.newCallResponseBody {
url(text) url(text)
}.text().let { }.text().let {
importBookshelf(it, groupId) importBookshelf(it, groupId)

@ -17,7 +17,7 @@ import io.legado.app.data.appDb
import io.legado.app.data.entities.RssArticle import io.legado.app.data.entities.RssArticle
import io.legado.app.data.entities.RssSource import io.legado.app.data.entities.RssSource
import io.legado.app.data.entities.RssStar import io.legado.app.data.entities.RssStar
import io.legado.app.help.http.newCall import io.legado.app.help.http.newCallResponseBody
import io.legado.app.help.http.okHttpClient import io.legado.app.help.http.okHttpClient
import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.model.rss.Rss import io.legado.app.model.rss.Rss
@ -157,7 +157,7 @@ class ReadRssViewModel(application: Application) : BaseViewModel(application),
private suspend fun webData2bitmap(data: String): ByteArray? { private suspend fun webData2bitmap(data: String): ByteArray? {
return if (URLUtil.isValidUrl(data)) { return if (URLUtil.isValidUrl(data)) {
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
okHttpClient.newCall { okHttpClient.newCallResponseBody {
url(data) url(data)
}.bytes() }.bytes()
} else { } else {

Loading…
Cancel
Save