diff --git a/README.md b/README.md new file mode 100644 index 000000000..d65e3dbdc --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# legado +## 阅读3.0 diff --git a/app/build.gradle b/app/build.gradle index 966ba8516..c299deee3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,29 +2,60 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' apply plugin: 'kotlin-kapt' +apply plugin: "de.timfreiheit.resourceplaceholders" +apply plugin: 'io.fabric' androidExtensions { experimental = true } +static def releaseTime() { + return new Date().format("yy.MMddHH", TimeZone.getTimeZone("GMT+8")) +} + +def name = "legado" +def version = "0." + releaseTime() +def gitCommits = Integer.parseInt('git rev-list --count HEAD'.execute([], project.rootDir).text.trim()) + android { compileSdkVersion 28 - dataBinding { - enabled = true + signingConfigs { + myConfig { + storeFile file(RELEASE_STORE_FILE) + storePassword RELEASE_STORE_PASSWORD + keyAlias RELEASE_KEY_ALIAS + keyPassword RELEASE_KEY_PASSWORD + v1SigningEnabled true + v2SigningEnabled true + } } defaultConfig { applicationId "io.legado.app" minSdkVersion 21 targetSdkVersion 28 - versionCode 1 - versionName "1.0" + versionCode gitCommits + versionName version testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + project.ext.set("archivesBaseName", name + "_" + version) } buildTypes { release { + signingConfig signingConfigs.myConfig minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } + debug { + signingConfig signingConfigs.myConfig + applicationIdSuffix '.debug' + versionNameSuffix 'debug' + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + android.applicationVariants.all { variant -> + variant.outputs.all { + outputFileName = "${name}_${defaultConfig.versionName}.apk" + } + } } compileOptions { @@ -32,8 +63,14 @@ android { targetCompatibility = '1.8' } + kotlinOptions { + jvmTarget = "1.8" + } } +resourcePlaceholders { + files = ['xml/shortcuts.xml'] +} kapt { arguments { @@ -41,51 +78,83 @@ kapt { } } -kotlin{ - experimental{ - coroutines "enable" - } -} - dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) - implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" - - implementation 'androidx.core:core-ktx:1.0.2' - implementation 'androidx.appcompat:appcompat:1.0.2' - implementation 'androidx.legacy:legacy-support-v4:1.0.0' - + testImplementation 'junit:junit:4.12' + androidTestImplementation 'androidx.test:runner:1.2.0' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + //kotlin + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" + + //fireBase + implementation 'com.google.firebase:firebase-core:17.2.0' + implementation 'com.crashlytics.sdk.android:crashlytics:2.10.1' + + //androidX + implementation 'androidx.core:core-ktx:1.2.0-beta01' + implementation 'androidx.appcompat:appcompat:1.1.0' + implementation 'androidx.media:media:1.1.0' + implementation 'androidx.preference:preference:1.1.0' implementation 'androidx.constraintlayout:constraintlayout:1.1.3' - implementation 'androidx.recyclerview:recyclerview:1.0.0' - implementation 'androidx.cardview:cardview:1.0.0' - implementation 'com.google.android.material:material:1.0.0' - - implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0' - kapt 'androidx.lifecycle:lifecycle-compiler:2.0.0' - - implementation 'androidx.room:room-runtime:2.0.0' - kapt 'androidx.room:room-compiler:2.0.0' - + implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.0.0' + implementation 'androidx.viewpager2:viewpager2:1.0.0-beta05' + implementation 'com.google.android.material:material:1.1.0-beta01' + implementation 'com.google.android:flexbox:1.1.0' + + //lifecycle + def lifecycle_version = '2.1.0' + implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version" + implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version" + + //room + def room_version = '2.2.0' + implementation "androidx.room:room-runtime:$room_version" + kapt "androidx.room:room-compiler:$room_version" + + //paging implementation 'androidx.paging:paging-runtime:2.1.0' //anko def anko_version = '0.10.8' implementation "org.jetbrains.anko:anko-sdk27:$anko_version" implementation "org.jetbrains.anko:anko-sdk27-listeners:$anko_version" + + //liveEventBus + implementation 'com.jeremyliao:live-event-bus-x:1.4.5' + //协程 - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.1' - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.1' + def coroutines_version = '1.2.2' + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" + //规则相关 - implementation 'pub.devrel:easypermissions:3.0.0' implementation 'com.google.code.gson:gson:2.8.5' - implementation 'com.jayway.jsonpath:json-path:2.4.0' implementation 'org.jsoup:jsoup:1.12.1' + implementation 'cn.wanghaomiao:JsoupXpath:2.3.2' + implementation 'com.jayway.jsonpath:json-path:2.4.0' + + //JS + implementation 'com.github.gedoor:rhino-android:1.3' //Retrofit - implementation 'com.squareup.okhttp3:logging-interceptor:3.14.0'// - implementation 'com.squareup.retrofit2:retrofit:2.5.0' + implementation 'com.squareup.okhttp3:logging-interceptor:4.1.0' + implementation 'com.squareup.retrofit2:retrofit:2.6.1' - testImplementation 'junit:junit:4.12' - androidTestImplementation 'androidx.test:runner:1.1.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' + //Glide + implementation 'com.github.bumptech.glide:glide:4.9.0' + + //webServer + implementation 'org.nanohttpd:nanohttpd:2.3.1' + implementation 'org.nanohttpd:nanohttpd-websocket:2.3.1' + + //二维码 + implementation 'cn.bingoogolapple:bga-qrcode-zxing:1.3.6' + + //颜色选择 + implementation 'com.jaredrummler:colorpicker:1.1.0' + + implementation 'org.apache.commons:commons-lang3:3.9' + implementation 'org.apache.commons:commons-text:1.8' } + +apply plugin: 'com.google.gms.google-services' \ No newline at end of file diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml new file mode 100644 index 000000000..b91572ada --- /dev/null +++ b/app/src/debug/res/values/strings.xml @@ -0,0 +1,4 @@ + + 阅读.debug + 阅读.debug·搜索 + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7c5e32f4a..dc9705e30 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,45 +1,126 @@ - - - - - - - - - - - - - + xmlns:tools="http://schemas.android.com/tools" + package="io.legado.app"> + + + + + + + + + + + + + - - - + android:name=".App" + android:allowBackup="true" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:networkSecurityConfig="@xml/network_security_config" + android:roundIcon="@mipmap/ic_launcher_round" + android:supportsRtl="true" + android:theme="@style/AppTheme.Light" + tools:ignore="AllowBackup,GoogleAppIndexingWarning,UnusedAttribute"> + + + + + + + + android:name=".ui.book.read.ReadBookActivity" + android:configChanges="locale|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout" + android:launchMode="singleTask" /> + + + + + + + + + + + + + - + - + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/bg/午后沙滩.jpg b/app/src/main/assets/bg/午后沙滩.jpg new file mode 100644 index 000000000..afe088dd9 Binary files /dev/null and b/app/src/main/assets/bg/午后沙滩.jpg differ diff --git a/app/src/main/assets/bg/宁静夜色.jpg b/app/src/main/assets/bg/宁静夜色.jpg new file mode 100644 index 000000000..501a46748 Binary files /dev/null and b/app/src/main/assets/bg/宁静夜色.jpg differ diff --git a/app/src/main/assets/bg/小清新.jpg b/app/src/main/assets/bg/小清新.jpg new file mode 100644 index 000000000..d9f229e1c Binary files /dev/null and b/app/src/main/assets/bg/小清新.jpg differ diff --git a/app/src/main/assets/bg/山水墨影.jpg b/app/src/main/assets/bg/山水墨影.jpg new file mode 100644 index 000000000..588374c68 Binary files /dev/null and b/app/src/main/assets/bg/山水墨影.jpg differ diff --git a/app/src/main/assets/bg/山水画.jpg b/app/src/main/assets/bg/山水画.jpg new file mode 100644 index 000000000..4c85a5b5d Binary files /dev/null and b/app/src/main/assets/bg/山水画.jpg differ diff --git a/app/src/main/assets/bg/护眼漫绿.jpg b/app/src/main/assets/bg/护眼漫绿.jpg new file mode 100644 index 000000000..daf109e8d Binary files /dev/null and b/app/src/main/assets/bg/护眼漫绿.jpg differ diff --git a/app/src/main/assets/bg/新羊皮纸.jpg b/app/src/main/assets/bg/新羊皮纸.jpg new file mode 100644 index 000000000..eec310669 Binary files /dev/null and b/app/src/main/assets/bg/新羊皮纸.jpg differ diff --git a/app/src/main/assets/bg/明媚倾城.jpg b/app/src/main/assets/bg/明媚倾城.jpg new file mode 100644 index 000000000..a68967cf2 Binary files /dev/null and b/app/src/main/assets/bg/明媚倾城.jpg differ diff --git a/app/src/main/assets/bg/浅黄飘带.jpg b/app/src/main/assets/bg/浅黄飘带.jpg new file mode 100644 index 000000000..ed033c057 Binary files /dev/null and b/app/src/main/assets/bg/浅黄飘带.jpg differ diff --git a/app/src/main/assets/bg/深宫魅影.jpg b/app/src/main/assets/bg/深宫魅影.jpg new file mode 100644 index 000000000..27896f0a6 Binary files /dev/null and b/app/src/main/assets/bg/深宫魅影.jpg differ diff --git a/app/src/main/assets/bg/清新.jpg b/app/src/main/assets/bg/清新.jpg new file mode 100644 index 000000000..cd9593da7 Binary files /dev/null and b/app/src/main/assets/bg/清新.jpg differ diff --git a/app/src/main/assets/bg/清新时光.jpg b/app/src/main/assets/bg/清新时光.jpg new file mode 100644 index 000000000..4bdd5aba6 Binary files /dev/null and b/app/src/main/assets/bg/清新时光.jpg differ diff --git a/app/src/main/assets/bg/羊皮纸1.jpg b/app/src/main/assets/bg/羊皮纸1.jpg new file mode 100644 index 000000000..40f397735 Binary files /dev/null and b/app/src/main/assets/bg/羊皮纸1.jpg differ diff --git a/app/src/main/assets/bg/羊皮纸2.jpg b/app/src/main/assets/bg/羊皮纸2.jpg new file mode 100644 index 000000000..3e46c8e0f Binary files /dev/null and b/app/src/main/assets/bg/羊皮纸2.jpg differ diff --git a/app/src/main/assets/bg/羊皮纸3.jpg b/app/src/main/assets/bg/羊皮纸3.jpg new file mode 100644 index 000000000..31b99a870 Binary files /dev/null and b/app/src/main/assets/bg/羊皮纸3.jpg differ diff --git a/app/src/main/assets/bg/羊皮纸4.jpg b/app/src/main/assets/bg/羊皮纸4.jpg new file mode 100644 index 000000000..cee31be58 Binary files /dev/null and b/app/src/main/assets/bg/羊皮纸4.jpg differ diff --git a/app/src/main/assets/bg/边彩画布.jpg b/app/src/main/assets/bg/边彩画布.jpg new file mode 100644 index 000000000..6638b9f21 Binary files /dev/null and b/app/src/main/assets/bg/边彩画布.jpg differ diff --git a/app/src/main/assets/bg/雾花.jpg b/app/src/main/assets/bg/雾花.jpg new file mode 100644 index 000000000..1326d1aa5 Binary files /dev/null and b/app/src/main/assets/bg/雾花.jpg differ diff --git a/app/src/main/assets/disclaimer.md b/app/src/main/assets/disclaimer.md new file mode 100644 index 000000000..0f630a9e2 --- /dev/null +++ b/app/src/main/assets/disclaimer.md @@ -0,0 +1,16 @@ +# 免责声明(Disclaimer) + +* 阅读是一款提供网络文学搜索的工具,为广大网络文学爱好者提供一种方便、快捷舒适的试读体验。 +* 当您搜索一本书的时,阅读会将该书的书名以关键词的形式提交到各个第三方网络文学网站。 +各第三方网站返回的内容与阅读无关,阅读对其概不负责,亦不承担任何法律责任。 +任何通过使用阅读而链接到的第三方网页均系他人制作或提供,您可能从第三方网页上获得其他服务,阅读对其合法性概不负责,亦不承担任何法律责任。 +第三方搜索引擎结果根据您提交的书名自动搜索获得并提供试读,不代表阅读赞成或被搜索链接到的第三方网页上的内容或立场。 +您应该对使用搜索引擎的结果自行承担风险。 +* 阅读不做任何形式的保证:不保证第三方搜索引擎的搜索结果满足您的要求,不保证搜索服务不中断,不保证搜索结果的安全性、正确性、及时性、合法性。 +因网络状况、通讯线路、第三方网站等任何原因而导致您不能正常使用阅读,阅读不承担任何法律责任。 +阅读尊重并保护所有使用阅读用户的个人隐私权,您注册的用户名、电子邮件地址等个人资料,非经您亲自许可或根据相关法律、法规的强制性规定,阅读不会主动地泄露给第三方。 +* 阅读致力于最大程度地减少网络文学阅读者在自行搜寻过程中的无意义的时间浪费,通过专业搜索展示不同网站中网络文学的最新章节。 +阅读在为广大小说爱好者提供方便、快捷舒适的试读体验的同时,也使优秀网络文学得以迅速、更广泛的传播,从而达到了在一定程度促进网络文学充分繁荣发展之目的。 +阅读鼓励广大小说爱好者通过阅读发现优秀网络小说及其提供商,并建议阅读正版图书。 +任何单位或个人认为通过阅读搜索链接到的第三方网页内容可能涉嫌侵犯其信息网络传播权,应该及时向阅读提出书面权力通知,并提供身份证明、权属证明及详细侵权情况证明。 +阅读在收到上述法律文件后,将会依法尽快断开相关链接内容。 \ No newline at end of file diff --git a/app/src/main/assets/number.ttf b/app/src/main/assets/number.ttf new file mode 100644 index 000000000..f2804dbe9 Binary files /dev/null and b/app/src/main/assets/number.ttf differ diff --git a/app/src/main/assets/readConfig.json b/app/src/main/assets/readConfig.json new file mode 100644 index 000000000..21cea9f69 --- /dev/null +++ b/app/src/main/assets/readConfig.json @@ -0,0 +1,72 @@ +[ + { + "bgStr": "羊皮纸2.jpg", + "bgType": 1, + "darkStatusIcon": true, + "textColor": "#5E432E", + "textSize": 22, + "letterSpacing": 0, + "lineSpacingExtra": 10, + "lineSpacingMultiplier": 1.2, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 0, + "paddingBottom": 0 + }, + { + "bgStr": "新羊皮纸.jpg", + "bgType": 1, + "darkStatusIcon": true, + "textColor": "#5E432E", + "textSize": 22, + "letterSpacing": 0, + "lineSpacingExtra": 10, + "lineSpacingMultiplier": 1.2, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 0, + "paddingBottom": 0 + }, + { + "bgStr": "#015A86", + "bgType": 0, + "darkStatusIcon": false, + "textColor": "#FFFFFF", + "textSize": 22, + "letterSpacing": 0, + "lineSpacingExtra": 10, + "lineSpacingMultiplier": 1.2, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 0, + "paddingBottom": 0 + }, + { + "bgStr": "宁静夜色", + "bgType": 1, + "darkStatusIcon": false, + "textColor": "#adadad", + "textSize": 22, + "letterSpacing": 0, + "lineSpacingExtra": 10, + "lineSpacingMultiplier": 1.2, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 0, + "paddingBottom": 0 + }, + { + "bgStr": "#000000", + "bgType": 0, + "darkStatusIcon": false, + "textColor": "#adadad", + "textSize": 22, + "letterSpacing": 0, + "lineSpacingExtra": 10, + "lineSpacingMultiplier": 1.2, + "paddingLeft": 16, + "paddingRight": 16, + "paddingTop": 0, + "paddingBottom": 0 + } +] \ No newline at end of file diff --git a/app/src/main/assets/txtChapterRule.json b/app/src/main/assets/txtChapterRule.json new file mode 100644 index 000000000..b035fccb5 --- /dev/null +++ b/app/src/main/assets/txtChapterRule.json @@ -0,0 +1,38 @@ +[ + { + "enable": true, + "name": "默认正则1", + "rule": "^(.{0,8})(第)([0-9零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,10})([章节卷集部篇回场])(.{0,30})$", + "serialNumber": 0 + }, + { + "enable": true, + "name": "默认正则2", + "rule": "^([0-9]{1,5})([\\,\\.,-])(.{1,20})$", + "serialNumber": 1 + }, + { + "enable": true, + "name": "默认正则3", + "rule": "^(\\s{0,4})([\\(【《]?(卷)?)([0-9零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,10})([\\.:: \f\t])(.{0,30})$", + "serialNumber": 2 + }, + { + "enable": true, + "name": "默认正则4", + "rule": "^(\\s{0,4})([\\((【《])(.{0,30})([\\))】》])(\\s{0,2})$", + "serialNumber": 3 + }, + { + "enable": true, + "name": "默认正则5", + "rule": "^(\\s{0,4})(正文)(.{0,20})$", + "serialNumber": 4 + }, + { + "enable": true, + "name": "默认正则6", + "rule": "^(.{0,4})(Chapter|chapter)(\\s{0,4})([0-9]{1,4})(.{0,30})$", + "serialNumber": 5 + } +] \ No newline at end of file diff --git a/app/src/main/assets/updateLog.md b/app/src/main/assets/updateLog.md new file mode 100644 index 000000000..93e1287fd --- /dev/null +++ b/app/src/main/assets/updateLog.md @@ -0,0 +1,5 @@ +## 本软件为开源软件,没有上架Google Play,请不要在任何地方购买! +### 关注公众号[开源阅读软件]点击广告可支持作者! +### 捐赠里点击红包搜索码可开启高级功能! +## 更新日志 + diff --git a/app/src/main/assets/web/bookshelf.css b/app/src/main/assets/web/bookshelf.css new file mode 100644 index 000000000..f6e26caa9 --- /dev/null +++ b/app/src/main/assets/web/bookshelf.css @@ -0,0 +1,176 @@ +html, body { + height: 100%; + margin: 0; +} + +.hide { + display: none; +} + +.top, .showchapter, .hidebooks { + width: 60px; + height: 50px; + position: absolute; + right: 30px; + bottom: 30px; + color: black; + font-size: 28px; + background-color: #ddd; + opacity: 0.85; +} + +.top { + bottom: 150px; +} + +.showchapter { + bottom: 90px; + bottom: 90px; +} + +.address { + width: 270px; +} + +.nav { + border-bottom: solid 1px #ccc; +} + +input, button { + width: 110px; + line-height: 34px; + background-color: #eee; + color: #555; + border: none; + margin: 10px 5px; + font-weight: 500; + border-radius: 2px; + outline: none; + cursor: pointer; +} + +input { + padding: 0 10px; + cursor: text; +} + + input:hover, button:hover { + border-color: #aaa; + background-color: #efefef; + color: #222; + outline: solid 1px #ccc; + } + +.allcontent { + height: calc(100% - 60px); +} + +.allscreen { + height: 100% +} + +.books > div { + display: inline-block; + margin: 10px; + vertical-align: top; + border: solid 1px #ddd; +} + +.read > .books { + width: 420px; + float: left; + height: 100%; + overflow: auto; + border-right: solid 1px #ccc; +} + + .read > .books > div { + margin-right: 0; + border-right: none; + } + + +.more { + overflow-y: auto; + height: 100%; + display: none; +} + +.read .more { + display: block; +} + +.books > div > img { + width: 120px; + height: 180px; + float: left; + margin-right: 10px; + cursor: pointer; +} + +.info { + padding: 10px 20px 0 20px; + width: 600px; + margin: 0 auto; +} + + .info > img { + width: 600px; + height: 900px; + } + + .info p { + line-height: 1.5; + text-align: justify; + margin: 0; + } + +.books tr:nth-child(n+2) td { + border-top: solid 1px #999; +} + +.books td:nth-child(1) { + vertical-align: top; + width: 50px; +} + +.books td:nth-child(2) { + vertical-align: top; + width: 200px; +} + +.clear { + clear: both; +} + +.chapter { + margin: 10px; + max-height: 500px; + overflow-y: auto; + border-top: solid 1px #333; + border-bottom: solid 1px #333; +} + + .chapter button { + width: 230px; + text-align: left; + text-indent: 14px; + margin: 10px 4px; + } + + +.content { + padding: 20px; + text-align: justify; + min-height: 1000px; + padding-bottom: 200px; +} + + .content h2 { + font-family: "Microsoft YaHei",微软雅黑,"MicrosoftJhengHei",华文细黑,STHeiti,MingLiu; + font-weight: 500; + text-align: center; + line-height: 100px; + font-size: 40px; + margin: 0; + } diff --git a/app/src/main/assets/web/bookshelf.html b/app/src/main/assets/web/bookshelf.html new file mode 100644 index 000000000..485622373 --- /dev/null +++ b/app/src/main/assets/web/bookshelf.html @@ -0,0 +1,32 @@ + + + + + 阅读书架 + + + + + + + + +
+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/app/src/main/assets/web/bookshelf.js b/app/src/main/assets/web/bookshelf.js new file mode 100644 index 000000000..22c8ff87e --- /dev/null +++ b/app/src/main/assets/web/bookshelf.js @@ -0,0 +1,161 @@ +var $ = document.querySelector.bind(document) + , $$ = document.querySelectorAll.bind(document) + , $c = document.createElement.bind(document) + , randomImg = "http://acg.bakayun.cn/randbg.php?t=dfzh" + , randomImg2 = "http://img.xjh.me/random_img.php" + , books + ; + +var formatTime = value => { + return new Date(value).toLocaleString('zh-CN', { + hour12: false, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" + }).replace(/\//g, "-"); +}; + +var apiMap = { + getBookshelf: "/getBookshelf", + getChapterList: "/getChapterList", + getBookContent: "/getBookContent", + saveBook: "/saveBook" +}; + +var apiAddress = (apiName, url) => { + let address = $('#address').value || window.location.host; + if (!(/^http|^\/\//).test(address)) { + address = "//" + address; + } + if (!(/:\d{4,}/).test(address.split("//")[1].split("/")[0])) { + address += ":1122"; + } + localStorage.setItem('address', address); + return address + apiMap[apiName] + (url ? "?url=" + encodeURIComponent(url) : ""); +}; + +var init = () => { + $('#allcontent').classList.remove("read"); + $('#books').innerHTML = ""; + fetch(apiAddress("getBookshelf"), { mode: "cors" }) + .then(res => res.json()) + .then(data => { + if (!data.isSuccess) { + alert(getBookshelf.errorMsg); + return; + } + books = data.data.sort((book1, book2) => book1.serialNumber - book2.serialNumber); + books.forEach(book => { + let bookDiv = $c("div"); + let img = $c("img"); + img.src = book.bookInfoBean.coverUrl || randomImg; + img.setAttribute("data-series-num", book.serialNumber); + bookDiv.appendChild(img); + bookDiv.innerHTML += ` + + + + + +
书名:${book.bookInfoBean.name}
作者:${book.bookInfoBean.author}
阅读:${book.durChapterName}
${formatTime(book.finalDate)}
更新:${book.lastChapterName}
${formatTime(book.finalRefreshData)}
来源:${book.bookInfoBean.origin}
`; + $('#books').appendChild(bookDiv); + }); + $$('#books img').forEach(bookImg => + bookImg.addEventListener("click", () => { + $('#allcontent').classList.add("read"); + var book = books[bookImg.getAttribute("data-series-num")]; + $("#info").innerHTML = ` +

  来源:${book.bookInfoBean.origin}

+

  书名:${book.bookInfoBean.name}

+

  作者:${book.bookInfoBean.author}

+

阅读章节:${book.durChapterName}

+

阅读时间:${formatTime(book.finalDate)}

+

最新章节:${book.lastChapterName}

+

检查时间:${formatTime(book.finalRefreshData)}

+

  简介:${book.bookInfoBean.introduce.trim().replace(/\n/g, "
")}

`; + window.location.hash = ""; + window.location.hash = "#info"; + $("#content").innerHTML = "章节列表加载中..."; + $("#chapter").innerHTML = ""; + fetch(apiAddress("getChapterList", book.noteUrl), { mode: "cors" }) + .then(res => res.json()) + .then(data => { + if (!data.isSuccess) { + alert(data.errorMsg); + $("#content").innerHTML = "章节列表加载失败!"; + return; + } + + data.data.forEach(chapter => { + let ch = $c("button"); + ch.setAttribute("data-url", chapter.durChapterUrl); + ch.setAttribute("title", chapter.durChapterName); + ch.innerHTML = chapter.durChapterName.length > 15 ? chapter.durChapterName.substring(0, 14) + "..." : chapter.durChapterName; + $("#chapter").appendChild(ch); + }); + $('#chapter').scrollTop = 0; + $("#content").innerHTML = "章节列表加载完成!"; + }); + + })); + }); +}; + +$("#back").addEventListener("click", () => { + if (window.location.hash === "#content") { + window.location.hash = "#chapter"; + } else if (window.location.hash === "#chapter") { + window.location.hash = "#info"; + } else { + $('#allcontent').classList.remove("read"); + } +}); + +$("#refresh").addEventListener("click", init); + +$('#hidebooks').addEventListener("click", () => { + $("#books").classList.toggle("hide"); + $(".nav").classList.toggle("hide"); + $("#allcontent").classList.toggle("allscreen"); +}); + +$('#top').addEventListener("click", () => { + window.location.hash = ""; + window.location.hash = "#info"; +}); + +$('#showchapter').addEventListener("click", () => { + window.location.hash = ""; + window.location.hash = "#chapter"; +}); + +$('#chapter').addEventListener("click", (e) => { + if (e.target.tagName === "BUTTON") { + var url = e.target.getAttribute("data-url"); + var name = e.target.getAttribute("title"); + if (!url) { + alert("未取得章节地址"); + } + $("#content").innerHTML = "

" + name + " 加载中...

"; + fetch(apiAddress("getBookContent", url), { mode: "cors" }) + .then(res => res.json()) + .then(data => { + if (!data.isSuccess) { + alert(data.errorMsg); + $("#content").innerHTML = "

" + name + " 加载失败!

"; + return; + } + var content = data.data.trim().split("\n\n"); + if (content.length === 2) { + $("#content").innerHTML = `

${content[0]}

  (全文 ${content[1].length} 字)

  ` + content[1].trim().replace(/\n/g, "

"); + } else { + $("#content").innerHTML = `

${name || e.target.innerHTML}

  (全文 ${data.data.length} 字)

  ` + data.data.trim().replace(/\n/g, "

"); + } + window.location.hash = ""; + window.location.hash = "#content"; + }); + } +}); + +$('#address').setAttribute("placeholder", "阅读APP地址或IP:" + window.location.host); +if (!$('#address').value && typeof localStorage && localStorage.getItem('address')) { + $('#address').value = localStorage.getItem('address'); +} +init(); diff --git a/app/src/main/assets/web/favicon.ico b/app/src/main/assets/web/favicon.ico new file mode 100644 index 000000000..c3d5e6bc0 Binary files /dev/null and b/app/src/main/assets/web/favicon.ico differ diff --git a/app/src/main/assets/web/index.css b/app/src/main/assets/web/index.css new file mode 100644 index 000000000..a3e76ba06 --- /dev/null +++ b/app/src/main/assets/web/index.css @@ -0,0 +1,148 @@ +body { + margin: 0; +} +.editor { + display: flex; + align-items: stretch; +} +.setbox, +.menu, +.outbox { + flex: 1; + display: flex; + flex-flow: column; + max-height: 100vh; + overflow-y: auto; +} +.menu { + justify-content: center; + max-width: 90px; + margin: 0 5px; +} +.menu .button { + width: 90px; + height: 30px; + min-height: 30px; + margin: 5px 0px; + cursor: pointer; +} +@keyframes stroker { + 0% { + stroke-dashoffset: 0 + } + 100% { + stroke-dashoffset: -240 + } +} +.button rect { + width: 100%; + height: 100%; + fill: transparent; + stroke: #666; + stroke-width: 2px; +} +.button rect.busy { + stroke: #fD1850; + stroke-dasharray: 30 90; + animation: stroker 1s linear infinite; +} +.button text { + text-anchor: middle; + dominant-baseline: middle; +} +.setbox { + min-width: 40em; +} +.rules, +.tabbox { + flex: 1; + display: flex; + flex-flow: column; +} +.rules>* { + display: flex; + margin: 2px 0; +} +.rules textarea { + flex: 1; + margin-left: 5px; +} +.rules>*, +.rules>*>div, +.rules textarea { + min-height: 1em; +} +textarea { + word-break: break-all; +} +.tabtitle { + display: flex; + z-index: 1; + justify-content: flex-end; +} +.tabtitle>div { + cursor: pointer; + padding: 1px 10px 0 10px; + border-bottom: 3px solid transparent; + font-weight: bold; +} +.tabtitle>.this { + color: #4f9da6; + border-bottom-color: #4EBBE4; +} +.tabbody { + flex: 1; + display: flex; + margin-top: -1px; + border: 1px solid #A9A9A9; + height: 0; +} +.tabbody>* { + flex: 1; + flex-flow: column; + display: none; +} +.tabbody>.this { + display: flex; +} +.tabbody>*>.titlebar{ + display: flex; +} +.tabbody>*>.titlebar>*{ + flex: 1; + margin: 1px 1px 1px 1px; +} +.tabbody>*>.context { + flex: 1; + flex-flow: column; + border: 0; + padding: 5px; + overflow-y: auto; +} +.tabbody>*>.inputbox{ + border: 0; + border-bottom: #A9A9A9 solid 1px; + height: 15px; + text-align:center; +} +.link>* { + display: flex; + margin: 5px; + border-bottom: 1px solid; + text-decoration: none; +} +#RuleList>label>* { + background: #eee; + padding-left: 3px; + margin: 2px 0; + cursor: pointer; +} +#RuleList input[type=radio] { + display: none; +} +#RuleList input[type="radio"]:checked+* { + background: #15cda8; +} +.isError { + color: #FF0000; +} \ No newline at end of file diff --git a/app/src/main/assets/web/index.html b/app/src/main/assets/web/index.html new file mode 100644 index 000000000..2c47adb7f --- /dev/null +++ b/app/src/main/assets/web/index.html @@ -0,0 +1,306 @@ + + + + + + 书源编辑器v3.8 + + + + +
+
+
+
书源基础信息
+
+
书源名称:
+ +
+
+
书源分组:
+ +
+
+
书源域名:
+ +
+
+
登录网页:
+ +
+
书籍发现规则
+
+
发现菜单:
+ +
+
+
结果列表:
+ +
+
+
书籍名称:
+ +
+
+
书籍作者:
+ +
+
+
书籍分类:
+ +
+
+
最新章节:
+ +
+
+
简介内容:
+ +
+
+
封面链接:
+ +
+
+
详情链接:
+ +
+
书籍搜索规则
+
+
搜索网址:
+ +
+
+
结果验证:
+ +
+
+
结果列表:
+ +
+
+
书籍名称:
+ +
+
+
书籍作者:
+ +
+
+
书籍分类:
+ +
+
+
最新章节:
+ +
+
+
简介内容:
+ +
+
+
封面链接:
+ +
+
+
详情链接:
+ +
+
书籍详情规则
+
+
页面处理:
+ +
+
+
书籍名称:
+ +
+
+
书籍作者:
+ +
+
+
书籍分类:
+ +
+
+
最新章节:
+ +
+
+
简介内容:
+ +
+
+
封面链接:
+ +
+
+
目录链接:
+ +
+
目录列表规则
+
+
目录翻页:
+ +
+
+
目录列表:
+ +
+
+
章节名称:
+ +
+
+
章节链接:
+ +
+
正文阅读规则
+
+
章节正文:
+ +
+
+
正文翻页:
+ +
+
其它规则
+
+
浏览标识:
+ +
+
+
排序编号:
+ +
+
+
搜索权重:
+ +
+
+
是否启用:
+ +
+
+
+ +
+
+
+
编辑书源
+
调试书源
+
书源列表
+
帮助信息
+
+
+
+ +
+
+ + +
+
+
+ + + + +
+
+
+
+ +
+
+
+
+
+ + + + \ No newline at end of file diff --git a/app/src/main/assets/web/index.js b/app/src/main/assets/web/index.js new file mode 100644 index 000000000..9463800ce --- /dev/null +++ b/app/src/main/assets/web/index.js @@ -0,0 +1,362 @@ +// 简化js原生选择器 +function $(selector) { return document.querySelector(selector); } +function $$(selector) { return document.querySelectorAll(selector); } +// 读写Hash值(val未赋值时为读取) +function hashParam(key, val) { + let hashstr = decodeURIComponent(window.location.hash); + let regKey = new RegExp(`${key}=([^&]*)`); + let getVal = regKey.test(hashstr) ? hashstr.match(regKey)[1] : null; + if (val == undefined) return getVal; + if (hashstr == '' || hashstr == '#') { + window.location.hash = `#${key}=${val}`; + } + else { + if (getVal) window.location.hash = hashstr.replace(getVal, val); + else { + window.location.hash = hashstr.indexOf(key) > -1 ? hashstr.replace(regKey, `${key}=${val}`) : `${hashstr}&${key}=${val}`; + } + } +} +// 创建书源规则容器对象 +const RuleJSON = (() => { + let ruleJson = {}; + $$('.rules textarea').forEach(item => ruleJson[item.id] = ''); +// for (let item of $$('.rules textarea')) ruleJson[item.id] = ''; + ruleJson.serialNumber = 0; + ruleJson.weight = 0; + ruleJson.enable = true; + return ruleJson; +})(); +// 选项卡Tab切换事件处理 +function showTab(tabName) { + $$('.tabtitle>*').forEach(node => { node.className = node.className.replace(' this', ''); }); + $$('.tabbody>*').forEach(node => { node.className = node.className.replace(' this', ''); }); + $(`.tabbody>.${$(`.tabtitle>*[name=${tabName}]`).className}`).className += ' this'; + $(`.tabtitle>*[name=${tabName}]`).className += ' this'; + hashParam('tab', tabName); +} +// 书源列表列表标签构造函数 +function newRule(rule) { + return ``; +} +// 缓存规则列表 +var RuleSources = []; +if (localStorage.getItem('RuleSources')) { + RuleSources = JSON.parse(localStorage.getItem('RuleSources')); + RuleSources.forEach(item => $('#RuleList').innerHTML += newRule(item)); +} +// 页面加载完成事件 +window.onload = () => { + $$('.tabtitle>*').forEach(item => { + item.addEventListener('click', () => { + showTab(item.innerHTML); + }); + }); + if (hashParam('tab')) showTab(hashParam('tab')); +} +// 获取数据 +function HttpGet(url) { + return fetch(hashParam('domain') ? hashParam('domain') + url : url) + .then(res => res.json()).catch(err => console.error('Error:', err)); +} +// 提交数据 +function HttpPost(url, data) { + return fetch(hashParam('domain') ? hashParam('domain') + url : url, { + body: JSON.stringify(data), + method: 'POST', + mode: "cors", + headers: new Headers({ + 'Content-Type': 'application/json;charset=utf-8' + }) + }).then(res => res.json()).catch(err => console.error('Error:', err)); +} +// 将书源表单转化为书源对象 +function rule2json() { + Object.keys(RuleJSON).forEach((key) => RuleJSON[key] = $('#' + key).value); + RuleJSON.serialNumber = RuleJSON.serialNumber == '' ? 0 : parseInt(RuleJSON.serialNumber); + RuleJSON.weight = RuleJSON.weight == '' ? 0 : parseInt(RuleJSON.weight); + RuleJSON.enable = RuleJSON.enable == '' || RuleJSON.enable.toLocaleLowerCase().replace(/^\s*|\s*$/g, '') == 'true'; + return RuleJSON; +} +// 将书源对象填充到书源表单 +function json2rule(RuleEditor) { + Object.keys(RuleJSON).forEach((key) => $("#" + key).value = RuleEditor[key] ? RuleEditor[key] : ''); +} +// 记录操作过程 +var course = { "old": [], "now": {}, "new": [] }; +if (localStorage.getItem('course')) { + course = JSON.parse(localStorage.getItem('course')); + json2rule(course.now); +} +else { + course.now = rule2json(); + window.localStorage.setItem('course', JSON.stringify(course)); +} +function todo() { + course.old.push(Object.assign({}, course.now)); + course.now = rule2json(); + course.new = []; + if (course.old.length > 50) course.old.shift(); // 限制历史记录堆栈大小 + localStorage.setItem('course', JSON.stringify(course)); +} +function undo() { + course = JSON.parse(localStorage.getItem('course')); + if (course.old.length > 0) { + course.new.push(course.now); + course.now = course.old.pop(); + localStorage.setItem('course', JSON.stringify(course)); + json2rule(course.now); + } +} +function redo() { + course = JSON.parse(localStorage.getItem('course')); + if (course.new.length > 0) { + course.old.push(course.now); + course.now = course.new.pop(); + localStorage.setItem('course', JSON.stringify(course)); + json2rule(course.now); + } +} +function setRule(editRule) { + let checkRule = RuleSources.find(x => x.bookSourceUrl == editRule.bookSourceUrl); + if ($(`input[id="${editRule.bookSourceUrl}"]`)) { + Object.keys(checkRule).forEach(key => { checkRule[key] = editRule[key]; }); + $(`input[id="${editRule.bookSourceUrl}"]+*`).innerHTML = `${editRule.bookSourceName}
${editRule.bookSourceUrl}`; + } else { + RuleSources.push(editRule); + $('#RuleList').innerHTML += newRule(editRule); + } +} +$$('input').forEach((item) => { item.addEventListener('change', () => { todo() }) }); +$$('textarea').forEach((item) => { item.addEventListener('change', () => { todo() }) }); +// 处理按钮点击事件 +$('.menu').addEventListener('click', e => { + let thisNode = e.target; + thisNode = thisNode.parentNode.nodeName == 'svg' ? thisNode.parentNode.querySelector('rect') : + thisNode.nodeName == 'svg' ? thisNode.querySelector('rect') : null; + if (!thisNode) return; + if (thisNode.getAttribute('class') == 'busy') return; + thisNode.setAttribute('class', 'busy'); + switch (thisNode.id) { + case 'push': + $$('#RuleList>label>div').forEach(item => { item.className = ''; }); + (async () => { + await HttpPost(`/saveSources`, RuleSources).then(json => { + if (json.isSuccess) { + let okData = json.data; + if (Array.isArray(okData)) { + let failMsg = ``; + if (RuleSources.length > okData.length) { + RuleSources.forEach(item => { + if (okData.find(x => x.bookSourceUrl == item.bookSourceUrl)) { } + else { $(`#RuleList #${item.bookSourceUrl}+*`).className += 'isError'; } + }); + failMsg = '\n推送失败的书源将用红色字体标注!'; + } + alert(`批量推送书源到「阅读APP」\n共计: ${RuleSources.length} 条\n成功: ${okData.length} 条\n失败: ${RuleSources.length - okData.length} 条${failMsg}`); + } + else { + alert(`批量推送书源到「阅读APP」成功!\n共计: ${RuleSources.length} 条`); + } + } + else { + alert(`批量推送书源失败!\nErrorMsg: ${json.errorMsg}`); + } + }).catch(err => { alert(`批量推送书源失败,无法连接到「阅读APP」!\n${err}`); }); + thisNode.setAttribute('class', ''); + })(); + return; + case 'pull': + showTab('书源列表'); + (async () => { + await HttpGet(`/getSources`).then(json => { + if (json.isSuccess) { + $('#RuleList').innerHTML = '' + localStorage.setItem('RuleSources', JSON.stringify(RuleSources = json.data)); + RuleSources.forEach(item => { + $('#RuleList').innerHTML += newRule(item); + }); + alert(`成功拉取 ${RuleSources.length} 条书源`); + } + else { + alert(`批量拉取书源失败!\nErrorMsg: ${json.errorMsg}`); + } + }).catch(err => { alert(`批量拉取书源失败,无法连接到「阅读APP」!\n${err}`); }); + thisNode.setAttribute('class', ''); + })(); + return; + case 'editor': + if ($('#RuleJsonString').value == '') break; + try { + json2rule(JSON.parse($('#RuleJsonString').value)); + todo(); + } catch (error) { + console.log(error); + alert(error); + } + break; + case 'conver': + showTab('编辑书源'); + $('#RuleJsonString').value = JSON.stringify(rule2json(), null, 4); + break; + case 'initial': + $$('.rules textarea').forEach(item => { item.value = '' }); + todo(); + break; + case 'undo': + undo() + break; + case 'redo': + redo() + break; + case 'debug': + showTab('调试书源'); + let wsOrigin = (hashParam('domain') || location.origin).replace(/^.*?:/, 'ws:').replace(/\d+$/, (port) => (parseInt(port) + 1)); + let DebugInfos = $('#DebugConsole'); + function DebugPrint(msg) { DebugInfos.value += `\n${msg}`; DebugInfos.scrollTop = DebugInfos.scrollHeight; } + let saveRule = [rule2json()]; + HttpPost(`/saveSources`, saveRule).then(sResult => { + if (sResult.isSuccess) { + let sKey = DebugKey.value ? DebugKey.value : '我的'; + $('#DebugConsole').value = `书源《${saveRule[0].bookSourceName}》保存成功!使用搜索关键字“${sKey}”开始调试...`; + let ws = new WebSocket(`${wsOrigin}/sourceDebug`); + ws.onopen = () => { + ws.send(`{"tag":"${saveRule[0].bookSourceUrl}", "key":"${sKey}"}`); + }; + ws.onmessage = (msg) => { + DebugPrint(msg.data == 'finish' ? `\n[${Date().split(' ')[4]}] 调试任务已完成!` : msg.data); + if (msg.data == 'finish') setRule(saveRule[0]); + }; + ws.onerror = (err) => { + throw `${err.data}`; + } + ws.onclose = () => { + thisNode.setAttribute('class', ''); + DebugPrint(`[${Date().split(' ')[4]}] 调试服务已关闭!`); + } + } else throw `${sResult.errorMsg}`; + }).catch(err => { + DebugPrint(`调试过程意外中止,以下是详细错误信息:\n${err}`); + thisNode.setAttribute('class', ''); + }); + return; + case 'accept': + (async () => { + let saveRule = [rule2json()]; + await HttpPost(`/saveSources`, saveRule).then(json => { + alert(json.isSuccess ? `书源《${saveRule[0].bookSourceName}》已成功保存到「阅读APP」` : `书源《${saveRule[0].bookSourceName}》保存失败!\nErrorMsg: ${json.errorMsg}`); + setRule(saveRule[0]); + }).catch(err => { alert(`保存书源失败,无法连接到「阅读APP」!\n${err}`); }); + thisNode.setAttribute('class', ''); + })(); + return; + default: + } + setTimeout(() => { thisNode.setAttribute('class', ''); }, 500); +}); +$('#DebugKey').addEventListener('keydown', e => { + if (e.keyCode == 13) { + let clickEvent = document.createEvent('MouseEvents'); + clickEvent.initEvent("click", true, false); + $('#debug').dispatchEvent(clickEvent); + } +}); + +// 列表规则更改事件 +$('#RuleList').addEventListener('click', e => { + let editRule = null; + if (e.target && e.target.getAttribute('name') == 'rule') { + editRule = rule2json(); + json2rule(RuleSources.find(x => x.bookSourceUrl == e.target.id)); + } else return; + if (editRule.bookSourceUrl == '') return; + if (editRule.bookSourceName == '') editRule.bookSourceName = editRule.bookSourceUrl.replace(/.*?\/\/|\/.*/g, ''); + setRule(editRule); + localStorage.setItem('RuleSources', JSON.stringify(RuleSources)); +}); +// 处理列表按钮事件 +$('.tab3>.titlebar').addEventListener('click', e => { + let thisNode = e.target; + if (thisNode.nodeName != 'BUTTON') return; + switch (thisNode.id) { + case 'Import': + let fileImport = document.createElement('input'); + fileImport.type = 'file'; + fileImport.accept = '.json'; + fileImport.addEventListener('change', () => { + let file = fileImport.files[0]; + let reader = new FileReader(); + reader.onloadend = function (evt) { + if (evt.target.readyState == FileReader.DONE) { + let fileText = evt.target.result; + try { + let fileJson = JSON.parse(fileText); + let newSources = []; + newSources.push(...fileJson); + if (window.confirm(`如何处理导入的书源?\n"确定": 覆盖当前列表(不会删除APP源)\n"取消": 插入列表尾部(自动忽略重复源)`)) { + localStorage.setItem('RuleSources', JSON.stringify(RuleSources = newSources)); + $('#RuleList').innerHTML = '' + RuleSources.forEach(item => { + $('#RuleList').innerHTML += newRule(item); + }); + } + else { + newSources = newSources.filter(item => !JSON.stringify(RuleSources).includes(item.bookSourceUrl)); + RuleSources.push(...newSources); + localStorage.setItem('RuleSources', JSON.stringify(RuleSources)); + newSources.forEach(item => { + $('#RuleList').innerHTML += newRule(item); + }); + } + alert(`成功导入 ${newSources.length} 条书源`); + } + catch (err) { + alert(`导入书源文件失败!\n${err}`); + } + } + }; + reader.readAsText(file); + }, false); + fileImport.click(); + break; + case 'Export': + let fileExport = document.createElement('a'); + fileExport.download = `Rules${Date().replace(/.*?\s(\d+)\s(\d+)\s(\d+:\d+:\d+).*/, '$2$1$3').replace(/:/g, '')}.json`; + let myBlob = new Blob([JSON.stringify(RuleSources, null, 4)], { type: "application/json" }); + fileExport.href = window.URL.createObjectURL(myBlob); + fileExport.click(); + break; + case 'Delete': + let selectRule = $('#RuleList input:checked'); + if (!selectRule) { + alert(`没有书源被选中!`); + return; + } + if (confirm(`确定要删除选定书源吗?\n(同时删除APP内书源)`)) { + let selectRuleUrl = selectRule.id; + let deleteSources = RuleSources.filter(item => item.bookSourceUrl == selectRuleUrl); // 提取待删除的书源 + let laveSources = RuleSources.filter(item => !(item.bookSourceUrl == selectRuleUrl)); // 提取待留下的书源 + HttpPost(`/deleteSources`, deleteSources).then(json => { + if (json.isSuccess) { + let selectNode = document.getElementById(selectRuleUrl).parentNode; + selectNode.parentNode.removeChild(selectNode); + localStorage.setItem('RuleSources', JSON.stringify(RuleSources = laveSources)); + if ($('#bookSourceUrl').value == selectRuleUrl) { + $$('.rules textarea').forEach(item => { item.value = '' }); + todo(); + } + console.log(deleteSources); + console.log(`以上书源已删除!`) + } + }).catch(err => { alert(`删除书源失败,无法连接到「阅读APP」!\n${err}`); }); + } + break; + case 'ClrAll': + if (confirm(`确定要清空当前书源列表吗?\n(不会删除APP内书源)`)) { + localStorage.setItem('RuleSources', JSON.stringify(RuleSources = [])); + $('#RuleList').innerHTML = '' + } + break; + default: + } +}); \ No newline at end of file diff --git a/app/src/main/ic_launcher-web.png b/app/src/main/ic_launcher-web.png new file mode 100644 index 000000000..7e2060a9c Binary files /dev/null and b/app/src/main/ic_launcher-web.png differ diff --git a/app/src/main/java/io/legado/app/App.kt b/app/src/main/java/io/legado/app/App.kt index 6ef0e877d..54238a6ad 100644 --- a/app/src/main/java/io/legado/app/App.kt +++ b/app/src/main/java/io/legado/app/App.kt @@ -1,8 +1,29 @@ package io.legado.app +import android.app.Activity import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import android.os.Bundle +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatDelegate +import com.jeremyliao.liveeventbus.LiveEventBus +import io.legado.app.constant.AppConst.channelIdDownload +import io.legado.app.constant.AppConst.channelIdReadAloud +import io.legado.app.constant.AppConst.channelIdWeb import io.legado.app.data.AppDatabase +import io.legado.app.help.ActivityHelp +import io.legado.app.help.CrashHandler +import io.legado.app.help.ReadBookConfig +import io.legado.app.lib.theme.ThemeStore +import io.legado.app.ui.widget.page.ChapterProvider +import io.legado.app.utils.getCompatColor +import io.legado.app.utils.getPrefInt +import io.legado.app.utils.isNightTheme +@Suppress("DEPRECATION") class App : Application() { companion object { @@ -15,9 +36,148 @@ class App : Application() { private set } + var versionCode = 0 + var versionName = "" + override fun onCreate() { super.onCreate() INSTANCE = this + CrashHandler().init(this) db = AppDatabase.createDatabase(INSTANCE) + packageManager.getPackageInfo(packageName, 0)?.let { + versionCode = it.versionCode + versionName = it.versionName + } + + if (!ThemeStore.isConfigured(this, versionCode)) applyTheme() + initNightTheme() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) createChannelId() + + LiveEventBus.get() + .config() + .supportBroadcast(this) + .lifecycleObserverAlwaysActive(true) + .autoClear(false) + + registerActivityLife() + } + + /** + * 更新主题 + */ + fun applyTheme() { + if (isNightTheme) { + ThemeStore.editTheme(this) + .primaryColor( + getPrefInt("colorPrimaryNight", getCompatColor(R.color.md_blue_grey_600)) + ) + .accentColor( + getPrefInt("colorAccentNight", getCompatColor(R.color.md_deep_orange_800)) + ) + .backgroundColor( + getPrefInt("colorBackgroundNight", getCompatColor(R.color.md_grey_800)) + ) + .apply() + } else { + ThemeStore.editTheme(this) + .primaryColor(getPrefInt("colorPrimary", getCompatColor(R.color.md_light_blue_500))) + .accentColor(getPrefInt("colorAccent", getCompatColor(R.color.md_pink_800))) + .backgroundColor(getPrefInt("colorBackground", getCompatColor(R.color.md_grey_100))) + .apply() + } + ChapterProvider.upReadAloudSpan() + } + + fun applyDayNight() { + ReadBookConfig.upBg() + applyTheme() + initNightTheme() + } + + + private fun initNightTheme() { + val targetMode = if (isNightTheme) { + AppCompatDelegate.MODE_NIGHT_YES + } else { + AppCompatDelegate.MODE_NIGHT_NO + } + AppCompatDelegate.setDefaultNightMode(targetMode) + } + + + /** + * 创建通知ID + */ + @RequiresApi(Build.VERSION_CODES.O) + private fun createChannelId() { + val notificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager + notificationManager?.let { + //用唯一的ID创建渠道对象 + val downloadChannel = NotificationChannel( + channelIdDownload, + getString(R.string.download_offline), + NotificationManager.IMPORTANCE_LOW + ) + //初始化channel + downloadChannel.enableLights(false) + downloadChannel.enableVibration(false) + downloadChannel.setSound(null, null) + + //用唯一的ID创建渠道对象 + val readAloudChannel = NotificationChannel( + channelIdReadAloud, + getString(R.string.read_aloud), + NotificationManager.IMPORTANCE_LOW + ) + //初始化channel + readAloudChannel.enableLights(false) + readAloudChannel.enableVibration(false) + readAloudChannel.setSound(null, null) + + //用唯一的ID创建渠道对象 + val webChannel = NotificationChannel( + channelIdWeb, + getString(R.string.web_service), + NotificationManager.IMPORTANCE_LOW + ) + //初始化channel + webChannel.enableLights(false) + webChannel.enableVibration(false) + webChannel.setSound(null, null) + + //向notification manager 提交channel + it.createNotificationChannels(listOf(downloadChannel, readAloudChannel, webChannel)) + } + } + + private fun registerActivityLife() { + registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { + override fun onActivityPaused(activity: Activity) { + } + + override fun onActivityResumed(activity: Activity) { + } + + override fun onActivityStarted(activity: Activity) { + + } + + override fun onActivityDestroyed(activity: Activity) { + ActivityHelp.remove(activity) + } + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle?) { + } + + override fun onActivityStopped(activity: Activity) { + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + ActivityHelp.add(activity) + } + + }) } } diff --git a/app/src/main/java/io/legado/app/base/BaseActivity.kt b/app/src/main/java/io/legado/app/base/BaseActivity.kt index f2f2aab07..c02f4731a 100644 --- a/app/src/main/java/io/legado/app/base/BaseActivity.kt +++ b/app/src/main/java/io/legado/app/base/BaseActivity.kt @@ -1,58 +1,98 @@ package io.legado.app.base import android.os.Bundle +import android.view.Menu import android.view.MenuItem +import android.view.View +import android.view.WindowManager import androidx.appcompat.app.AppCompatActivity -import androidx.databinding.DataBindingUtil -import androidx.databinding.ViewDataBinding -import androidx.lifecycle.ViewModel +import io.legado.app.R +import io.legado.app.lib.theme.ATH +import io.legado.app.lib.theme.ColorUtils +import io.legado.app.lib.theme.primaryColor +import io.legado.app.utils.applyTint +import io.legado.app.utils.disableAutoFill +import io.legado.app.utils.hideSoftInput +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel -abstract class BaseActivity : AppCompatActivity() { - protected lateinit var dataBinding: BD - private set - - protected abstract val viewModel: VM - - protected abstract val layoutID: Int +abstract class BaseActivity(private val layoutID: Int, private val fullScreen: Boolean = true) : + AppCompatActivity(), + CoroutineScope by MainScope() { override fun onCreate(savedInstanceState: Bundle?) { + window.decorView.disableAutoFill() + initTheme() + setupSystemBar() super.onCreate(savedInstanceState) - dataBinding = DataBindingUtil.setContentView(this, layoutID) - onViewModelCreated(viewModel, savedInstanceState) + setContentView(layoutID) + onActivityCreated(savedInstanceState) + observeLiveBus() } - open fun onViewModelCreated(viewModel: VM, savedInstanceState: Bundle?){ + override fun onDestroy() { + super.onDestroy() + cancel() + } + abstract fun onActivityCreated(savedInstanceState: Bundle?) + + final override fun onCreateOptionsMenu(menu: Menu?): Boolean { + return menu?.let { + val bool = onCompatCreateOptionsMenu(it) + it.applyTint(this) + bool + } ?: super.onCreateOptionsMenu(menu) } - override fun onOptionsItemSelected(item: MenuItem?): Boolean { + + open fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + return super.onCreateOptionsMenu(menu) + } + + final override fun onOptionsItemSelected(item: MenuItem?): Boolean { item?.let { if (it.itemId == android.R.id.home) { supportFinishAfterTransition() return true } } - return if (item == null) false else onCompatOptionsItemSelected(item) + return item != null && onCompatOptionsItemSelected(item) } open fun onCompatOptionsItemSelected(item: MenuItem): Boolean { - return true + return super.onOptionsItemSelected(item) } - override fun setTitle(title: CharSequence?) { - supportActionBar?.title = title + private fun initTheme() { + ATH.applyBackgroundTint(window.decorView) + if (ColorUtils.isColorLight(primaryColor)) { + setTheme(R.style.AppTheme_Light) + } else { + setTheme(R.style.AppTheme_Dark) + } } - override fun setTitle(titleId: Int) { - supportActionBar?.setTitle(titleId) + private fun setupSystemBar() { + if (fullScreen) { + window.clearFlags( + WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS + or WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION + ) + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + window.decorView.systemUiVisibility = + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_STABLE + } + ATH.setStatusBarColorAuto(this, fullScreen) } - fun setSubTitle(subtitle: CharSequence?) { - supportActionBar?.subtitle = subtitle + open fun observeLiveBus() { } - fun setSubTitle(subtitleId: Int) { - supportActionBar?.setSubtitle(subtitleId) + override fun finish() { + currentFocus?.hideSoftInput() + super.finish() } } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/base/BaseFragment.kt b/app/src/main/java/io/legado/app/base/BaseFragment.kt new file mode 100644 index 000000000..1f3ff2dcb --- /dev/null +++ b/app/src/main/java/io/legado/app/base/BaseFragment.kt @@ -0,0 +1,51 @@ +package io.legado.app.base + +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.appcompat.view.SupportMenuInflater +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.Fragment +import io.legado.app.utils.applyTint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel + +abstract class BaseFragment(layoutID: Int) : Fragment(layoutID), + CoroutineScope by MainScope() { + + var supportToolbar: Toolbar? = null + private set + + val menuInflater: MenuInflater + get() = SupportMenuInflater(requireContext()) + + + override fun onDestroy() { + super.onDestroy() + cancel() + } + + fun setSupportToolbar(toolbar: Toolbar) { + supportToolbar = toolbar + supportToolbar?.let { + it.menu.apply { + onCompatCreateOptionsMenu(this) + applyTint(requireContext()) + } + + it.setOnMenuItemClickListener { item -> + onCompatOptionsItemSelected(item) + true + } + } + } + + + open fun onCompatCreateOptionsMenu(menu: Menu) { + } + + open fun onCompatOptionsItemSelected(item: MenuItem) { + } + +} diff --git a/app/src/main/java/io/legado/app/base/BaseService.kt b/app/src/main/java/io/legado/app/base/BaseService.kt new file mode 100644 index 000000000..c67acb95c --- /dev/null +++ b/app/src/main/java/io/legado/app/base/BaseService.kt @@ -0,0 +1,21 @@ +package io.legado.app.base + +import android.app.Service +import android.content.Intent +import android.os.IBinder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel + +abstract class BaseService : Service(), CoroutineScope by MainScope() { + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + + override fun onDestroy() { + super.onDestroy() + cancel() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/base/BaseViewModel.kt b/app/src/main/java/io/legado/app/base/BaseViewModel.kt index 74bc4bfc2..3610a00a5 100644 --- a/app/src/main/java/io/legado/app/base/BaseViewModel.kt +++ b/app/src/main/java/io/legado/app/base/BaseViewModel.kt @@ -1,52 +1,65 @@ package io.legado.app.base import android.app.Application +import android.content.Context +import androidx.annotation.CallSuper import androidx.lifecycle.AndroidViewModel +import io.legado.app.App +import io.legado.app.help.coroutine.Coroutine import kotlinx.coroutines.* +import org.jetbrains.anko.AnkoLogger +import org.jetbrains.anko.toast import kotlin.coroutines.CoroutineContext -open class BaseViewModel(application: Application) : AndroidViewModel(application), CoroutineScope { - override val coroutineContext: CoroutineContext - get() = Dispatchers.Main +open class BaseViewModel(application: Application) : AndroidViewModel(application), + CoroutineScope by MainScope(), + AnkoLogger { + val context: Context by lazy { this.getApplication() } - private val launchManager: MutableList = mutableListOf() + fun execute( + scope: CoroutineScope = this, + context: CoroutineContext = Dispatchers.IO, + block: suspend CoroutineScope.() -> T + ): Coroutine { + return Coroutine.async(scope, context) { block() } + } + + fun submit( + scope: CoroutineScope = this, + context: CoroutineContext = Dispatchers.IO, + block: suspend CoroutineScope.() -> Deferred + ): Coroutine { + return Coroutine.async(scope, context) { block().await() } + } - protected fun launchOnUI( - tryBlock: suspend CoroutineScope.() -> Unit,//成功 - errorBlock: suspend CoroutineScope.(Throwable) -> Unit,//失败 - finallyBlock: suspend CoroutineScope.() -> Unit//结束 - ) { - launchOnUI { - tryCatch(tryBlock, errorBlock, finallyBlock) + @CallSuper + override fun onCleared() { + super.onCleared() + cancel() + } + + open fun toast(message: Int) { + launch { + context.toast(message) } } - /** - * add launch task to [launchManager] - */ - private fun launchOnUI(block: suspend CoroutineScope.() -> Unit) { - val job = launch { block() }//主线程 - launchManager.add(job) - job.invokeOnCompletion { launchManager.remove(job) } + open fun toast(message: CharSequence) { + launch { + context.toast(message) + } } - private suspend fun tryCatch( - tryBlock: suspend CoroutineScope.() -> Unit, - errorBlock: suspend CoroutineScope.(Throwable) -> Unit, - finallyBlock: suspend CoroutineScope.() -> Unit - ) { - try { - coroutineScope { tryBlock() } - } catch (e: Throwable) { - coroutineScope { errorBlock(e) } - } finally { - coroutineScope { finallyBlock() } + open fun longToast(message: Int) { + launch { + context.toast(message) } } - override fun onCleared() { - super.onCleared() - launchManager.clear() + open fun longToast(message: CharSequence) { + launch { + context.toast(message) + } } } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/base/VMBaseActivity.kt b/app/src/main/java/io/legado/app/base/VMBaseActivity.kt new file mode 100644 index 000000000..8267a2c8b --- /dev/null +++ b/app/src/main/java/io/legado/app/base/VMBaseActivity.kt @@ -0,0 +1,10 @@ +package io.legado.app.base + +import androidx.lifecycle.ViewModel + +abstract class VMBaseActivity(layoutID: Int, fullScreen: Boolean = true) : + BaseActivity(layoutID, fullScreen) { + + protected abstract val viewModel: VM + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/base/VMBaseFragment.kt b/app/src/main/java/io/legado/app/base/VMBaseFragment.kt new file mode 100644 index 000000000..cc78d19dd --- /dev/null +++ b/app/src/main/java/io/legado/app/base/VMBaseFragment.kt @@ -0,0 +1,9 @@ +package io.legado.app.base + +import androidx.lifecycle.ViewModel + +abstract class VMBaseFragment(layoutID: Int) : BaseFragment(layoutID) { + + protected abstract val viewModel: VM + +} diff --git a/app/src/main/java/io/legado/app/base/adapter/CommonRecyclerAdapter.kt b/app/src/main/java/io/legado/app/base/adapter/CommonRecyclerAdapter.kt index ce7b3d4a6..a87416469 100644 --- a/app/src/main/java/io/legado/app/base/adapter/CommonRecyclerAdapter.kt +++ b/app/src/main/java/io/legado/app/base/adapter/CommonRecyclerAdapter.kt @@ -53,11 +53,11 @@ abstract class CommonRecyclerAdapter(protected val context: Context) : Rec } fun > addItemViewDelegate(viewType: Int, delegate: DELEGATE) { - itemDelegates.put(viewType, delegate) + itemDelegates[viewType] = delegate } fun > addItemViewDelegate(delegate: DELEGATE) { - itemDelegates.put(itemDelegates.size, delegate) + itemDelegates[itemDelegates.size] = delegate } fun > addItemViewDelegates(vararg delegates: DELEGATE) { @@ -122,7 +122,7 @@ abstract class CommonRecyclerAdapter(protected val context: Context) : Rec } } - fun setItems(items: List?) { + fun setItems(items: List?, notify: Boolean = true) { synchronized(lock) { if (this.items.isNotEmpty()) { this.items.clear() @@ -130,7 +130,9 @@ abstract class CommonRecyclerAdapter(protected val context: Context) : Rec if (items != null) { this.items.addAll(items) } - notifyDataSetChanged() + if (notify) { + notifyDataSetChanged() + } } } @@ -148,26 +150,15 @@ abstract class CommonRecyclerAdapter(protected val context: Context) : Rec synchronized(lock) { val oldSize = getActualItemCount() if (this.items.add(item)) { - if (oldSize == 0) { - notifyDataSetChanged() - } else { - notifyItemInserted(oldSize + getHeaderCount()) - } + notifyItemInserted(oldSize + getHeaderCount()) } } } fun addItems(position: Int, newItems: List) { synchronized(lock) { - val oldSize = getActualItemCount() - if (position in 0 until oldSize) { - if (if (oldSize == 0) this.items.addAll(newItems) else this.items.addAll(position, newItems)) { - if (oldSize == 0) { - notifyDataSetChanged() - } else { - notifyItemRangeChanged(position + getHeaderCount(), newItems.size) - } - } + if (this.items.addAll(position, newItems)) { + notifyItemRangeInserted(position + getHeaderCount(), newItems.size) } } } @@ -176,33 +167,32 @@ abstract class CommonRecyclerAdapter(protected val context: Context) : Rec synchronized(lock) { val oldSize = getActualItemCount() if (this.items.addAll(newItems)) { - if (oldSize == 0) { - notifyDataSetChanged() - } else { - notifyItemRangeChanged(oldSize + getHeaderCount(), newItems.size) - } + notifyItemRangeInserted(oldSize + getHeaderCount(), newItems.size) } } } fun removeItem(position: Int) { synchronized(lock) { - if (this.items.removeAt(position) != null) + if (this.items.removeAt(position) != null) { notifyItemRemoved(position + getHeaderCount()) + } } } fun removeItem(item: ITEM) { synchronized(lock) { - if (this.items.remove(item)) + if (this.items.remove(item)) { notifyItemRemoved(this.items.indexOf(item) + getHeaderCount()) + } } } fun removeItems(items: List) { synchronized(lock) { - if (this.items.removeAll(items)) + if (this.items.removeAll(items)) { notifyDataSetChanged() + } } } @@ -210,8 +200,21 @@ abstract class CommonRecyclerAdapter(protected val context: Context) : Rec synchronized(lock) { val size = getActualItemCount() if (oldPosition in 0 until size && newPosition in 0 until size) { - Collections.swap(this.items, oldPosition + getHeaderCount(), newPosition + getHeaderCount()) - notifyDataSetChanged() + val srcPosition = oldPosition + getHeaderCount() + val targetPosition = newPosition + getHeaderCount() + Collections.swap(this.items, srcPosition, targetPosition) + notifyItemChanged(srcPosition) + notifyItemChanged(targetPosition) + } + } + } + + fun updateItem(item: ITEM) { + synchronized(lock) { + val index = this.items.indexOf(item) + if (index >= 0) { + this.items[index] = item + notifyItemChanged(index) } } } @@ -234,6 +237,13 @@ abstract class CommonRecyclerAdapter(protected val context: Context) : Rec } } + fun clearItems() { + synchronized(lock) { + this.items.clear() + notifyDataSetChanged() + } + } + fun isEmpty(): Boolean { return items.isEmpty() } @@ -295,7 +305,7 @@ abstract class CommonRecyclerAdapter(protected val context: Context) : Rec } else -> { - val holder = ItemViewHolder(inflater.inflate(itemDelegates.getValue(viewType).layoutID, parent, false)) + val holder = ItemViewHolder(inflater.inflate(itemDelegates.getValue(viewType).layoutId, parent, false)) if (itemClickListener != null) { holder.itemView.setOnClickListener { @@ -324,7 +334,7 @@ abstract class CommonRecyclerAdapter(protected val context: Context) : Rec final override fun onBindViewHolder(holder: ItemViewHolder, position: Int, payloads: MutableList) { if (!isHeader(holder.layoutPosition) && !isFooter(holder.layoutPosition)) { - getItem(holder.layoutPosition)?.let { + getItem(holder.layoutPosition - getHeaderCount())?.let { itemDelegates.getValue(getItemViewType(holder.layoutPosition)) .convert(holder, it, payloads) } diff --git a/app/src/main/java/io/legado/app/base/adapter/InfiniteScrollListener.kt b/app/src/main/java/io/legado/app/base/adapter/InfiniteScrollListener.kt index fc7c48bab..a679f3e08 100644 --- a/app/src/main/java/io/legado/app/base/adapter/InfiniteScrollListener.kt +++ b/app/src/main/java/io/legado/app/base/adapter/InfiniteScrollListener.kt @@ -14,7 +14,7 @@ abstract class InfiniteScrollListener() : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { // if (dy < 0 || dataLoading.isDataLoading()) return - val layoutManager:LinearLayoutManager = recyclerView.layoutManager as LinearLayoutManager + val layoutManager: LinearLayoutManager = recyclerView.layoutManager as LinearLayoutManager val visibleItemCount = recyclerView.childCount val totalItemCount = layoutManager.itemCount val firstVisibleItem = layoutManager.findFirstVisibleItemPosition() diff --git a/app/src/main/java/io/legado/app/base/adapter/ItemAnimation.kt b/app/src/main/java/io/legado/app/base/adapter/ItemAnimation.kt index 6db4a7415..cf2f40305 100644 --- a/app/src/main/java/io/legado/app/base/adapter/ItemAnimation.kt +++ b/app/src/main/java/io/legado/app/base/adapter/ItemAnimation.kt @@ -2,12 +2,7 @@ package io.legado.app.base.adapter import android.view.animation.Interpolator import android.view.animation.LinearInterpolator -import io.legado.app.base.adapter.animations.AlphaInAnimation -import io.legado.app.base.adapter.animations.BaseAnimation -import io.legado.app.base.adapter.animations.ScaleInAnimation -import io.legado.app.base.adapter.animations.SlideInBottomAnimation -import io.legado.app.base.adapter.animations.SlideInLeftAnimation -import io.legado.app.base.adapter.animations.SlideInRightAnimation +import io.legado.app.base.adapter.animations.* /** * Created by Invincible on 2017/12/15. diff --git a/app/src/main/java/io/legado/app/base/adapter/ItemViewDelegate.kt b/app/src/main/java/io/legado/app/base/adapter/ItemViewDelegate.kt index 789ca250e..2ac1090f2 100644 --- a/app/src/main/java/io/legado/app/base/adapter/ItemViewDelegate.kt +++ b/app/src/main/java/io/legado/app/base/adapter/ItemViewDelegate.kt @@ -7,9 +7,7 @@ import android.content.Context * * item代理, */ -abstract class ItemViewDelegate(protected val context: Context) { - - abstract val layoutID: Int +abstract class ItemViewDelegate(protected val context: Context, val layoutId: Int) { abstract fun convert(holder: ItemViewHolder, item: ITEM, payloads: MutableList) diff --git a/app/src/main/java/io/legado/app/base/adapter/SimpleRecyclerAdapter.kt b/app/src/main/java/io/legado/app/base/adapter/SimpleRecyclerAdapter.kt index 176098f50..c65cfabf4 100644 --- a/app/src/main/java/io/legado/app/base/adapter/SimpleRecyclerAdapter.kt +++ b/app/src/main/java/io/legado/app/base/adapter/SimpleRecyclerAdapter.kt @@ -5,22 +5,18 @@ import android.content.Context /** * Created by Invincible on 2017/12/15. */ -abstract class SimpleRecyclerAdapter(context: Context) : CommonRecyclerAdapter(context) { +abstract class SimpleRecyclerAdapter(context: Context, private val layoutId: Int) : + CommonRecyclerAdapter(context) { init { - addItemViewDelegate(object : ItemViewDelegate(context) { - override val layoutID: Int - get() = this@SimpleRecyclerAdapter.layoutID + addItemViewDelegate(object : ItemViewDelegate(context, layoutId) { override fun convert(holder: ItemViewHolder, item: ITEM, payloads: MutableList) { this@SimpleRecyclerAdapter.convert(holder, item, payloads) } }) - } - abstract val layoutID: Int - abstract fun convert(holder: ItemViewHolder, item: ITEM, payloads: MutableList) } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/base/adapter/animations/AlphaInAnimation.kt b/app/src/main/java/io/legado/app/base/adapter/animations/AlphaInAnimation.kt index b307300a3..e3a5b523a 100644 --- a/app/src/main/java/io/legado/app/base/adapter/animations/AlphaInAnimation.kt +++ b/app/src/main/java/io/legado/app/base/adapter/animations/AlphaInAnimation.kt @@ -8,7 +8,7 @@ import android.view.View class AlphaInAnimation @JvmOverloads constructor(private val mFrom: Float = DEFAULT_ALPHA_FROM) : BaseAnimation { override fun getAnimators(view: View): Array = - arrayOf(ObjectAnimator.ofFloat(view, "alpha", mFrom, 1f)) + arrayOf(ObjectAnimator.ofFloat(view, "alpha", mFrom, 1f)) companion object { diff --git a/app/src/main/java/io/legado/app/base/adapter/animations/SlideInBottomAnimation.kt b/app/src/main/java/io/legado/app/base/adapter/animations/SlideInBottomAnimation.kt index 0fd2bfde6..941562201 100644 --- a/app/src/main/java/io/legado/app/base/adapter/animations/SlideInBottomAnimation.kt +++ b/app/src/main/java/io/legado/app/base/adapter/animations/SlideInBottomAnimation.kt @@ -3,11 +3,10 @@ package io.legado.app.base.adapter.animations import android.animation.Animator import android.animation.ObjectAnimator import android.view.View -import io.legado.app.base.adapter.animations.BaseAnimation class SlideInBottomAnimation : BaseAnimation { override fun getAnimators(view: View): Array = - arrayOf(ObjectAnimator.ofFloat(view, "translationY", view.measuredHeight.toFloat(), 0f)) + arrayOf(ObjectAnimator.ofFloat(view, "translationY", view.measuredHeight.toFloat(), 0f)) } diff --git a/app/src/main/java/io/legado/app/base/adapter/animations/SlideInLeftAnimation.kt b/app/src/main/java/io/legado/app/base/adapter/animations/SlideInLeftAnimation.kt index daa6f17a1..8cfae170b 100644 --- a/app/src/main/java/io/legado/app/base/adapter/animations/SlideInLeftAnimation.kt +++ b/app/src/main/java/io/legado/app/base/adapter/animations/SlideInLeftAnimation.kt @@ -3,12 +3,11 @@ package io.legado.app.base.adapter.animations import android.animation.Animator import android.animation.ObjectAnimator import android.view.View -import io.legado.app.base.adapter.animations.BaseAnimation class SlideInLeftAnimation : BaseAnimation { override fun getAnimators(view: View): Array = - arrayOf(ObjectAnimator.ofFloat(view, "translationX", -view.rootView.width.toFloat(), 0f)) + arrayOf(ObjectAnimator.ofFloat(view, "translationX", -view.rootView.width.toFloat(), 0f)) } diff --git a/app/src/main/java/io/legado/app/base/adapter/animations/SlideInRightAnimation.kt b/app/src/main/java/io/legado/app/base/adapter/animations/SlideInRightAnimation.kt index 71c8feac7..e7f1c85e5 100644 --- a/app/src/main/java/io/legado/app/base/adapter/animations/SlideInRightAnimation.kt +++ b/app/src/main/java/io/legado/app/base/adapter/animations/SlideInRightAnimation.kt @@ -3,12 +3,11 @@ package io.legado.app.base.adapter.animations import android.animation.Animator import android.animation.ObjectAnimator import android.view.View -import io.legado.app.base.adapter.animations.BaseAnimation class SlideInRightAnimation : BaseAnimation { override fun getAnimators(view: View): Array = - arrayOf(ObjectAnimator.ofFloat(view, "translationX", view.rootView.width.toFloat(), 0f)) + arrayOf(ObjectAnimator.ofFloat(view, "translationX", view.rootView.width.toFloat(), 0f)) } diff --git a/app/src/main/java/io/legado/app/constant/Action.kt b/app/src/main/java/io/legado/app/constant/Action.kt new file mode 100644 index 000000000..edccff54c --- /dev/null +++ b/app/src/main/java/io/legado/app/constant/Action.kt @@ -0,0 +1,14 @@ +package io.legado.app.constant + +object Action { + + const val play = "play" + const val stop = "stop" + const val resume = "resume" + const val pause = "pause" + const val addTimer = "addTimer" + const val setTimer = "setTimer" + const val prevParagraph = "prevParagraph" + const val nextParagraph = "nextParagraph" + const val upTtsSpeechRate = "upTtsSpeechRate" +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/constant/AppConst.kt b/app/src/main/java/io/legado/app/constant/AppConst.kt index 89a1d204f..991da0859 100644 --- a/app/src/main/java/io/legado/app/constant/AppConst.kt +++ b/app/src/main/java/io/legado/app/constant/AppConst.kt @@ -1,11 +1,56 @@ package io.legado.app.constant +import android.annotation.SuppressLint +import io.legado.app.App +import io.legado.app.R +import io.legado.app.data.entities.BookGroup +import io.legado.app.utils.getPrefBoolean +import io.legado.app.utils.putPrefBoolean +import java.text.SimpleDateFormat +import javax.script.ScriptEngine +import javax.script.ScriptEngineManager + +@SuppressLint("SimpleDateFormat") object AppConst { + + const val APP_TAG = "Legado" + const val channelIdDownload = "channel_download" const val channelIdReadAloud = "channel_read_aloud" const val channelIdWeb = "channel_web" - const val APP_TAG = "Legado" - const val RC_IMPORT_YUEDU_DATA = 100 + val userAgent: String by lazy { + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36" + } + + val SCRIPT_ENGINE: ScriptEngine by lazy { + ScriptEngineManager().getEngineByName("rhino") + } + + val TIME_FORMAT: SimpleDateFormat by lazy { + SimpleDateFormat("HH:mm") + } + + val keyboardToolChars: List by lazy { + arrayListOf( + "@", "&", "|", "%", "/", ":", "[", "]", "{", "}", "<", ">", "\\", "$", "#", "!", ".", + "href", "src", "textNodes", "xpath", "json", "css", "id", "class", "tag" + ) + } + + val bookGroupAll = BookGroup(-1, App.INSTANCE.getString(R.string.all)) + val bookGroupLocal = BookGroup(-2, App.INSTANCE.getString(R.string.local)) + val bookGroupAudio = BookGroup(-3, App.INSTANCE.getString(R.string.audio)) + + var bookGroupLocalShow: Boolean + get() = App.INSTANCE.getPrefBoolean("bookGroupLocal", false) + set(value) { + App.INSTANCE.putPrefBoolean("bookGroupLocal", value) + } + var bookGroupAudioShow: Boolean + get() = App.INSTANCE.getPrefBoolean("bookGroupAudio", false) + set(value) { + App.INSTANCE.putPrefBoolean("bookGroupAudio", value) + } } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/constant/BookType.kt b/app/src/main/java/io/legado/app/constant/BookType.kt new file mode 100644 index 000000000..686e0623b --- /dev/null +++ b/app/src/main/java/io/legado/app/constant/BookType.kt @@ -0,0 +1,7 @@ +package io.legado.app.constant + +object BookType { + const val default = 0 + const val audio = 1 + const val local = "loc_book" +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/constant/Bus.kt b/app/src/main/java/io/legado/app/constant/Bus.kt new file mode 100644 index 000000000..d81f8f224 --- /dev/null +++ b/app/src/main/java/io/legado/app/constant/Bus.kt @@ -0,0 +1,16 @@ +package io.legado.app.constant + +object Bus { + const val RECREATE = "RECREATE" + const val UP_BOOK = "sourceDebugLog" + const val ALOUD_STATE = "aloud_state" + const val TTS_START = "ttsStart" + const val TTS_TURN_PAGE = "ttsTurnPage" + const val TTS_DS = "ttsDs" + const val BATTERY_CHANGED = "batteryChanged" + const val TIME_CHANGED = "timeChanged" + const val READ_ALOUD_BUTTON = "readAloudButton" + const val UP_CONFIG = "upConfig" + const val OPEN_CHAPTER = "openChapter" + const val REPLACE = "replace" +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/constant/Pattern.kt b/app/src/main/java/io/legado/app/constant/Pattern.kt new file mode 100644 index 000000000..fd623bf69 --- /dev/null +++ b/app/src/main/java/io/legado/app/constant/Pattern.kt @@ -0,0 +1,8 @@ +package io.legado.app.constant + +import java.util.regex.Pattern + +object Pattern { + val JS_PATTERN: Pattern = Pattern.compile("([\\w\\W]*?|@js:[\\w\\W]*$)", Pattern.CASE_INSENSITIVE) + val EXP_PATTERN: Pattern = Pattern.compile("\\{\\{([\\w\\W]*?)\\}\\}") +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/constant/RSSKeywords.kt b/app/src/main/java/io/legado/app/constant/RSSKeywords.kt new file mode 100644 index 000000000..bb8d4207a --- /dev/null +++ b/app/src/main/java/io/legado/app/constant/RSSKeywords.kt @@ -0,0 +1,19 @@ +package io.legado.app.constant + +object RSSKeywords { + + const val RSS_ITEM = "item" + const val RSS_ITEM_TITLE = "title" + const val RSS_ITEM_LINK = "link" + const val RSS_ITEM_AUTHOR = "author" + const val RSS_ITEM_CATEGORY = "category" + const val RSS_ITEM_THUMBNAIL = "media:thumbnail" + const val RSS_ITEM_ENCLOSURE = "enclosure" + const val RSS_ITEM_DESCRIPTION = "description" + const val RSS_ITEM_CONTENT = "content:encoded" + const val RSS_ITEM_PUB_DATE = "pubDate" + const val RSS_ITEM_TIME = "time" + const val RSS_ITEM_URL = "url" + const val RSS_ITEM_TYPE = "type" + const val RSS_ITEM_GUID = "guid" +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/constant/Status.kt b/app/src/main/java/io/legado/app/constant/Status.kt new file mode 100644 index 000000000..678c62221 --- /dev/null +++ b/app/src/main/java/io/legado/app/constant/Status.kt @@ -0,0 +1,7 @@ +package io.legado.app.constant + +object Status { + const val STOP = 0 + const val PLAY = 1 + const val PAUSE = 3 +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/AppDatabase.kt b/app/src/main/java/io/legado/app/data/AppDatabase.kt index f5db6c908..be65f7730 100644 --- a/app/src/main/java/io/legado/app/data/AppDatabase.kt +++ b/app/src/main/java/io/legado/app/data/AppDatabase.kt @@ -6,23 +6,22 @@ import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase -import io.legado.app.data.dao.BookDao -import io.legado.app.data.dao.ReplaceRuleDao -import io.legado.app.data.entities.Book -import io.legado.app.data.entities.Chapter -import io.legado.app.data.entities.ReplaceRule -import javax.xml.transform.Source +import io.legado.app.data.dao.* +import io.legado.app.data.entities.* -@Database(entities = [Book::class, Chapter::class, ReplaceRule::class], version = 1, exportSchema = true) -// @TypeConverters(Converters::class) +@Database( + entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class, ReplaceRule::class, + SearchBook::class, SearchKeyword::class, SourceCookie::class, RssSource::class, Bookmark::class, + RssArticle::class], + version = 1, + exportSchema = true +) abstract class AppDatabase : RoomDatabase() { companion object { private const val DATABASE_NAME = "legado.db" - - private val MIGRATION_1_2: Migration = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) { database.run { @@ -35,23 +34,30 @@ abstract class AppDatabase : RoomDatabase() { } fun createDatabase(context: Context): AppDatabase { - return Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, DATABASE_NAME) + return Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + DATABASE_NAME + ) // .addMigrations(MIGRATION_1_2) // .addMigrations(MIGRATION_2_3) // .addMigrations(MIGRATION_3_4) // .addMigrations(MIGRATION_4_5) // .addMigrations(MIGRATION_5_6) - .addCallback(object : Callback() { - override fun onCreate(db: SupportSQLiteDatabase) { - super.onCreate(db) - } - }) .build() } } abstract fun bookDao(): BookDao + abstract fun bookGroupDao(): BookGroupDao + abstract fun bookSourceDao(): BookSourceDao + abstract fun bookChapterDao(): BookChapterDao abstract fun replaceRuleDao(): ReplaceRuleDao - + abstract fun searchBookDao(): SearchBookDao + abstract fun searchKeywordDao(): SearchKeywordDao + abstract fun sourceCookieDao(): SourceCookieDao + abstract fun rssSourceDao(): RssSourceDao + abstract fun bookmarkDao(): BookmarkDao + abstract fun rssArticleDao(): RssArticleDao } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/api/CommonHttpApi.kt b/app/src/main/java/io/legado/app/data/api/CommonHttpApi.kt deleted file mode 100644 index 08e06f52f..000000000 --- a/app/src/main/java/io/legado/app/data/api/CommonHttpApi.kt +++ /dev/null @@ -1,14 +0,0 @@ -package io.legado.app.data.api - -import kotlinx.coroutines.Deferred -import retrofit2.http.* - -interface CommonHttpApi { - - @GET - fun get(@Url url: String, @QueryMap map: Map): Deferred - - @FormUrlEncoded - @POST - fun post(@Url url: String, @FieldMap map: Map): Deferred -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/api/IHttpGetApi.kt b/app/src/main/java/io/legado/app/data/api/IHttpGetApi.kt new file mode 100644 index 000000000..e4180fa59 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/api/IHttpGetApi.kt @@ -0,0 +1,42 @@ +package io.legado.app.data.api + +import kotlinx.coroutines.Deferred +import retrofit2.Call +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.HeaderMap +import retrofit2.http.QueryMap +import retrofit2.http.Url + +/** + * Created by GKF on 2018/1/21. + * get web content + */ + +interface IHttpGetApi { + @GET + fun getAsync( + @Url url: String, + @HeaderMap headers: Map + ): Deferred> + + @GET + fun getMapAsync( + @Url url: String, + @QueryMap(encoded = true) queryMap: Map, + @HeaderMap headers: Map + ): Deferred> + + @GET + fun get( + @Url url: String, + @HeaderMap headers: Map + ): Call + + @GET + fun getMap( + @Url url: String, + @QueryMap(encoded = true) queryMap: Map, + @HeaderMap headers: Map + ): Call +} diff --git a/app/src/main/java/io/legado/app/data/api/IHttpPostApi.kt b/app/src/main/java/io/legado/app/data/api/IHttpPostApi.kt new file mode 100644 index 000000000..92b5f1cf3 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/api/IHttpPostApi.kt @@ -0,0 +1,53 @@ +package io.legado.app.data.api + +import kotlinx.coroutines.Deferred +import okhttp3.RequestBody +import retrofit2.Call +import retrofit2.Response +import retrofit2.http.* + +/** + * Created by GKF on 2018/1/29. + * post + */ + +interface IHttpPostApi { + + @FormUrlEncoded + @POST + fun postMapAsync( + @Url url: String, + @FieldMap(encoded = true) fieldMap: Map, + @HeaderMap headers: Map + ): Deferred> + + @POST + fun postBodyAsync( + @Url url: String, + @Body body: RequestBody, + @HeaderMap headers: Map + ): Deferred> + + @FormUrlEncoded + @POST + fun postMap( + @Url url: String, + @FieldMap(encoded = true) fieldMap: Map, + @HeaderMap headers: Map + ): Call + + @POST + fun postBody( + @Url url: String, + @Body body: RequestBody, + @HeaderMap headers: Map + ): Call + + @FormUrlEncoded + @POST + fun postMapByte( + @Url url: String, + @FieldMap(encoded = true) fieldMap: Map, + @HeaderMap headers: Map + ): Call +} diff --git a/app/src/main/java/io/legado/app/data/dao/BookChapterDao.kt b/app/src/main/java/io/legado/app/data/dao/BookChapterDao.kt new file mode 100644 index 000000000..9a4db0cb1 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/BookChapterDao.kt @@ -0,0 +1,31 @@ +package io.legado.app.data.dao + +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.legado.app.data.entities.BookChapter + +@Dao +interface BookChapterDao { + + @Query("select * from chapters where bookUrl = :bookUrl") + fun observeByBook(bookUrl: String): LiveData> + + @Query("select * from chapters where bookUrl = :bookUrl") + fun getChapterList(bookUrl: String): List + + @Query("select * from chapters where bookUrl = :bookUrl and `index` = :index") + fun getChapter(bookUrl: String, index: Int): BookChapter? + + @Query("select count(url) from chapters where bookUrl = :bookUrl") + fun getChapterCount(bookUrl: String): Int + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg bookChapter: BookChapter) + + @Query("delete from chapters where bookUrl = :bookUrl") + fun delByBook(bookUrl: String) + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/BookDao.kt b/app/src/main/java/io/legado/app/data/dao/BookDao.kt index 6e71ae680..70091e69d 100644 --- a/app/src/main/java/io/legado/app/data/dao/BookDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/BookDao.kt @@ -1,19 +1,56 @@ package io.legado.app.data.dao import androidx.lifecycle.LiveData -import androidx.paging.DataSource -import androidx.room.Dao -import androidx.room.Query +import androidx.room.* +import io.legado.app.constant.BookType import io.legado.app.data.entities.Book - @Dao interface BookDao { + @Query("SELECT * FROM books order by durChapterTime desc") + fun observeAll(): LiveData> + + @Query("SELECT * FROM books WHERE type = ${BookType.audio} order by durChapterTime desc") + fun observeAudio(): LiveData> + + @Query("SELECT * FROM books WHERE origin = '${BookType.local}' order by durChapterTime desc") + fun observeLocal(): LiveData> + @Query("SELECT * FROM books WHERE `group` = :group") - fun observeByGroup(group: Int): DataSource.Factory + fun observeByGroup(group: Int): LiveData> - @Query("SELECT descUrl FROM books WHERE `group` = :group") + @Query("SELECT bookUrl FROM books WHERE `group` = :group") fun observeUrlsByGroup(group: Int): LiveData> + @Query("SELECT * FROM books WHERE name like '%'||:key||'%' or author like '%'||:key||'%'") + fun liveDataSearch(key: String): LiveData> + + @Query("SELECT * FROM books WHERE `name` in (:names)") + fun findByName(vararg names: String): List + + @Query("SELECT * FROM books WHERE bookUrl = :bookUrl") + fun getBook(bookUrl: String): Book? + + @get:Query("SELECT * FROM books") + val allBooks: List + + @get:Query("SELECT * FROM books ORDER BY durChapterTime DESC limit 1") + val lastReadBook: Book? + + @get:Query("SELECT bookUrl FROM books") + val allBookUrls: List + + @get:Query("SELECT COUNT(*) FROM books") + val allBookCount: Int + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg book: Book) + + @Update + fun update(vararg books: Book) + + @Query("delete from books where bookUrl = :bookUrl") + fun delete(bookUrl: String) + } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/BookGroupDao.kt b/app/src/main/java/io/legado/app/data/dao/BookGroupDao.kt new file mode 100644 index 000000000..6162ffc5a --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/BookGroupDao.kt @@ -0,0 +1,24 @@ +package io.legado.app.data.dao + +import androidx.lifecycle.LiveData +import androidx.room.* +import io.legado.app.data.entities.BookGroup + +@Dao +interface BookGroupDao { + + @Query("SELECT * FROM book_groups ORDER BY `order`") + fun liveDataAll(): LiveData> + + @get:Query("SELECT MAX(groupId) FROM book_groups") + val maxId: Int + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg bookGroup: BookGroup) + + @Update + fun update(vararg bookGroup: BookGroup) + + @Delete + fun delete(vararg bookGroup: BookGroup) +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt b/app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt new file mode 100644 index 000000000..d2b48fa49 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt @@ -0,0 +1,82 @@ +package io.legado.app.data.dao + +import androidx.lifecycle.LiveData +import androidx.paging.DataSource +import androidx.room.* +import io.legado.app.data.entities.BookSource + +@Dao +interface BookSourceDao { + + @Query("select * from book_sources order by customOrder asc") + fun liveDataAll(): LiveData> + + @Query("select * from book_sources where bookSourceName like :searchKey or bookSourceGroup like :searchKey or bookSourceUrl like :searchKey order by customOrder asc") + fun liveDataSearch(searchKey: String = ""): LiveData> + + @Query("select * from book_sources where enabledExplore = 1 and exploreUrl is not null and exploreUrl <> '' order by customOrder asc") + fun liveExplore(): LiveData> + + @Query("select * from book_sources where enabledExplore = 1 and exploreUrl is not null and exploreUrl <> '' and (bookSourceGroup like :key or bookSourceName like :key) order by customOrder asc") + fun liveExplore(key: String): LiveData> + + @Query("select bookSourceGroup from book_sources where bookSourceGroup is not null and bookSourceGroup <> ''") + fun liveGroup(): LiveData> + + @Query("select bookSourceGroup from book_sources where enabled = 1 and bookSourceGroup is not null and bookSourceGroup <> ''") + fun liveGroupEnabled(): LiveData> + + @Query("select distinct enabled from book_sources where bookSourceName like :searchKey or bookSourceGroup like :searchKey or bookSourceUrl like :searchKey") + fun searchIsEnable(searchKey: String = ""): List + + @Query("update book_sources set enabled = 1 where bookSourceUrl in (:sourceUrls)") + fun enableSection(vararg sourceUrls: String) + + @Query("update book_sources set enabled = 0 where bookSourceUrl in (:sourceUrls)") + fun disableSection(vararg sourceUrls: String) + + @Query("delete from book_sources where bookSourceUrl in (:sourceUrls)") + fun delSection(vararg sourceUrls: String) + + @Query("select * from book_sources where enabledExplore = 1 order by customOrder asc") + fun observeFind(): DataSource.Factory + + @Query("select * from book_sources where bookSourceGroup like '%' || :group || '%'") + fun getByGroup(group: String): List + + @Query("select * from book_sources where enabled = 1 and bookSourceGroup like '%' || :group || '%'") + fun getEnabledByGroup(group: String): List + + @get:Query("select * from book_sources where bookSourceGroup is null or bookSourceGroup = ''") + val noGroup: List + + @get:Query("select * from book_sources order by customOrder asc") + val all: List + + @get:Query("select * from book_sources where enabled = 1 order by customOrder asc") + val allEnabled: List + + @Query("select * from book_sources where bookSourceUrl = :key") + fun getBookSource(key: String): BookSource? + + @Query("select count(*) from book_sources") + fun allCount(): Int + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg bookSource: BookSource) + + @Update + fun update(vararg bookSource: BookSource) + + @Delete + fun delete(vararg bookSource: BookSource) + + @Query("delete from book_sources where bookSourceUrl = :key") + fun delete(key: String) + + @get:Query("select min(customOrder) from book_sources") + val minOrder: Int + + @get:Query("select max(customOrder) from book_sources") + val maxOrder: Int +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/BookmarkDao.kt b/app/src/main/java/io/legado/app/data/dao/BookmarkDao.kt new file mode 100644 index 000000000..eb757c547 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/BookmarkDao.kt @@ -0,0 +1,18 @@ +package io.legado.app.data.dao + +import androidx.paging.DataSource +import androidx.room.Dao +import androidx.room.Query +import io.legado.app.data.entities.Bookmark + + +@Dao +interface BookmarkDao { + + @Query("select * from bookmarks") + fun all(): List + + @Query("select * from bookmarks where bookUrl = :bookUrl") + fun observeByBook(bookUrl: String): DataSource.Factory + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/ReplaceRuleDao.kt b/app/src/main/java/io/legado/app/data/dao/ReplaceRuleDao.kt index 9b92e4c6a..5940272af 100644 --- a/app/src/main/java/io/legado/app/data/dao/ReplaceRuleDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/ReplaceRuleDao.kt @@ -1,7 +1,6 @@ package io.legado.app.data.dao import androidx.lifecycle.LiveData -import androidx.paging.DataSource import androidx.room.* import io.legado.app.data.entities.ReplaceRule @@ -10,10 +9,13 @@ import io.legado.app.data.entities.ReplaceRule interface ReplaceRuleDao { @Query("SELECT * FROM replace_rules ORDER BY sortOrder ASC") - fun observeAll(): DataSource.Factory + fun liveDataAll(): LiveData> - @Query("SELECT id FROM replace_rules ORDER BY sortOrder ASC") - fun observeAllIds(): LiveData> + @Query("SELECT * FROM replace_rules where `group` like :key or name like :key ORDER BY sortOrder ASC") + fun liveDataSearch(key: String): LiveData> + + @get:Query("SELECT MIN(sortOrder) FROM replace_rules") + val minOrder: Int @get:Query("SELECT MAX(sortOrder) FROM replace_rules") val maxOrder: Int @@ -25,25 +27,53 @@ interface ReplaceRuleDao { val allEnabled: List @Query("SELECT * FROM replace_rules WHERE id = :id") - fun findById(id: Int): ReplaceRule? + fun findById(id: Long): ReplaceRule? @Query("SELECT * FROM replace_rules WHERE id in (:ids)") - fun findByIds(vararg ids: Int): List + fun findByIds(vararg ids: Long): List + + @Query("update replace_rules set isEnabled = 1 where id in (:ids)") + fun enableSection(vararg ids: Long) + + @Query("update replace_rules set isEnabled = 0 where id in (:ids)") + fun disableSection(vararg ids: Long) - @Query("SELECT * FROM replace_rules WHERE isEnabled = 1 AND scope LIKE '%' || :scope || '%'") + @Query("delete from replace_rules where id in (:ids)") + fun delSection(vararg ids: Long) + + @Query( + """SELECT * FROM replace_rules WHERE isEnabled = 1 + AND (scope LIKE '%' || :scope || '%' or scope = null or scope = '')""" + ) fun findEnabledByScope(scope: String): List - @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insert(vararg replaceRules: ReplaceRule) + @Query( + """SELECT * FROM replace_rules WHERE isEnabled = 1 + AND (scope LIKE '%' || :name || '%' or scope LIKE '%' || :origin || '%' or scope = null or scope = '')""" + ) + fun findEnabledByScope(name: String, origin: String): List + + @Query("select `group` from replace_rules where `group` is not null and `group` <> ''") + fun liveGroup(): LiveData> + + @Query("select * from replace_rules where `group` like '%' || :group || '%'") + fun getByGroup(group: String): List + + @get:Query("select * from replace_rules where `group` is null or `group` = ''") + val noGroup: List + + @get:Query("SELECT COUNT(*) - SUM(isEnabled) FROM replace_rules") + val summary: Int + + @Query("UPDATE replace_rules SET isEnabled = :enable") + fun enableAll(enable: Boolean) @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insert(replaceRule: ReplaceRule): Long + fun insert(vararg replaceRule: ReplaceRule): List @Update fun update(vararg replaceRules: ReplaceRule) @Delete fun delete(vararg replaceRules: ReplaceRule) - - } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/RssArticleDao.kt b/app/src/main/java/io/legado/app/data/dao/RssArticleDao.kt new file mode 100644 index 000000000..a38fa15d9 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/RssArticleDao.kt @@ -0,0 +1,24 @@ +package io.legado.app.data.dao + +import androidx.lifecycle.LiveData +import androidx.room.* +import io.legado.app.data.entities.RssArticle + +@Dao +interface RssArticleDao { + + @Query("select * from rssArticles where origin = :origin and title = :title") + fun get(origin: String, title: String): RssArticle? + + @Query("select * from rssArticles where origin = :origin order by `order` desc") + fun liveByOrigin(origin: String): LiveData> + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(vararg rssArticle: RssArticle) + + @Update + fun update(vararg rssArticle: RssArticle) + + @Query("delete from rssArticles where origin = :origin") + fun delete(origin: String) +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/RssSourceDao.kt b/app/src/main/java/io/legado/app/data/dao/RssSourceDao.kt new file mode 100644 index 000000000..6a70e4666 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/RssSourceDao.kt @@ -0,0 +1,60 @@ +package io.legado.app.data.dao + +import androidx.lifecycle.LiveData +import androidx.room.* +import io.legado.app.data.entities.RssSource + +@Dao +interface RssSourceDao { + + @Query("select * from rssSources where sourceUrl = :key") + fun getByKey(key: String): RssSource? + + @get:Query("SELECT * FROM rssSources") + val all: List + + @Query("SELECT * FROM rssSources order by customOrder") + fun liveAll(): LiveData> + + @Query("SELECT * FROM rssSources where sourceName like :key or sourceUrl like :key or sourceGroup like :key order by customOrder") + fun liveSearch(key: String): LiveData> + + @Query("SELECT * FROM rssSources where enabled = 1 order by customOrder") + fun liveEnabled(): LiveData> + + @Query("select sourceGroup from rssSources where sourceGroup is not null and sourceGroup <> ''") + fun liveGroup(): LiveData> + + @Query("update rssSources set enabled = 1 where sourceUrl in (:sourceUrls)") + fun enableSection(vararg sourceUrls: String) + + @Query("update rssSources set enabled = 0 where sourceUrl in (:sourceUrls)") + fun disableSection(vararg sourceUrls: String) + + @get:Query("select min(customOrder) from rssSources") + val minOrder: Int + + @get:Query("select max(customOrder) from rssSources") + val maxOrder: Int + + @Query("delete from rssSources where sourceUrl in (:sourceUrls)") + fun delSection(vararg sourceUrls: String) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg rssSource: RssSource) + + @Update + fun update(vararg rssSource: RssSource) + + @Delete + fun delete(vararg rssSource: RssSource) + + @Query("delete from rssSources where sourceUrl = :sourceUrl") + fun delete(sourceUrl: String) + + @get:Query("select * from rssSources where sourceGroup is null or sourceGroup = ''") + val noGroup: List + + @Query("select * from rssSources where sourceGroup like '%' || :group || '%'") + fun getByGroup(group: String): List +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/SearchBookDao.kt b/app/src/main/java/io/legado/app/data/dao/SearchBookDao.kt new file mode 100644 index 000000000..a8b61a068 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/SearchBookDao.kt @@ -0,0 +1,44 @@ +package io.legado.app.data.dao + +import androidx.paging.DataSource +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.legado.app.data.entities.SearchBook +import io.legado.app.data.entities.SearchShow + +@Dao +interface SearchBookDao { + + @Query( + """SELECT name, author, min(time) time, max(kind) kind, max(coverUrl) coverUrl,max(intro) intro, max(wordCount) wordCount, + max(latestChapterTitle) latestChapterTitle, count(origin) originCount + FROM searchBooks where time >= :time + group by name, author + order by case when name = :key then 1 when author = :key then 2 when name like '%'||:key||'%' then 3 when author like '%'||:key||'%' then 4 else 5 end, time""" + ) + fun observeShow(key: String, time: Long): DataSource.Factory + + @Query("SELECT * FROM searchBooks") + fun observeAll(): DataSource.Factory + + @Query("SELECT * FROM searchBooks where time >= :time") + fun observeNew(time: Long): DataSource.Factory + + @Query("select * from searchBooks where bookUrl = :bookUrl") + fun getSearchBook(bookUrl: String): SearchBook? + + @Query("select * from searchBooks where name = :name and author = :author order by originOrder limit 1") + fun getFirstByNameAuthor(name: String, author: String): SearchBook? + + @Query("select * from searchBooks where name = :name and author = :author order by originOrder") + fun getByNameAuthor(name: String, author: String): List + + @Query("select * from searchBooks where name = :name and author = :author and origin in (select bookSourceUrl from book_sources where enabled = 1) order by originOrder") + fun getByNameAuthorEnable(name: String, author: String): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg searchBook: SearchBook): List + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/SearchKeywordDao.kt b/app/src/main/java/io/legado/app/data/dao/SearchKeywordDao.kt new file mode 100644 index 000000000..be7ad75c5 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/SearchKeywordDao.kt @@ -0,0 +1,35 @@ +package io.legado.app.data.dao + +import androidx.lifecycle.LiveData +import androidx.room.* +import io.legado.app.data.entities.SearchKeyword + + +@Dao +interface SearchKeywordDao { + + @Query("SELECT * FROM search_keywords ORDER BY usage DESC") + fun liveDataByUsage(): LiveData> + + @Query("SELECT * FROM search_keywords ORDER BY lastUseTime DESC") + fun liveDataByTime(): LiveData> + + @Query("SELECT * FROM search_keywords where word like '%'||:key||'%' ORDER BY usage DESC") + fun liveDataSearch(key: String): LiveData> + + @Query("select * from search_keywords where word = :key") + fun get(key: String): SearchKeyword? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg keywords: SearchKeyword) + + @Update + fun update(vararg keywords: SearchKeyword) + + @Delete + fun delete(vararg keywords: SearchKeyword) + + @Query("DELETE FROM search_keywords") + fun deleteAll() + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/SourceCookieDao.kt b/app/src/main/java/io/legado/app/data/dao/SourceCookieDao.kt new file mode 100644 index 000000000..fcf4abf70 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/SourceCookieDao.kt @@ -0,0 +1,12 @@ +package io.legado.app.data.dao + +import androidx.room.Dao +import androidx.room.Query + +@Dao +interface SourceCookieDao { + + @Query("SELECT cookie FROM cookies Where url = :url") + fun getCookieByUrl(url: String): String? + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/BaseBook.kt b/app/src/main/java/io/legado/app/data/entities/BaseBook.kt new file mode 100644 index 000000000..bd5c28db3 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/BaseBook.kt @@ -0,0 +1,26 @@ +package io.legado.app.data.entities + +import io.legado.app.utils.splitNotBlank + +interface BaseBook { + var variableMap: HashMap + var kind: String? + var wordCount: String? + + var infoHtml: String? + var tocHtml: String? + + fun putVariable(key: String, value: String) + + fun getKindList(): List { + val kindList = arrayListOf() + wordCount?.let { + if (it.isNotBlank()) kindList.add(it) + } + kind?.let { + val kinds = it.splitNotBlank(",", "\n") + kindList.addAll(kinds) + } + return kindList + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/Book.kt b/app/src/main/java/io/legado/app/data/entities/Book.kt index fc601f9fe..2b26dcf72 100644 --- a/app/src/main/java/io/legado/app/data/entities/Book.kt +++ b/app/src/main/java/io/legado/app/data/entities/Book.kt @@ -1,50 +1,93 @@ package io.legado.app.data.entities import android.os.Parcelable -import androidx.room.* -import io.legado.app.utils.strim +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.Index +import androidx.room.PrimaryKey +import io.legado.app.constant.BookType +import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonObject +import kotlinx.android.parcel.IgnoredOnParcel import kotlinx.android.parcel.Parcelize +import kotlin.math.max @Parcelize -@Entity(tableName = "books", indices = [(Index(value = ["descUrl"], unique = true))]) -data class Book(@PrimaryKey - var descUrl: String = "", // 详情页Url(本地书源存储完整文件路径) - var tocUrl: String = "", // 目录页Url (toc=table of Contents) - var sourceId: Int = -1, // 书源规则id(默认-1,表示本地书籍) - var name: String = "", // 书籍名称(书源获取) - var customName: String? = null, // 书籍名称(用户修改) - var author: String? = null, // 作者名称(书源获取) - var customAuthor: String? = null, // 作者名称(用户修改) - var tag: String? = null, // 分类信息(书源获取) - var customTag: String? = null, // 分类信息(用户修改) - var coverUrl: String? = null, // 封面Url(书源获取) - var customCoverUrl: String? = null, // 封面Url(用户修改) - var description: String? = null, // 简介内容(书源获取) - var customDescription: String? = null, // 简介内容(用户修改) - var charset: String? = null, // 自定义字符集名称(仅适用于本地书籍) - var type: Int = 0, // 0: 文本读物, 1: 有声读物 - var group: Int = 0, // 自定义分组索引号 - var latestChapterTitle: String? = null, // 最新章节标题 - var latestChapterTime: Long = 0, // 最新章节标题更新时间 - var lastCheckTime: Long = 0, // 最近一次更新书籍信息的时间 - var lastCheckCount: Int = 0, // 最近一次发现新章节的数量 - var totalChapterNum: Int = 0, // 书籍目录总数 - var durChapterTitle: String? = null, // 当前章节名称 - var durChapterIndex: Int = 0, // 当前章节索引 - var durChapterPos: Int = 0, // 当前阅读的进度(首行字符的索引位置) - var durChapterTime: Long = 0, // 最近一次阅读书籍的时间(打开正文的时间) - var canUpdate: Boolean = true, // 刷新书架时更新书籍信息 - var variable: String? = null // 自定义书籍变量信息(用于书源规则检索书籍信息) -) : Parcelable { - - fun getUnreadChapterNum() = Math.max(totalChapterNum - durChapterIndex - 1, 0) - - fun getDisplayName() = customName.strim() ?: name - - fun getDisplayAuthor() = customAuthor.strim() ?: author - - fun getDisplayCover() = customCoverUrl.strim() ?: coverUrl - - fun getDisplayDescription() = customDescription.strim() ?: description +@Entity(tableName = "books", indices = [(Index(value = ["bookUrl"], unique = true))]) +data class Book( + @PrimaryKey + var bookUrl: String = "", // 详情页Url(本地书源存储完整文件路径) + var tocUrl: String = "", // 目录页Url (toc=table of Contents) + var origin: String = BookType.local, // 书源URL(默认BookType.local) + var originName: String = "", //书源名称 + var name: String = "", // 书籍名称(书源获取) + var author: String = "", // 作者名称(书源获取) + override var kind: String? = null, // 分类信息(书源获取) + var customTag: String? = null, // 分类信息(用户修改) + var coverUrl: String? = null, // 封面Url(书源获取) + var customCoverUrl: String? = null, // 封面Url(用户修改) + var intro: String? = null, // 简介内容(书源获取) + var customIntro: String? = null, // 简介内容(用户修改) + var charset: String? = null, // 自定义字符集名称(仅适用于本地书籍) + var type: Int = 0, // @BookType + var group: Int = 0, // 自定义分组索引号 + var latestChapterTitle: String? = null, // 最新章节标题 + var latestChapterTime: Long = System.currentTimeMillis(), // 最新章节标题更新时间 + var lastCheckTime: Long = System.currentTimeMillis(), // 最近一次更新书籍信息的时间 + var lastCheckCount: Int = 0, // 最近一次发现新章节的数量 + var totalChapterNum: Int = 0, // 书籍目录总数 + var durChapterTitle: String? = null, // 当前章节名称 + var durChapterIndex: Int = 0, // 当前章节索引 + var durChapterPos: Int = 0, // 当前阅读的进度(首行字符的索引位置) + var durChapterTime: Long = System.currentTimeMillis(), // 最近一次阅读书籍的时间(打开正文的时间) + override var wordCount: String? = null, + var canUpdate: Boolean = true, // 刷新书架时更新书籍信息 + var order: Int = 0, // 手动排序 + var originOrder: Int = 0, //书源排序 + var useReplaceRule: Boolean = true, // 正文使用净化替换规则 + var variable: String? = null // 自定义书籍变量信息(用于书源规则检索书籍信息) +) : Parcelable, BaseBook { + @Ignore + @IgnoredOnParcel + override var variableMap: HashMap = GSON.fromJsonObject(variable) ?: HashMap() + @Ignore + @IgnoredOnParcel + override var infoHtml: String? = null + + @Ignore + @IgnoredOnParcel + override var tocHtml: String? = null + + fun getUnreadChapterNum() = max(totalChapterNum - durChapterIndex - 1, 0) + + fun getDisplayCover() = if (customCoverUrl.isNullOrEmpty()) coverUrl else customCoverUrl + + fun getDisplayIntro() = if (customIntro.isNullOrEmpty()) intro else customIntro + + override fun putVariable(key: String, value: String) { + variableMap[key] = value + variable = GSON.toJson(variableMap) + } + + fun toSearchBook(): SearchBook { + return SearchBook( + name = name, + author = author, + kind = kind, + bookUrl = bookUrl, + origin = origin, + originName = originName, + wordCount = wordCount, + latestChapterTitle = latestChapterTitle, + coverUrl = coverUrl, + intro = intro, + tocUrl = tocUrl, + originOrder = originOrder, + variable = variable + ).apply { + this.infoHtml = this@Book.infoHtml + this.tocHtml = this@Book.tocHtml + } + } } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/BookChapter.kt b/app/src/main/java/io/legado/app/data/entities/BookChapter.kt new file mode 100644 index 000000000..61006a6ea --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/BookChapter.kt @@ -0,0 +1,32 @@ +package io.legado.app.data.entities + +import android.os.Parcelable +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import kotlinx.android.parcel.Parcelize + + +@Parcelize +@Entity( + tableName = "chapters", + primaryKeys = ["url", "bookUrl"], + indices = [(Index(value = ["bookUrl"], unique = false)), (Index(value = ["bookUrl", "index"], unique = true))], + foreignKeys = [(ForeignKey( + entity = Book::class, + parentColumns = ["bookUrl"], + childColumns = ["bookUrl"], + onDelete = ForeignKey.CASCADE + ))] +) // 删除书籍时自动删除章节 +data class BookChapter( + var url: String = "", // 章节地址 + var title: String = "", // 章节标题 + var bookUrl: String = "", // 书籍地址 + var index: Int = 0, // 章节序号 + var resourceUrl: String? = null, // 音频真实URL + var tag: String? = null, // + var start: Long? = null, // 章节起始位置 + var end: Long? = null // 章节终止位置 +) : Parcelable + diff --git a/app/src/main/java/io/legado/app/data/entities/BookGroup.kt b/app/src/main/java/io/legado/app/data/entities/BookGroup.kt new file mode 100644 index 000000000..5b3bb0992 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/BookGroup.kt @@ -0,0 +1,15 @@ +package io.legado.app.data.entities + +import android.os.Parcelable +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlinx.android.parcel.Parcelize + +@Parcelize +@Entity(tableName = "book_groups") +data class BookGroup( + @PrimaryKey + var groupId: Int = 0, + var groupName: String, + var order: Int = 0 +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/BookSource.kt b/app/src/main/java/io/legado/app/data/entities/BookSource.kt new file mode 100644 index 000000000..3236d5796 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/BookSource.kt @@ -0,0 +1,176 @@ +package io.legado.app.data.entities + +import android.os.Parcelable +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.Index +import androidx.room.PrimaryKey +import io.legado.app.App +import io.legado.app.constant.AppConst +import io.legado.app.constant.AppConst.userAgent +import io.legado.app.data.entities.rule.* +import io.legado.app.help.JsExtensions +import io.legado.app.utils.ACache +import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonObject +import io.legado.app.utils.getPrefString +import kotlinx.android.parcel.IgnoredOnParcel +import kotlinx.android.parcel.Parcelize +import java.util.* +import javax.script.SimpleBindings + +@Parcelize +@Entity( + tableName = "book_sources", + indices = [(Index(value = ["bookSourceUrl"], unique = false))] +) +data class BookSource( + var bookSourceName: String = "", // 名称 + var bookSourceGroup: String? = null, // 分组 + @PrimaryKey + var bookSourceUrl: String = "", // 地址,包括 http/https + var bookSourceType: Int = 0, // 类型,0 文本,1 音频 + var bookUrlPattern: String? = null, + var customOrder: Int = 0, // 手动排序编号 + var enabled: Boolean = true, // 是否启用 + var enabledExplore: Boolean = true, //启用发现 + var header: String? = null, // header + var loginUrl: String? = null, // 登录地址 + var lastUpdateTime: Long = 0, // 最后更新时间,用于排序 + var weight: Int = 0, // 智能排序的权重 + var exploreUrl: String? = null, //发现url + var ruleExplore: String? = null, // 发现规则 + var searchUrl: String? = null, //搜索url + var ruleSearch: String? = null, // 搜索规则 + var ruleBookInfo: String? = null, // 书籍信息页规则 + var ruleToc: String? = null, // 目录页规则 + var ruleContent: String? = null // 正文页规则 +) : Parcelable { + @Ignore + @IgnoredOnParcel + var searchRuleV: SearchRule? = null + + @Ignore + @IgnoredOnParcel + var exploreRuleV: ExploreRule? = null + + @Ignore + @IgnoredOnParcel + var bookInfoRuleV: BookInfoRule? = null + + @Ignore + @IgnoredOnParcel + var tocRuleV: TocRule? = null + + @Ignore + @IgnoredOnParcel + var contentRuleV: ContentRule? = null + + @Throws(Exception::class) + fun getHeaderMap(): Map { + val headerMap = HashMap() + headerMap["User-Agent"] = App.INSTANCE.getPrefString("user_agent") ?: userAgent + header?.let { + val header1 = when { + it.startsWith("@js:", true) -> + evalJS(it.substring(4)).toString() + it.startsWith("", true) -> + evalJS(it.substring(4, it.lastIndexOf("<"))).toString() + else -> it + } + GSON.fromJsonObject>(header1)?.let { map -> + headerMap.putAll(map) + } + } + return headerMap + } + + fun getSearchRule(): SearchRule { + searchRuleV ?: let { + searchRuleV = GSON.fromJsonObject(ruleSearch) + searchRuleV ?: let { searchRuleV = SearchRule() } + } + return searchRuleV!! + } + + fun getExploreRule(): ExploreRule { + exploreRuleV ?: let { + exploreRuleV = GSON.fromJsonObject(ruleExplore) + exploreRuleV ?: let { exploreRuleV = ExploreRule() } + } + return exploreRuleV!! + } + + fun getBookInfoRule(): BookInfoRule { + bookInfoRuleV ?: let { + bookInfoRuleV = GSON.fromJsonObject(ruleBookInfo) + bookInfoRuleV ?: let { bookInfoRuleV = BookInfoRule() } + } + return bookInfoRuleV!! + } + + fun getTocRule(): TocRule { + tocRuleV ?: let { + tocRuleV = GSON.fromJsonObject(ruleToc) + tocRuleV ?: let { tocRuleV = TocRule() } + } + return tocRuleV!! + } + + fun getContentRule(): ContentRule { + contentRuleV ?: let { + contentRuleV = GSON.fromJsonObject(ruleContent) + contentRuleV ?: let { contentRuleV = ContentRule() } + } + return contentRuleV!! + } + + fun getExploreKinds(): ArrayList? { + val exploreKinds = arrayListOf() + exploreUrl?.let { + var a = it + if (a.isNotBlank()) { + try { + if (it.startsWith("", false)) { + val aCache = ACache.get(App.INSTANCE, "explore") + a = aCache.getAsString(bookSourceUrl) ?: "" + if (a.isBlank()) { + val bindings = SimpleBindings() + bindings["baseUrl"] = bookSourceUrl + bindings["java"] = JsExtensions + a = AppConst.SCRIPT_ENGINE.eval( + it.substring(4, it.lastIndexOf("<")), + bindings + ).toString() + aCache.put(bookSourceUrl, a) + } + } + val b = a.split("(&&|\n)+".toRegex()) + b.map { c -> + val d = c.split("::") + if (d.size > 1) + exploreKinds.add(ExploreKind(d[0], d[1])) + } + } catch (e: Exception) { + exploreKinds.add(ExploreKind(e.localizedMessage)) + } + } + } + return exploreKinds + } + + /** + * 执行JS + */ + @Throws(Exception::class) + private fun evalJS(jsStr: String): Any { + val bindings = SimpleBindings() + bindings["java"] = JsExtensions + return AppConst.SCRIPT_ENGINE.eval(jsStr, bindings) + } + + data class ExploreKind( + var title: String, + var url: String? = null + ) +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/Bookmark.kt b/app/src/main/java/io/legado/app/data/entities/Bookmark.kt new file mode 100644 index 000000000..c1f638137 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/Bookmark.kt @@ -0,0 +1,21 @@ +package io.legado.app.data.entities + +import android.os.Parcelable +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import kotlinx.android.parcel.Parcelize + +@Parcelize +@Entity(tableName = "bookmarks", indices = [(Index(value = ["bookUrl"], unique = true))]) +data class Bookmark( + @PrimaryKey + var time: Long = System.currentTimeMillis(), + var bookUrl: String = "", + var bookName: String = "", + var chapterName: String = "", + var chapterIndex: Int = 0, + var pageIndex: Int = 0, + var content: String = "" + +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/Chapter.kt b/app/src/main/java/io/legado/app/data/entities/Chapter.kt deleted file mode 100644 index 724ae6258..000000000 --- a/app/src/main/java/io/legado/app/data/entities/Chapter.kt +++ /dev/null @@ -1,28 +0,0 @@ -package io.legado.app.data.entities - -import android.os.Parcelable -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.Index -import androidx.room.PrimaryKey -import kotlinx.android.parcel.Parcelize - - -@Parcelize -@Entity(tableName = "chapters", - indices = [(Index(value = ["url"], unique = true)), (Index(value = ["bookUrl", "index"], unique = true))], - foreignKeys = [(ForeignKey(entity = Book::class, - parentColumns = ["descUrl"], - childColumns = ["bookUrl"], - onDelete = ForeignKey.CASCADE))]) // 删除书籍时自动删除章节 -data class Chapter(@PrimaryKey - var url: String = "", // 章节地址 - var title: String = "", // 章节标题 - var bookUrl: String = "", // 书籍地址 - var index: Int = 0, // 章节序号 - var resourceUrl: String? = null, // 音频真实URL - var tag: String? = null, // - var start: Long? = null, // 章节起始位置 - var end: Long? = null // 章节终止位置 -) : Parcelable - diff --git a/app/src/main/java/io/legado/app/data/entities/EditEntity.kt b/app/src/main/java/io/legado/app/data/entities/EditEntity.kt new file mode 100644 index 000000000..d4c1ecc1a --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/EditEntity.kt @@ -0,0 +1,3 @@ +package io.legado.app.data.entities + +data class EditEntity(var key: String, var value: String?, var hint: Int) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/ReplaceRule.kt b/app/src/main/java/io/legado/app/data/entities/ReplaceRule.kt index 8b5a0fda6..269325e5d 100644 --- a/app/src/main/java/io/legado/app/data/entities/ReplaceRule.kt +++ b/app/src/main/java/io/legado/app/data/entities/ReplaceRule.kt @@ -8,19 +8,20 @@ import androidx.room.PrimaryKey import kotlinx.android.parcel.Parcelize @Parcelize -@Entity(tableName = "replace_rules", - indices = [(Index(value = ["id"]))]) +@Entity( + tableName = "replace_rules", + indices = [(Index(value = ["id"]))] +) data class ReplaceRule( - @PrimaryKey(autoGenerate = true) - var id: Int = 0, - var name: String? = null, - var pattern: String? = null, - var replacement: String? = null, - var scope: String? = null, - var isEnabled: Boolean = true, - var isRegex: Boolean = true, - @ColumnInfo(name = "sortOrder") - var order: Int = 0 -) : Parcelable - - + @PrimaryKey(autoGenerate = true) + var id: Long = System.currentTimeMillis(), + var name: String = "", + var group: String? = null, + var pattern: String = "", + var replacement: String = "", + var scope: String? = null, + var isEnabled: Boolean = true, + var isRegex: Boolean = true, + @ColumnInfo(name = "sortOrder") + var order: Int = 0 +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/RssArticle.kt b/app/src/main/java/io/legado/app/data/entities/RssArticle.kt new file mode 100644 index 000000000..e3810036f --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/RssArticle.kt @@ -0,0 +1,29 @@ +package io.legado.app.data.entities + +import androidx.room.Entity +import androidx.room.Ignore + + +@Entity( + tableName = "rssArticles", + primaryKeys = ["origin", "title"] +) +data class RssArticle( + var origin: String = "", + var title: String = "", + var order: Long = 0, + var author: String? = null, + var link: String? = null, + var pubDate: String? = null, + var description: String? = null, + var content: String? = null, + var image: String? = null, + var categories: String? = null, + var read: Boolean = false, + var star: Boolean = false +) { + + @Ignore + var categoryList: MutableList = mutableListOf() + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/RssSource.kt b/app/src/main/java/io/legado/app/data/entities/RssSource.kt new file mode 100644 index 000000000..c295753b1 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/RssSource.kt @@ -0,0 +1,33 @@ +package io.legado.app.data.entities + +import android.os.Parcelable +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import kotlinx.android.parcel.Parcelize + +@Parcelize +@Entity(tableName = "rssSources", indices = [(Index(value = ["sourceUrl"], unique = false))]) +data class RssSource( + @PrimaryKey + var sourceUrl: String = "", + var sourceName: String = "", + var sourceIcon: String = "", + var sourceGroup: String? = null, + var enabled: Boolean = true, + //列表规则 + var ruleArticles: String? = null, + var ruleNextPage: String? = null, + var ruleTitle: String? = null, + var rulePubDate: String? = null, + //类别规则 + var ruleCategories: String? = null, + //webView规则 + var ruleDescription: String? = null, + var ruleImage: String? = null, + var ruleLink: String? = null, + var ruleContent: String? = null, + var enableJs: Boolean = false, + var loadWithBaseUrl: Boolean = false, + var customOrder: Int = 0 +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/Rule.kt b/app/src/main/java/io/legado/app/data/entities/Rule.kt deleted file mode 100644 index e83807c87..000000000 --- a/app/src/main/java/io/legado/app/data/entities/Rule.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.legado.app.data.entities - -class Rule { - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/SearchBook.kt b/app/src/main/java/io/legado/app/data/entities/SearchBook.kt index 8994f14bd..7457c9b29 100644 --- a/app/src/main/java/io/legado/app/data/entities/SearchBook.kt +++ b/app/src/main/java/io/legado/app/data/entities/SearchBook.kt @@ -1,3 +1,87 @@ package io.legado.app.data.entities -class SearchBook \ No newline at end of file +import android.os.Parcelable +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.Index +import androidx.room.PrimaryKey +import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonObject +import kotlinx.android.parcel.IgnoredOnParcel +import kotlinx.android.parcel.Parcelize + +@Parcelize +@Entity(tableName = "searchBooks", indices = [(Index(value = ["bookUrl"], unique = true))]) +data class SearchBook( + @PrimaryKey + var bookUrl: String = "", + var origin: String = "", // 书源规则 + var originName: String = "", + var name: String = "", + var author: String = "", + override var kind: String? = null, + var coverUrl: String? = null, + var intro: String? = null, + override var wordCount: String? = null, + var latestChapterTitle: String? = null, + var tocUrl: String = "", // 目录页Url (toc=table of Contents) + var time: Long = System.currentTimeMillis(), + var variable: String? = null, + var originOrder: Int = 0 +) : Parcelable, BaseBook, Comparable { + + @Ignore + @IgnoredOnParcel + override var infoHtml: String? = null + + @Ignore + @IgnoredOnParcel + override var tocHtml: String? = null + + override fun equals(other: Any?): Boolean { + if (other is SearchBook) { + if (other.bookUrl == bookUrl) { + return true + } + } + return false + } + + override fun hashCode(): Int { + return bookUrl.hashCode() + } + + override fun compareTo(other: SearchBook): Int { + return other.originOrder - this.originOrder + } + + @IgnoredOnParcel + @Ignore + override var variableMap: HashMap = GSON.fromJsonObject(variable) ?: HashMap() + + override fun putVariable(key: String, value: String) { + variableMap[key] = value + variable = GSON.toJson(variableMap) + } + + fun toBook(): Book { + return Book( + name = name, + author = author, + kind = kind, + bookUrl = bookUrl, + origin = origin, + originName = originName, + wordCount = wordCount, + latestChapterTitle = latestChapterTitle, + coverUrl = coverUrl, + intro = intro, + tocUrl = tocUrl, + originOrder = originOrder, + variable = variable + ).apply { + this.infoHtml = this@SearchBook.infoHtml + this.tocUrl = this@SearchBook.tocUrl + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/SearchKeyword.kt b/app/src/main/java/io/legado/app/data/entities/SearchKeyword.kt new file mode 100644 index 000000000..89d1c3baf --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/SearchKeyword.kt @@ -0,0 +1,17 @@ +package io.legado.app.data.entities + +import android.os.Parcelable +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import kotlinx.android.parcel.Parcelize + + +@Parcelize +@Entity(tableName = "search_keywords", indices = [(Index(value = ["word"], unique = true))]) +data class SearchKeyword( + @PrimaryKey + var word: String = "", // 搜索关键词 + var usage: Int = 1, // 使用次数 + var lastUseTime: Long = System.currentTimeMillis() // 最后一次使用时间 +) : Parcelable diff --git a/app/src/main/java/io/legado/app/data/entities/SearchShow.kt b/app/src/main/java/io/legado/app/data/entities/SearchShow.kt new file mode 100644 index 000000000..a738909ea --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/SearchShow.kt @@ -0,0 +1,29 @@ +package io.legado.app.data.entities + +import io.legado.app.utils.splitNotBlank + +data class SearchShow( + var name: String = "", + var author: String = "", + var kind: String? = null, + var coverUrl: String? = null, + var intro: String? = null, + var wordCount: String? = null, + var latestChapterTitle: String? = null, + var time: Long = 0L, + var originCount: Int = 0 +) { + + fun getKindList(): List { + val kindList = arrayListOf() + wordCount?.let { + if (it.isNotBlank()) kindList.add(it) + } + kind?.let { + val kinds = it.splitNotBlank(",", "\n") + kindList.addAll(kinds) + } + return kindList + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/Source.kt b/app/src/main/java/io/legado/app/data/entities/Source.kt deleted file mode 100644 index aa38236b5..000000000 --- a/app/src/main/java/io/legado/app/data/entities/Source.kt +++ /dev/null @@ -1,31 +0,0 @@ -package io.legado.app.data.entities - -import android.os.Parcelable -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.Index -import androidx.room.PrimaryKey -import kotlinx.android.parcel.Parcelize - -@Parcelize -@Entity(tableName = "sources", - indices = [(Index(value = ["sourceId"]))]) -data class Source(@PrimaryKey(autoGenerate = true) - @ColumnInfo(name = "sourceId") - var id: Int = 0, // 编号 - var name: String = "", // 名称 - var origin: String = "", // 地址,包括 http/https - var type: Int = 0, // 类型,0 文本,1 音频 - var group: String? = null, // 分组 - var header: String? = null, // header - var loginUrl: String? = null, // 登录地址 - var isEnabled: Boolean = true, // 是否启用 - var lastUpdateTime: Long = 0, // 最后更新时间,用于排序 - var customOrder: Int = 0, // 手动排序编号 - var weight: Int = 0, // 智能排序的权重 - var exploreRule: String? = null, // 发现规则 - var searchRule: String? = null, // 搜索规则 - var bookInfoRule: String? = null, // 书籍信息页规则 - var tocRule: String? = null, // 目录页规则 - var contentRule: String? = null // 正文页规则 - ) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/SourceCookie.kt b/app/src/main/java/io/legado/app/data/entities/SourceCookie.kt new file mode 100644 index 000000000..62581a2f8 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/SourceCookie.kt @@ -0,0 +1,12 @@ +package io.legado.app.data.entities + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity(tableName = "cookies", indices = [(Index(value = ["url"], unique = true))]) +data class SourceCookie( + @PrimaryKey + var url: String = "", + var cookie: String = "" +) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/rule/BookInfoRule.kt b/app/src/main/java/io/legado/app/data/entities/rule/BookInfoRule.kt new file mode 100644 index 000000000..a28f3c6e6 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/rule/BookInfoRule.kt @@ -0,0 +1,14 @@ +package io.legado.app.data.entities.rule + +data class BookInfoRule( + var init: String? = null, + var name: String? = null, + var author: String? = null, + var intro: String? = null, + var kind: String? = null, + var lastChapter: String? = null, + var updateTime: String? = null, + var coverUrl: String? = null, + var tocUrl: String? = null, + var wordCount: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/rule/BookListRule.kt b/app/src/main/java/io/legado/app/data/entities/rule/BookListRule.kt new file mode 100644 index 000000000..31676052d --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/rule/BookListRule.kt @@ -0,0 +1,14 @@ +package io.legado.app.data.entities.rule + +interface BookListRule { + var bookList: String? + var name: String? + var author: String? + var intro: String? + var kind: String? + var lastChapter: String? + var updateTime: String? + var bookUrl: String? + var coverUrl: String? + var wordCount: String? +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/rule/ContentRule.kt b/app/src/main/java/io/legado/app/data/entities/rule/ContentRule.kt new file mode 100644 index 000000000..36179bbce --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/rule/ContentRule.kt @@ -0,0 +1,6 @@ +package io.legado.app.data.entities.rule + +data class ContentRule( + var content: String? = null, + var nextContentUrl: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/rule/ExploreRule.kt b/app/src/main/java/io/legado/app/data/entities/rule/ExploreRule.kt new file mode 100644 index 000000000..22b267ed3 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/rule/ExploreRule.kt @@ -0,0 +1,14 @@ +package io.legado.app.data.entities.rule + +data class ExploreRule( + override var bookList: String? = null, + override var name: String? = null, + override var author: String? = null, + override var intro: String? = null, + override var kind: String? = null, + override var lastChapter: String? = null, + override var updateTime: String? = null, + override var bookUrl: String? = null, + override var coverUrl: String? = null, + override var wordCount: String? = null +) : BookListRule \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/rule/SearchRule.kt b/app/src/main/java/io/legado/app/data/entities/rule/SearchRule.kt new file mode 100644 index 000000000..83921e56d --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/rule/SearchRule.kt @@ -0,0 +1,14 @@ +package io.legado.app.data.entities.rule + +data class SearchRule( + override var bookList: String? = null, + override var name: String? = null, + override var author: String? = null, + override var intro: String? = null, + override var kind: String? = null, + override var lastChapter: String? = null, + override var updateTime: String? = null, + override var bookUrl: String? = null, + override var coverUrl: String? = null, + override var wordCount: String? = null +) : BookListRule \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/rule/TocRule.kt b/app/src/main/java/io/legado/app/data/entities/rule/TocRule.kt new file mode 100644 index 000000000..60aabaa41 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/rule/TocRule.kt @@ -0,0 +1,8 @@ +package io.legado.app.data.entities.rule + +data class TocRule( + var chapterList: String? = null, + var chapterName: String? = null, + var chapterUrl: String? = null, + var nextTocUrl: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/ActivityHelp.kt b/app/src/main/java/io/legado/app/help/ActivityHelp.kt new file mode 100644 index 000000000..8c85fe618 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/ActivityHelp.kt @@ -0,0 +1,85 @@ +package io.legado.app.help + +import android.app.Activity +import java.lang.ref.WeakReference +import java.util.* + +/** + * Activity管理器,管理项目中Activity的状态 + */ +object ActivityHelp { + + private val activities: MutableList> = arrayListOf() + + /** + * 判断指定Activity是否存在 + */ + fun isExist(activityClass: Class<*>): Boolean { + for (item in activities) { + if (item.get()?.javaClass == activityClass) { + return true + } + } + return false + } + + /** + * 添加Activity + */ + fun add(activity: Activity) { + activities.add(WeakReference(activity)) + } + + /** + * 移除Activity + */ + fun remove(activity: Activity) { + for (temp in activities) { + if (null != temp.get() && temp.get() === activity) { + activities.remove(temp) + break + } + } + } + + /** + * 移除Activity + */ + fun remove(activityClass: Class<*>) { + val iterator = activities.iterator() + while (iterator.hasNext()) { + val item = iterator.next() + if (item.get()?.javaClass == activityClass) { + iterator.remove() + } + } + } + + /** + * 关闭指定 activity + */ + fun finishActivity(vararg activities: Activity) { + for (activity in activities) { + activity.finish() + } + } + + /** + * 关闭指定 activity(class) + */ + fun finishActivity(vararg activityClasses: Class<*>) { + val waitFinish = ArrayList>() + for (temp in activities) { + for (activityClass in activityClasses) { + if (temp.get()?.javaClass == activityClass) { + waitFinish.add(temp) + break + } + } + } + for (activityWeakReference in waitFinish) { + activityWeakReference.get()?.finish() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/AdapterDataObserverProxy.kt b/app/src/main/java/io/legado/app/help/AdapterDataObserverProxy.kt new file mode 100644 index 000000000..9ef120e15 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/AdapterDataObserverProxy.kt @@ -0,0 +1,30 @@ +package io.legado.app.help + +import androidx.recyclerview.widget.RecyclerView + +internal class AdapterDataObserverProxy(var adapterDataObserver: RecyclerView.AdapterDataObserver, var headerCount: Int) : RecyclerView.AdapterDataObserver() { + override fun onChanged() { + adapterDataObserver.onChanged() + } + + override fun onItemRangeChanged(positionStart: Int, itemCount: Int) { + adapterDataObserver.onItemRangeChanged(positionStart + headerCount, itemCount) + } + + override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) { + adapterDataObserver.onItemRangeChanged(positionStart + headerCount, itemCount, payload) + } + + // 当第n个数据被获取,更新第n+1个position + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + adapterDataObserver.onItemRangeInserted(positionStart + headerCount, itemCount) + } + + override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) { + adapterDataObserver.onItemRangeRemoved(positionStart + headerCount, itemCount) + } + + override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) { + super.onItemRangeMoved(fromPosition + headerCount, toPosition + headerCount, itemCount) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/BlurTransformation.kt b/app/src/main/java/io/legado/app/help/BlurTransformation.kt new file mode 100644 index 000000000..9d60a9047 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/BlurTransformation.kt @@ -0,0 +1,60 @@ +package io.legado.app.help + +import android.annotation.TargetApi +import android.content.Context +import android.graphics.Bitmap +import android.os.Build +import android.renderscript.Allocation +import android.renderscript.Element +import android.renderscript.RenderScript +import android.renderscript.ScriptIntrinsicBlur + +import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool +import com.bumptech.glide.load.resource.bitmap.BitmapTransformation + +import java.security.MessageDigest + +/** + * 模糊 + */ +class BlurTransformation(context: Context, private val radius: Int) : BitmapTransformation() { + private val rs: RenderScript = RenderScript.create(context) + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) + override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap { + val blurredBitmap = toTransform.copy(Bitmap.Config.ARGB_8888, true) + + // Allocate memory for Renderscript to work with + //分配用于渲染脚本的内存 + val input = Allocation.createFromBitmap( + rs, + blurredBitmap, + Allocation.MipmapControl.MIPMAP_FULL, + Allocation.USAGE_SHARED + ) + val output = Allocation.createTyped(rs, input.type) + + // Load up an instance of the specific script that we want to use. + //加载我们想要使用的特定脚本的实例。 + val script = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)) + script.setInput(input) + + // Set the blur radius + //设置模糊半径 + script.setRadius(radius.toFloat()) + + // Start the ScriptIntrinsicBlur + //启动 ScriptIntrinsicBlur, + script.forEach(output) + + // Copy the output to the blurred bitmap + //将输出复制到模糊的位图 + output.copyTo(blurredBitmap) + + return blurredBitmap + } + + override fun updateDiskCacheKey(messageDigest: MessageDigest) { + messageDigest.update("blur transformation".toByteArray()) + } +} diff --git a/app/src/main/java/io/legado/app/help/BookHelp.kt b/app/src/main/java/io/legado/app/help/BookHelp.kt new file mode 100644 index 000000000..e198a316f --- /dev/null +++ b/app/src/main/java/io/legado/app/help/BookHelp.kt @@ -0,0 +1,166 @@ +package io.legado.app.help + +import io.legado.app.App +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.ReplaceRule +import io.legado.app.utils.getPrefInt +import io.legado.app.utils.getPrefString +import org.apache.commons.text.similarity.JaccardSimilarity +import java.io.File +import kotlin.math.min + +object BookHelp { + + private var downloadPath: String = + App.INSTANCE.getPrefString("downloadPath") + ?: App.INSTANCE.getExternalFilesDir(null)?.absolutePath + ?: App.INSTANCE.cacheDir.absolutePath + + fun upDownloadPath() { + downloadPath = + App.INSTANCE.getPrefString("downloadPath") + ?: App.INSTANCE.getExternalFilesDir(null)?.absolutePath + ?: App.INSTANCE.cacheDir.absolutePath + } + + fun saveContent(book: Book, bookChapter: BookChapter, content: String) { + if (content.isEmpty()) return + val filePath = getChapterPath(book, bookChapter) + val file = FileHelp.getFile(filePath) + file.writeText(content) + } + + fun hasContent(book: Book, bookChapter: BookChapter): Boolean { + val filePath = getChapterPath(book, bookChapter) + runCatching { + val file = File(filePath) + if (file.exists()) { + return true + } + } + return false + } + + fun getContent(book: Book, bookChapter: BookChapter): String? { + val filePath = getChapterPath(book, bookChapter) + runCatching { + val file = File(filePath) + if (file.exists()) { + return file.readText() + } + } + return null + } + + fun delContent(book: Book, bookChapter: BookChapter) { + val filePath = getChapterPath(book, bookChapter) + kotlin.runCatching { + val file = File(filePath) + if (file.exists()) { + file.delete() + } + } + } + + private fun getChapterPath(book: Book, bookChapter: BookChapter): String { + val bookFolder = formatFolderName(book.name + book.bookUrl) + val chapterFile = + String.format("%05d-%s", bookChapter.index, formatFolderName(bookChapter.title)) + return "$downloadPath${File.separator}book_cache${File.separator}$bookFolder${File.separator}$chapterFile.nb" + } + + private fun formatFolderName(folderName: String): String { + return folderName.replace("[\\\\/:*?\"<>|.]".toRegex(), "") + } + + fun formatAuthor(author: String?): String { + return author + ?.replace("作\\s*者[\\s::]*".toRegex(), "") + ?.replace("\\s+".toRegex(), " ") + ?.trim { it <= ' ' } + ?: "" + } + + fun getDurChapterIndexByChapterTitle( + title: String?, + index: Int, + chapters: List + ): Int { + if (title.isNullOrEmpty()) { + return min(index, chapters.lastIndex) + } + if (chapters.size > index && title == chapters[index].title) { + return index + } + + var newIndex = 0 + val jaccardSimilarity = JaccardSimilarity() + var similarity = if (chapters.size > index) { + jaccardSimilarity.apply(title, chapters[index].title) + } else 0.0 + if (similarity == 1.0) { + return index + } else { + for (i in 1..50) { + if (index - i in chapters.indices) { + jaccardSimilarity.apply(title, chapters[index - i].title).let { + if (it > similarity) { + similarity = it + newIndex = index - i + if (similarity == 1.0) { + return newIndex + } + } + } + } + if (index + i in chapters.indices) { + jaccardSimilarity.apply(title, chapters[index + i].title).let { + if (it > similarity) { + similarity = it + newIndex = index + i + if (similarity == 1.0) { + return newIndex + } + } + } + } + } + } + return newIndex + } + + var bookName: String? = null + var bookOrigin: String? = null + var replaceRules: List = arrayListOf() + + fun disposeContent( + name: String, origin: String?, + content: String, + enableReplace: Boolean + ): String { + var c = content + synchronized(this) { + if (enableReplace && (bookName != name || bookOrigin != origin)) { + replaceRules = if (origin.isNullOrEmpty()) { + App.db.replaceRuleDao().findEnabledByScope(name) + } else { + App.db.replaceRuleDao().findEnabledByScope(name, origin) + } + } + } + for (item in replaceRules) { + item.pattern.let { + if (it.isNotEmpty()) { + c = if (item.isRegex) { + c.replace(it.toRegex(), item.replacement) + } else { + c.replace(it, item.replacement) + } + } + } + } + val indent = App.INSTANCE.getPrefInt("textIndent", 2) + return c.replace("\\s*\\n+\\s*".toRegex(), "\n" + " ".repeat(indent)) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/CrashHandler.kt b/app/src/main/java/io/legado/app/help/CrashHandler.kt new file mode 100644 index 000000000..a8fe0d05f --- /dev/null +++ b/app/src/main/java/io/legado/app/help/CrashHandler.kt @@ -0,0 +1,153 @@ +package io.legado.app.help + +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.widget.Toast +import io.legado.app.service.TTSReadAloudService +import java.io.File +import java.io.FileOutputStream +import java.io.PrintWriter +import java.io.StringWriter +import java.text.SimpleDateFormat +import java.util.* + +/** + * 异常管理类 + */ +@Suppress("DEPRECATION") +class CrashHandler : Thread.UncaughtExceptionHandler { + private val tag = this.javaClass.simpleName + /** + * 系统默认UncaughtExceptionHandler + */ + private var mDefaultHandler: Thread.UncaughtExceptionHandler? = null + + /** + * context + */ + private var mContext: Context? = null + + /** + * 存储异常和参数信息 + */ + private val paramsMap = HashMap() + + /** + * 格式化时间 + */ + @SuppressLint("SimpleDateFormat") + private val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss") + + fun init(context: Context) { + mContext = context + mDefaultHandler = Thread.getDefaultUncaughtExceptionHandler() + //设置该CrashHandler为系统默认的 + Thread.setDefaultUncaughtExceptionHandler(this) + } + + /** + * uncaughtException 回调函数 + */ + override fun uncaughtException(thread: Thread, ex: Throwable) { + TTSReadAloudService.clearTTS() + handleException(ex) + mDefaultHandler?.uncaughtException(thread, ex) + } + + /** + * 处理该异常 + */ + private fun handleException(ex: Throwable?) { + if (ex == null) return + //收集设备参数信息 + collectDeviceInfo(mContext!!) + //添加自定义信息 + addCustomInfo() + kotlin.runCatching { + //使用Toast来显示异常信息 + Handler(Looper.getMainLooper()).post { + Toast.makeText( + mContext, + ex.message, + Toast.LENGTH_LONG + ).show() + } + } + //保存日志文件 + saveCrashInfo2File(ex) + } + + /** + * 收集设备参数信息 + */ + private fun collectDeviceInfo(ctx: Context) { + //获取versionName,versionCode + kotlin.runCatching { + val pm = ctx.packageManager + val pi = pm.getPackageInfo(ctx.packageName, PackageManager.GET_ACTIVITIES) + if (pi != null) { + val versionName = if (pi.versionName == null) "null" else pi.versionName + val versionCode = pi.versionCode.toString() + "" + paramsMap["versionName"] = versionName + paramsMap["versionCode"] = versionCode + } + } + + //获取所有系统信息 + val fields = Build::class.java.declaredFields + kotlin.runCatching { + for (field in fields) { + field.isAccessible = true + paramsMap[field.name] = field.get(null).toString() + } + } + } + + /** + * 添加自定义参数 + */ + private fun addCustomInfo() { + Log.i(tag, "addCustomInfo: 程序出错了...") + } + + /** + * 保存错误信息到文件中 + */ + private fun saveCrashInfo2File(ex: Throwable) { + kotlin.runCatching { + val sb = StringBuilder() + for ((key, value) in paramsMap) { + sb.append(key).append("=").append(value).append("\n") + } + + val writer = StringWriter() + val printWriter = PrintWriter(writer) + ex.printStackTrace(printWriter) + var cause: Throwable? = ex.cause + while (cause != null) { + cause.printStackTrace(printWriter) + cause = cause.cause + } + printWriter.close() + val result = writer.toString() + sb.append(result) + val timestamp = System.currentTimeMillis() + val time = format.format(Date()) + val fileName = "crash-$time-$timestamp.log" + val path = mContext?.externalCacheDir?.toString() + "/crash/" + val dir = File(path) + if (!dir.exists()) { + dir.mkdirs() + } + val fos = FileOutputStream(path + fileName) + fos.write(sb.toString().toByteArray()) + fos.close() + } + } + +} diff --git a/app/src/main/java/io/legado/app/help/EventMessage.kt b/app/src/main/java/io/legado/app/help/EventMessage.kt new file mode 100644 index 000000000..fe9c80994 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/EventMessage.kt @@ -0,0 +1,50 @@ +package io.legado.app.help + +import android.text.TextUtils + +import java.util.Arrays + +class EventMessage { + + var what: Int?=null + var tag: String? = null + var obj: Any? = null + + fun isFrom(tag: String): Boolean { + return TextUtils.equals(this.tag, tag) + } + + fun maybeFrom(vararg tags: String): Boolean { + return listOf(*tags).contains(tag) + } + + companion object { + + fun obtain(tag: String): EventMessage { + val message = EventMessage() + message.tag = tag + return message + } + + fun obtain(what: Int): EventMessage { + val message = EventMessage() + message.what = what + return message + } + + fun obtain(what: Int, obj: Any): EventMessage { + val message = EventMessage() + message.what = what + message.obj = obj + return message + } + + fun obtain(tag: String, obj: Any): EventMessage { + val message = EventMessage() + message.tag = tag + message.obj = obj + return message + } + } + +} diff --git a/app/src/main/java/io/legado/app/help/FileHelp.kt b/app/src/main/java/io/legado/app/help/FileHelp.kt new file mode 100644 index 000000000..a208a74a2 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/FileHelp.kt @@ -0,0 +1,58 @@ +package io.legado.app.help + +import io.legado.app.App +import java.io.File +import java.io.IOException + +object FileHelp { + + + //获取文件夹 + fun getFolder(filePath: String): File { + val file = File(filePath) + //如果文件夹不存在,就创建它 + if (!file.exists()) { + file.mkdirs() + } + return file + } + + //获取文件 + @Synchronized + fun getFile(filePath: String): File { + val file = File(filePath) + try { + if (!file.exists()) { + //创建父类文件夹 + getFolder(file.parent) + //创建文件 + file.createNewFile() + } + } catch (e: IOException) { + e.printStackTrace() + } + return file + } + + fun getCachePath(): String { + return App.INSTANCE.externalCacheDir?.absolutePath + ?: App.INSTANCE.cacheDir.absolutePath + } + + //递归删除文件夹下的数据 + @Synchronized + fun deleteFile(filePath: String) { + val file = File(filePath) + if (!file.exists()) return + + if (file.isDirectory) { + val files = file.listFiles() + for (subFile in files) { + val path = subFile.path + deleteFile(path) + } + } + //删除文件 + file.delete() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/ImageLoader.kt b/app/src/main/java/io/legado/app/help/ImageLoader.kt new file mode 100644 index 000000000..10a9a8a50 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/ImageLoader.kt @@ -0,0 +1,261 @@ +package io.legado.app.help + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.net.Uri +import android.widget.ImageView +import androidx.annotation.DrawableRes +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.RequestManager +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.resource.bitmap.BitmapTransitionOptions +import com.bumptech.glide.load.resource.bitmap.CenterCrop +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions +import com.bumptech.glide.load.resource.gif.GifDrawable +import com.bumptech.glide.request.RequestOptions +import com.bumptech.glide.request.transition.Transition +import java.io.File + +object ImageLoader { + + fun load(context: Context, url: String?): ImageLoadBuilder { + return ImageLoadBuilder(context, url) + } + + fun load(context: Context, @DrawableRes resId: Int?): ImageLoadBuilder { + return ImageLoadBuilder(context, resId) + } + + fun load(context: Context, file: File?): ImageLoadBuilder { + return ImageLoadBuilder(context, file) + } + + fun load(context: Context, uri: Uri?): ImageLoadBuilder { + return ImageLoadBuilder(context, uri) + } + + fun load(context: Context, drawable: Drawable?): ImageLoadBuilder { + return ImageLoadBuilder(context, drawable) + } + + fun load(context: Context, bitmap: Bitmap?): ImageLoadBuilder { + return ImageLoadBuilder(context, bitmap) + } + + fun load(context: Context, bytes: ByteArray?): ImageLoadBuilder { + return ImageLoadBuilder(context, bytes) + } + + + fun with(context: Context): ImageLoadBuilder { + return ImageLoadBuilder(context) + } + + fun clear(imageView: ImageView) { + with(imageView.context).clear(imageView) + } + + class ImageLoadBuilder constructor(context: Context, private var source: S? = null) { + + private val manager: RequestManager = Glide.with(context) + private var requestOptions: RequestOptions = RequestOptions() + private var crossFade: Boolean = false + private var noCache: Boolean = false + + fun load(source: S): ImageLoadBuilder { + this.source = source + return this + } + + fun toRound(corner: Int): ImageLoadBuilder { + requestOptions = requestOptions.transform(RoundedCorners(corner)) + return this + } + + fun toCropRound(corner: Int): ImageLoadBuilder { + requestOptions = requestOptions.transform(CenterCrop(), RoundedCorners(corner)) + return this + } + + fun toCircle(): ImageLoadBuilder { + requestOptions = requestOptions.circleCrop() + return this + } + + fun centerInside(): ImageLoadBuilder { + requestOptions = requestOptions.centerInside() + return this + } + + fun fitCenter(): ImageLoadBuilder { + requestOptions = requestOptions.fitCenter() + return this + } + + fun centerCrop(): ImageLoadBuilder { + requestOptions = requestOptions.centerCrop() + return this + } + + fun crossFade(): ImageLoadBuilder { + crossFade = true + return this + } + + fun noCache(): ImageLoadBuilder { + noCache = true + return this + } + + fun placeholder(placeholder: Drawable): ImageLoadBuilder { + requestOptions = requestOptions.placeholder(placeholder) + return this + } + + fun placeholder(@DrawableRes resId: Int): ImageLoadBuilder { + requestOptions = requestOptions.placeholder(resId) + return this + } + + fun error(drawable: Drawable): ImageLoadBuilder { + requestOptions = requestOptions.error(drawable) + return this + } + + fun error(@DrawableRes resId: Int): ImageLoadBuilder { + requestOptions = requestOptions.error(resId) + return this + } + + fun override(width: Int, height: Int): ImageLoadBuilder { + requestOptions = requestOptions.override(width, height) + return this + } + + fun override(size: Int): ImageLoadBuilder { + requestOptions = requestOptions.override(size) + return this + } + + fun clear(imageView: ImageView) { + manager.clear(imageView) + } + + fun downloadOnly(target: ImageViewTarget) { + manager.downloadOnly().load(source).into(Target(target)) + } + + fun setAsDrawable(imageView: ImageView) { + asDrawable().into(imageView) + } + + fun setAsDrawable(target: ImageViewTarget) { + asDrawable().into(Target(target)) + } + + fun setAsBitmap(imageView: ImageView) { + asBitmap().into(imageView) + } + + fun setAsBitmap(target: ImageViewTarget) { + asBitmap().into(Target(target)) + } + + fun setAsGif(imageView: ImageView) { + asGif().into(imageView) + } + + fun setAsGif(target: ImageViewTarget) { + asGif().into(Target(target)) + } + + fun setAsFile(imageView: ImageView) { + asFile().into(imageView) + } + + fun setAsFile(target: ImageViewTarget) { + asFile().into(Target(target)) + } + + private fun asDrawable(): RequestBuilder { + var builder: RequestBuilder = ensureOptions(manager.asDrawable().load(source)) + + if (crossFade) { + builder = builder.transition(DrawableTransitionOptions.withCrossFade()) + } + + return builder + } + + private fun asBitmap(): RequestBuilder { + var builder: RequestBuilder = ensureOptions(manager.asBitmap().load(source)) + + if (crossFade) { + builder = builder.transition(BitmapTransitionOptions.withCrossFade()) + } + + return builder + } + + private fun asGif(): RequestBuilder { + var builder: RequestBuilder = ensureOptions(manager.asGif().load(source)) + + if (crossFade) { + builder = builder.transition(DrawableTransitionOptions.withCrossFade()) + } + + return builder + } + + private fun asFile(): RequestBuilder { + return manager.asFile().load(source) + } + + private fun ensureOptions(builder: RequestBuilder): RequestBuilder { + return builder.apply(requestOptions.diskCacheStrategy(if (noCache) DiskCacheStrategy.NONE else DiskCacheStrategy.AUTOMATIC)) + } + + private inner class Target constructor(private val target: ImageViewTarget) : + com.bumptech.glide.request.target.ImageViewTarget(target.view) { + + init { + if (this.target.waitForLayout) { + waitForLayout() + } + } + + override fun onResourceReady(resource: R, transition: Transition?) { + if (!target.onResourceReady(resource)) { + super.onResourceReady(resource, transition) + } + } + + override fun setResource(resource: R?) { + target.setResource(resource) + } + } + } + + abstract class ImageViewTarget(val view: ImageView) { + internal var waitForLayout: Boolean = false + + fun waitForLayout(): ImageViewTarget { + waitForLayout = true + return this + } + + fun setResource(resource: R?) { + + } + + fun onResourceReady(resource: R?): Boolean { + return false + } + + } + + +} diff --git a/app/src/main/java/io/legado/app/help/IntentDataHelp.kt b/app/src/main/java/io/legado/app/help/IntentDataHelp.kt new file mode 100644 index 000000000..e7484949e --- /dev/null +++ b/app/src/main/java/io/legado/app/help/IntentDataHelp.kt @@ -0,0 +1,18 @@ +package io.legado.app.help + +object IntentDataHelp { + + private val bigData: MutableMap = mutableMapOf() + + fun putData(data: Any, tag: String = ""): String { + val key = tag + System.currentTimeMillis() + bigData[key] = data + return key + } + + fun getData(key: String): Any? { + val data = bigData[key] + bigData.remove(key) + return data + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/IntentHelp.kt b/app/src/main/java/io/legado/app/help/IntentHelp.kt new file mode 100644 index 000000000..815867694 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/IntentHelp.kt @@ -0,0 +1,41 @@ +package io.legado.app.help + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import io.legado.app.R +import org.jetbrains.anko.toast + +object IntentHelp { + + + fun toTTSSetting(context: Context) { + //跳转到文字转语音设置界面 + try { + val intent = Intent() + intent.action = "com.android.settings.TTS_SETTINGS" + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(intent) + } catch (ignored: Exception) { + context.toast(R.string.tip_cannot_jump_setting_page) + } + } + + inline fun servicePendingIntent(context: Context, action: String): PendingIntent? { + return PendingIntent.getService( + context, + 0, + Intent(context, T::class.java).apply { this.action = action }, + PendingIntent.FLAG_UPDATE_CURRENT + ) + } + + inline fun activityPendingIntent(context: Context, action: String): PendingIntent? { + return PendingIntent.getActivity( + context, + 0, + Intent(context, T::class.java).apply { this.action = action }, + PendingIntent.FLAG_UPDATE_CURRENT + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/ItemTouchCallback.kt b/app/src/main/java/io/legado/app/help/ItemTouchCallback.kt new file mode 100644 index 000000000..9cf1fccf5 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/ItemTouchCallback.kt @@ -0,0 +1,127 @@ +package io.legado.app.help + + +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import androidx.viewpager.widget.ViewPager + +/** + * Created by GKF on 2018/3/16. + */ + +class ItemTouchCallback : ItemTouchHelper.Callback() { + + private var swipeRefreshLayout: SwipeRefreshLayout? = null + private var viewPager: ViewPager? = null + + /** + * Item操作的回调 + */ + var onItemTouchCallbackListener: OnItemTouchCallbackListener? = null + + /** + * 是否可以拖拽 + */ + var isCanDrag = false + /** + * 是否可以被滑动 + */ + var isCanSwipe = false + + /** + * 当Item被长按的时候是否可以被拖拽 + */ + override fun isLongPressDragEnabled(): Boolean { + return isCanDrag + } + + /** + * Item是否可以被滑动(H:左右滑动,V:上下滑动) + */ + override fun isItemViewSwipeEnabled(): Boolean { + return isCanSwipe + } + + /** + * 当用户拖拽或者滑动Item的时候需要我们告诉系统滑动或者拖拽的方向 + */ + override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { + val layoutManager = recyclerView.layoutManager + if (layoutManager is GridLayoutManager) {// GridLayoutManager + // flag如果值是0,相当于这个功能被关闭 + val dragFlag = ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT or ItemTouchHelper.UP or ItemTouchHelper.DOWN + val swipeFlag = 0 + // create make + return makeMovementFlags(dragFlag, swipeFlag) + } else if (layoutManager is LinearLayoutManager) {// linearLayoutManager + val linearLayoutManager = layoutManager as LinearLayoutManager? + val orientation = linearLayoutManager!!.orientation + + var dragFlag = 0 + var swipeFlag = 0 + + // 为了方便理解,相当于分为横着的ListView和竖着的ListView + if (orientation == LinearLayoutManager.HORIZONTAL) {// 如果是横向的布局 + swipeFlag = ItemTouchHelper.UP or ItemTouchHelper.DOWN + dragFlag = ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT + } else if (orientation == LinearLayoutManager.VERTICAL) {// 如果是竖向的布局,相当于ListView + dragFlag = ItemTouchHelper.UP or ItemTouchHelper.DOWN + swipeFlag = ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT + } + return makeMovementFlags(dragFlag, swipeFlag) + } + return 0 + } + + /** + * 当Item被拖拽的时候被回调 + * + * @param recyclerView recyclerView + * @param srcViewHolder 拖拽的ViewHolder + * @param targetViewHolder 目的地的viewHolder + */ + override fun onMove( + recyclerView: RecyclerView, + srcViewHolder: RecyclerView.ViewHolder, + targetViewHolder: RecyclerView.ViewHolder + ): Boolean { + onItemTouchCallbackListener?.let { + return it.onMove(srcViewHolder.adapterPosition, targetViewHolder.adapterPosition) + } + return false + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + onItemTouchCallbackListener?.let { + return it.onSwiped(viewHolder.adapterPosition) + } + } + + override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { + super.onSelectedChanged(viewHolder, actionState) + val swiping = actionState == ItemTouchHelper.ACTION_STATE_DRAG + swipeRefreshLayout?.isEnabled = !swiping + viewPager?.requestDisallowInterceptTouchEvent(swiping) + } + + interface OnItemTouchCallbackListener { + /** + * 当某个Item被滑动删除的时候 + * + * @param adapterPosition item的position + */ + fun onSwiped(adapterPosition: Int) + + /** + * 当两个Item位置互换的时候被回调 + * + * @param srcPosition 拖拽的item的position + * @param targetPosition 目的地的Item的position + * @return 开发者处理了操作应该返回true,开发者没有处理就返回false + */ + fun onMove(srcPosition: Int, targetPosition: Int): Boolean + } +} diff --git a/app/src/main/java/io/legado/app/help/JsExtensions.kt b/app/src/main/java/io/legado/app/help/JsExtensions.kt new file mode 100644 index 000000000..d321f57fb --- /dev/null +++ b/app/src/main/java/io/legado/app/help/JsExtensions.kt @@ -0,0 +1,44 @@ +package io.legado.app.help + +import io.legado.app.model.analyzeRule.AnalyzeUrl +import io.legado.app.utils.EncoderUtils +import io.legado.app.utils.MD5Utils + + +@Suppress("unused") +object JsExtensions { + + /** + * js实现跨域访问,不能删 + */ + fun ajax(urlStr: String): String? { + return try { + val analyzeUrl = AnalyzeUrl(urlStr, null, null, null, null, null) + val call = analyzeUrl.getResponse() + val response = call.execute() + response.body() + } catch (e: Exception) { + e.localizedMessage + } + } + + /** + * js实现解码,不能删 + */ + fun base64Decode(str: String): String { + return EncoderUtils.base64Decode(str) + } + + fun base64Encode(str: String): String? { + return EncoderUtils.base64Encode(str) + } + + fun strToMd5By32(str: String): String? { + return MD5Utils.strToMd5By32(str) + } + + fun strToMd5By16(str: String): String? { + return MD5Utils.strToMd5By16(str) + } + +} diff --git a/app/src/main/java/io/legado/app/help/MediaHelp.kt b/app/src/main/java/io/legado/app/help/MediaHelp.kt new file mode 100644 index 000000000..65c15410b --- /dev/null +++ b/app/src/main/java/io/legado/app/help/MediaHelp.kt @@ -0,0 +1,57 @@ +package io.legado.app.help + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.media.MediaPlayer +import android.os.Build +import android.support.v4.media.session.PlaybackStateCompat +import androidx.annotation.RequiresApi +import io.legado.app.R + +object MediaHelp { + const val MEDIA_SESSION_ACTIONS = (PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS + or PlaybackStateCompat.ACTION_REWIND + or PlaybackStateCompat.ACTION_PLAY + or PlaybackStateCompat.ACTION_PLAY_PAUSE + or PlaybackStateCompat.ACTION_PAUSE + or PlaybackStateCompat.ACTION_STOP + or PlaybackStateCompat.ACTION_FAST_FORWARD + or PlaybackStateCompat.ACTION_SKIP_TO_NEXT + or PlaybackStateCompat.ACTION_SEEK_TO + or PlaybackStateCompat.ACTION_SET_RATING + or PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID + or PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH + or PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM + or PlaybackStateCompat.ACTION_PLAY_FROM_URI + or PlaybackStateCompat.ACTION_PREPARE + or PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID + or PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH + or PlaybackStateCompat.ACTION_PREPARE_FROM_URI + or PlaybackStateCompat.ACTION_SET_REPEAT_MODE + or PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE + or PlaybackStateCompat.ACTION_SET_CAPTIONING_ENABLED) + + @RequiresApi(Build.VERSION_CODES.O) + fun getFocusRequest(audioFocusChangeListener: AudioManager.OnAudioFocusChangeListener): AudioFocusRequest { + val mPlaybackAttributes = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build() + return AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes(mPlaybackAttributes) + .setAcceptsDelayedFocusGain(true) + .setOnAudioFocusChangeListener(audioFocusChangeListener) + .build() + } + + fun playSilentSound(mContext: Context) { + kotlin.runCatching { + // Stupid Android 8 "Oreo" hack to make media buttons work + val mMediaPlayer = MediaPlayer.create(mContext, R.raw.silent_sound) + mMediaPlayer.setOnCompletionListener { mMediaPlayer.release() } + mMediaPlayer.start() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/ReadAloud.kt b/app/src/main/java/io/legado/app/help/ReadAloud.kt new file mode 100644 index 000000000..9dd52c9a4 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/ReadAloud.kt @@ -0,0 +1,98 @@ +package io.legado.app.help + +import android.content.Context +import android.content.Intent +import io.legado.app.App +import io.legado.app.constant.Action +import io.legado.app.service.BaseReadAloudService +import io.legado.app.service.HttpReadAloudService +import io.legado.app.service.TTSReadAloudService +import io.legado.app.utils.getPrefBoolean + +object ReadAloud { + var aloudClass: Class<*> = getReadAloudClass() + + fun getReadAloudClass(): Class<*> { + return if (App.INSTANCE.getPrefBoolean("readAloudOnLine")) { + HttpReadAloudService::class.java + } else { + TTSReadAloudService::class.java + } + } + + fun play( + context: Context, + title: String, + subtitle: String, + pageIndex: Int, + dataKey: String, + play: Boolean = true + ) { + val readAloudIntent = Intent(context, aloudClass) + readAloudIntent.action = Action.play + readAloudIntent.putExtra("title", title) + readAloudIntent.putExtra("subtitle", subtitle) + readAloudIntent.putExtra("pageIndex", pageIndex) + readAloudIntent.putExtra("dataKey", dataKey) + readAloudIntent.putExtra("play", play) + context.startService(readAloudIntent) + } + + fun pause(context: Context) { + if (BaseReadAloudService.isRun) { + val intent = Intent(context, aloudClass) + intent.action = Action.pause + context.startService(intent) + } + } + + fun resume(context: Context) { + if (BaseReadAloudService.isRun) { + val intent = Intent(context, aloudClass) + intent.action = Action.resume + context.startService(intent) + } + } + + fun stop(context: Context) { + if (BaseReadAloudService.isRun) { + val intent = Intent(context, aloudClass) + intent.action = Action.stop + context.startService(intent) + } + } + + fun prevParagraph(context: Context) { + if (BaseReadAloudService.isRun) { + val intent = Intent(context, aloudClass) + intent.action = Action.prevParagraph + context.startService(intent) + } + } + + fun nextParagraph(context: Context) { + if (BaseReadAloudService.isRun) { + val intent = Intent(context, aloudClass) + intent.action = Action.nextParagraph + context.startService(intent) + } + } + + fun upTtsSpeechRate(context: Context) { + if (BaseReadAloudService.isRun) { + val intent = Intent(context, aloudClass) + intent.action = Action.upTtsSpeechRate + context.startService(intent) + } + } + + fun setTimer(context: Context, minute: Int) { + if (BaseReadAloudService.isRun) { + val intent = Intent(context, aloudClass) + intent.action = Action.setTimer + intent.putExtra("minute", minute) + context.startService(intent) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/ReadBookConfig.kt b/app/src/main/java/io/legado/app/help/ReadBookConfig.kt new file mode 100644 index 000000000..328433c3c --- /dev/null +++ b/app/src/main/java/io/legado/app/help/ReadBookConfig.kt @@ -0,0 +1,190 @@ +package io.legado.app.help + +import android.graphics.Color +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import io.legado.app.App +import io.legado.app.R +import io.legado.app.utils.* +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import java.io.BufferedWriter +import java.io.File +import java.io.FileWriter +import java.io.IOException + +/** + * 阅读界面配置 + */ +object ReadBookConfig { + const val readConfigFileName = "readConfig.json" + private val configFilePath = + App.INSTANCE.filesDir.absolutePath + File.separator + readConfigFileName + val configList: ArrayList = arrayListOf() + + var styleSelect + get() = App.INSTANCE.getPrefInt("readStyleSelect") + set(value) = App.INSTANCE.putPrefInt("readStyleSelect", value) + var bg: Drawable? = null + + init { + upConfig() + } + + @Synchronized + fun getConfig(index: Int = styleSelect): Config { + if (configList.size < 5) { + reset() + } + return configList[index] + } + + fun upConfig() { + val configFile = File(configFilePath) + val json = if (configFile.exists()) { + configFile.readText() + } else { + String(App.INSTANCE.assets.open(readConfigFileName).readBytes()) + } + try { + GSON.fromJsonArray(json)?.let { + configList.clear() + configList.addAll(it) + } ?: reset() + } catch (e: Exception) { + reset() + } + } + + fun upBg() { + val resources = App.INSTANCE.resources + val dm = resources.displayMetrics + val width = dm.widthPixels + val height = dm.heightPixels + bg = getConfig().bgDrawable(width, height) + } + + fun save() { + GlobalScope.launch(IO) { + val json = GSON.toJson(configList) + val configFile = File(configFilePath) + //获取流并存储 + try { + BufferedWriter(FileWriter(configFile)).use { writer -> + writer.write(json) + writer.flush() + } + } catch (e: IOException) { + e.printStackTrace() + } + } + } + + private fun reset() { + val json = String(App.INSTANCE.assets.open(readConfigFileName).readBytes()) + GSON.fromJsonArray(json)?.let { + configList.clear() + configList.addAll(it) + } + save() + } + + data class Config( + var bgStr: String = "#EEEEEE", + var bgStrNight: String = "#000000", + var bgType: Int = 0, + var bgTypeNight: Int = 0, + var darkStatusIcon: Boolean = true, + var darkStatusIconNight: Boolean = false, + var letterSpacing: Float = 1f, + var lineSpacingExtra: Int = 12, + var lineSpacingMultiplier: Float = 1.2f, + var paddingBottom: Int = 0, + var paddingLeft: Int = 16, + var paddingRight: Int = 16, + var paddingTop: Int = 0, + var textBold: Boolean = false, + var textColor: String = "#3E3D3B", + var textColorNight: String = "#adadad", + var textSize: Int = 15 + ) { + fun setBg(bgType: Int, bg: String) { + if (App.INSTANCE.isNightTheme) { + bgTypeNight = bgType + bgStrNight = bg + } else { + this.bgType = bgType + bgStr = bg + } + } + + fun setTextColor(color: Int) { + if (App.INSTANCE.isNightTheme) { + textColorNight = "#${color.hexString}" + } else { + textColor = "#${color.hexString}" + } + } + + fun setStatusIconDark(isDark: Boolean) { + if (App.INSTANCE.isNightTheme) { + darkStatusIconNight = isDark + } else { + darkStatusIcon = isDark + } + } + + fun statusIconDark(): Boolean { + return if (App.INSTANCE.isNightTheme) { + darkStatusIconNight + } else { + darkStatusIcon + } + } + + fun textColor(): Int { + return if (App.INSTANCE.isNightTheme) Color.parseColor(textColorNight) + else Color.parseColor(textColor) + } + + fun bgStr(): String { + return if (App.INSTANCE.isNightTheme) bgStrNight + else bgStr + } + + fun bgType(): Int { + return if (App.INSTANCE.isNightTheme) bgTypeNight + else bgType + } + + fun bgDrawable(width: Int, height: Int): Drawable { + var bgDrawable: Drawable? = null + val resources = App.INSTANCE.resources + try { + bgDrawable = when (bgType()) { + 0 -> ColorDrawable(Color.parseColor(bgStr())) + 1 -> { + BitmapDrawable( + resources, + BitmapUtils.decodeBitmap( + App.INSTANCE, + "bg" + File.separator + bgStr(), + width, + height + ) + ) + } + else -> BitmapDrawable( + resources, + BitmapUtils.decodeBitmap(bgStr(), width, height) + ) + } + } catch (e: Exception) { + e.printStackTrace() + } + return bgDrawable ?: ColorDrawable(App.INSTANCE.getCompatColor(R.color.background)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/coroutine/CompositeCoroutine.kt b/app/src/main/java/io/legado/app/help/coroutine/CompositeCoroutine.kt new file mode 100644 index 000000000..3f76acbe6 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/coroutine/CompositeCoroutine.kt @@ -0,0 +1,83 @@ +package io.legado.app.help.coroutine + +class CompositeCoroutine : CoroutineContainer { + + private var resources: HashSet>? = null + + val size: Int + get() = resources?.size ?: 0 + + val isEmpty: Boolean + get() = size == 0 + + constructor() + + constructor(vararg coroutines: Coroutine<*>) { + this.resources = hashSetOf(*coroutines) + } + + constructor(coroutines: Iterable>) { + this.resources = hashSetOf() + for (d in coroutines) { + this.resources?.add(d) + } + } + + override fun add(coroutine: Coroutine<*>): Boolean { + synchronized(this) { + var set: HashSet>? = resources + if (resources == null) { + set = hashSetOf() + resources = set + } + return set!!.add(coroutine) + } + } + + override fun addAll(vararg coroutines: Coroutine<*>): Boolean { + synchronized(this) { + var set: HashSet>? = resources + if (resources == null) { + set = hashSetOf() + resources = set + } + for (coroutine in coroutines) { + val add = set!!.add(coroutine) + if (!add) { + return false + } + } + } + return true + } + + override fun remove(coroutine: Coroutine<*>): Boolean { + if (delete(coroutine)) { + coroutine.cancel() + return true + } + return false + } + + override fun delete(coroutine: Coroutine<*>): Boolean { + synchronized(this) { + val set = resources + if (set == null || !set.remove(coroutine)) { + return false + } + } + return true + } + + override fun clear() { + val set: HashSet>? + synchronized(this) { + set = resources + resources = null + } + + set?.forEachIndexed { index, coroutine -> + coroutine.cancel() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/coroutine/Coroutine.kt b/app/src/main/java/io/legado/app/help/coroutine/Coroutine.kt new file mode 100644 index 000000000..e9fd0df86 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/coroutine/Coroutine.kt @@ -0,0 +1,188 @@ +package io.legado.app.help.coroutine + +import io.legado.app.BuildConfig +import kotlinx.coroutines.* +import kotlin.coroutines.CoroutineContext + + +class Coroutine( + scope: CoroutineScope, + context: CoroutineContext = Dispatchers.IO, + block: suspend CoroutineScope.() -> T +) { + + companion object { + + val DEFAULT = MainScope() + + fun async( + scope: CoroutineScope = DEFAULT, + context: CoroutineContext = Dispatchers.IO, + block: suspend CoroutineScope.() -> T + ): Coroutine { + return Coroutine(scope, context, block) + } + + } + + private val job: Job + + private var start: VoidCallback? = null + private var success: Callback? = null + private var error: Callback? = null + private var finally: VoidCallback? = null + + private var timeMillis: Long? = null + private var errorReturn: Result? = null + + val isCancelled: Boolean + get() = job.isCancelled + + val isActive: Boolean + get() = job.isActive + + val isCompleted: Boolean + get() = job.isCompleted + + init { + this.job = executeInternal(scope, context, block) + } + + fun timeout(timeMillis: () -> Long): Coroutine { + this.timeMillis = timeMillis() + return this@Coroutine + } + + fun timeout(timeMillis: Long): Coroutine { + this.timeMillis = timeMillis + return this@Coroutine + } + + fun onErrorReturn(value: () -> T?): Coroutine { + this.errorReturn = Result(value()) + return this@Coroutine + } + + fun onErrorReturn(value: T?): Coroutine { + this.errorReturn = Result(value) + return this@Coroutine + } + + fun onStart( + context: CoroutineContext? = null, + block: (suspend CoroutineScope.() -> Unit) + ): Coroutine { + this.start = VoidCallback(context, block) + return this@Coroutine + } + + fun onSuccess( + context: CoroutineContext? = null, + block: suspend CoroutineScope.(T?) -> Unit + ): Coroutine { + this.success = Callback(context, block) + return this@Coroutine + } + + fun onError( + context: CoroutineContext? = null, + block: suspend CoroutineScope.(Throwable) -> Unit + ): Coroutine { + this.error = Callback(context, block) + return this@Coroutine + } + + fun onFinally( + context: CoroutineContext? = null, + block: suspend CoroutineScope.() -> Unit + ): Coroutine { + this.finally = VoidCallback(context, block) + return this@Coroutine + } + + //取消当前任务 + fun cancel(cause: CancellationException? = null) { + job.cancel(cause) + } + + fun invokeOnCompletion(handler: CompletionHandler): DisposableHandle { + return job.invokeOnCompletion(handler) + } + + private fun executeInternal( + scope: CoroutineScope, + context: CoroutineContext, + block: suspend CoroutineScope.() -> T + ): Job { + return scope.plus(Dispatchers.Main).launch { + try { + start?.let { dispatchVoidCallback(this, it) } + val value = executeBlock(scope, context, timeMillis ?: 0L, block) + success?.let { dispatchCallback(this, value, it) } + } catch (e: Throwable) { + if (BuildConfig.DEBUG) { + e.printStackTrace() + } + val consume: Boolean = errorReturn?.value?.let { value -> + success?.let { dispatchCallback(this, value, it) } + true + } ?: false + + if (!consume) { + error?.let { dispatchCallback(this, e, it) } + } + } finally { + finally?.let { dispatchVoidCallback(this, it) } + } + } + } + + private suspend inline fun dispatchVoidCallback(scope: CoroutineScope, callback: VoidCallback) { + if (null == callback.context) { + callback.block.invoke(scope) + } else { + withContext(scope.coroutineContext.plus(callback.context)) { + callback.block.invoke(this) + } + } + } + + private suspend inline fun dispatchCallback( + scope: CoroutineScope, + value: R, + callback: Callback + ) { + if (null == callback.context) { + callback.block.invoke(scope, value) + } else { + withContext(scope.coroutineContext.plus(callback.context)) { + callback.block.invoke(this, value) + } + } + } + + private suspend inline fun executeBlock( + scope: CoroutineScope, + context: CoroutineContext, + timeMillis: Long, + noinline block: suspend CoroutineScope.() -> T + ): T? { + return withContext(scope.coroutineContext.plus(context)) { + if (timeMillis > 0L) withTimeout(timeMillis) { + block() + } else block() + } + } + + private data class Result(val value: T?) + + private inner class VoidCallback( + val context: CoroutineContext?, + val block: suspend CoroutineScope.() -> Unit + ) + + private inner class Callback( + val context: CoroutineContext?, + val block: suspend CoroutineScope.(VALUE) -> Unit + ) +} diff --git a/app/src/main/java/io/legado/app/help/coroutine/CoroutineContainer.kt b/app/src/main/java/io/legado/app/help/coroutine/CoroutineContainer.kt new file mode 100644 index 000000000..8ef02af1f --- /dev/null +++ b/app/src/main/java/io/legado/app/help/coroutine/CoroutineContainer.kt @@ -0,0 +1,15 @@ +package io.legado.app.help.coroutine + +internal interface CoroutineContainer { + + fun add(coroutine: Coroutine<*>): Boolean + + fun addAll(vararg coroutines: Coroutine<*>): Boolean + + fun remove(coroutine: Coroutine<*>): Boolean + + fun delete(coroutine: Coroutine<*>): Boolean + + fun clear() + +} diff --git a/app/src/main/java/io/legado/app/help/http/ByteConverter.kt b/app/src/main/java/io/legado/app/help/http/ByteConverter.kt new file mode 100644 index 000000000..0f1682274 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/http/ByteConverter.kt @@ -0,0 +1,20 @@ +package io.legado.app.help.http + +import okhttp3.ResponseBody +import retrofit2.Converter +import retrofit2.Retrofit +import java.lang.reflect.Type + +class ByteConverter() : Converter.Factory() { + + override fun responseBodyConverter( + type: Type?, + annotations: Array?, + retrofit: Retrofit? + ): Converter? { + return Converter { value -> + value.bytes() + } + } + +} diff --git a/app/src/main/java/io/legado/app/help/http/CoroutinesCallAdapterFactory.kt b/app/src/main/java/io/legado/app/help/http/CoroutinesCallAdapterFactory.kt index bf296f5bb..5489275ea 100644 --- a/app/src/main/java/io/legado/app/help/http/CoroutinesCallAdapterFactory.kt +++ b/app/src/main/java/io/legado/app/help/http/CoroutinesCallAdapterFactory.kt @@ -8,8 +8,9 @@ import java.lang.reflect.Type class CoroutinesCallAdapterFactory private constructor() : CallAdapter.Factory() { companion object { - @JvmStatic @JvmName("create") - operator fun invoke() = CoroutinesCallAdapterFactory() + fun create(): CoroutinesCallAdapterFactory { + return CoroutinesCallAdapterFactory() + } } override fun get( @@ -22,7 +23,8 @@ class CoroutinesCallAdapterFactory private constructor() : CallAdapter.Factory() } if (returnType !is ParameterizedType) { throw IllegalStateException( - "Deferred return type must be parameterized as Deferred or Deferred") + "Deferred return type must be parameterized as Deferred or Deferred" + ) } val responseType = getParameterUpperBound(0, returnType) @@ -30,7 +32,8 @@ class CoroutinesCallAdapterFactory private constructor() : CallAdapter.Factory() return if (rawDeferredType == Response::class.java) { if (responseType !is ParameterizedType) { throw IllegalStateException( - "Response must be parameterized as Response or Response") + "Response must be parameterized as Response or Response" + ) } ResponseCallAdapter( getParameterUpperBound( @@ -44,7 +47,7 @@ class CoroutinesCallAdapterFactory private constructor() : CallAdapter.Factory() } private class BodyCallAdapter( - private val responseType: Type + private val responseType: Type ) : CallAdapter> { override fun responseType() = responseType @@ -77,7 +80,7 @@ class CoroutinesCallAdapterFactory private constructor() : CallAdapter.Factory() } private class ResponseCallAdapter( - private val responseType: Type + private val responseType: Type ) : CallAdapter>> { override fun responseType() = responseType diff --git a/app/src/main/java/io/legado/app/help/http/EncodeConverter.kt b/app/src/main/java/io/legado/app/help/http/EncodeConverter.kt new file mode 100644 index 000000000..b17f50ef8 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/http/EncodeConverter.kt @@ -0,0 +1,36 @@ +package io.legado.app.help.http + +import okhttp3.ResponseBody +import retrofit2.Converter +import retrofit2.Retrofit +import java.lang.reflect.Type +import java.nio.charset.Charset + +class EncodeConverter(private val encode: String? = null) : Converter.Factory() { + + override fun responseBodyConverter( + type: Type?, + annotations: Array?, + retrofit: Retrofit? + ): Converter? { + return Converter { value -> + val responseBytes = value.bytes() + encode?.let { return@Converter String(responseBytes, Charset.forName(encode)) } + + var charsetName: String? = null + val mediaType = value.contentType() + //根据http头判断 + if (mediaType != null) { + val charset = mediaType.charset() + charsetName = charset?.displayName() + } + + if (charsetName == null) { + charsetName = EncodingDetect.getHtmlEncode(responseBytes) + } + + String(responseBytes, Charset.forName(charsetName)) + } + } + +} diff --git a/app/src/main/java/io/legado/app/help/http/EncodingDetect.java b/app/src/main/java/io/legado/app/help/http/EncodingDetect.java new file mode 100644 index 000000000..2659e0cb4 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/http/EncodingDetect.java @@ -0,0 +1,4914 @@ +package io.legado.app.help.http; + +import androidx.annotation.NonNull; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +import static android.text.TextUtils.isEmpty; + +/** + * Copyright (C) <2009> + *

+ * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation, either version 3 of the License, or (at your option) any later + * version. + *

+ * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + *

+ * EncodingDetect.java
+ * 自动获取文件的编码 + * + * @author Billows.Van + * @version 1.0 + * @since Create on 2010-01-27 11:19:00 + */ +public class EncodingDetect { + + public static String getHtmlEncode(@NonNull byte[] bytes) { + try { + Document doc = Jsoup.parse(new String(bytes, StandardCharsets.UTF_8)); + Elements metaTags = doc.getElementsByTag("meta"); + String charsetStr; + for (Element metaTag : metaTags) { + charsetStr = metaTag.attr("charset"); + if (!isEmpty(charsetStr)) { + return charsetStr; + } + String content = metaTag.attr("content"); + String http_equiv = metaTag.attr("http-equiv"); + if (http_equiv.toLowerCase().equals("content-type")) { + if (content.toLowerCase().contains("charset")) { + charsetStr = content.substring(content.toLowerCase().indexOf("charset") + "charset=".length()); + } else { + charsetStr = content.substring(content.toLowerCase().indexOf(";") + 1); + } + if (!isEmpty(charsetStr)) { + return charsetStr; + } + } + } + } catch (Exception ignored) { + } + return getJavaEncode(bytes); + } + + public static String getJavaEncode(@NonNull byte[] bytes) { + int len = bytes.length > 2000 ? 2000 : bytes.length; + byte[] cBytes = new byte[len]; + System.arraycopy(bytes, 0, cBytes, 0, len); + BytesEncodingDetect bytesEncodingDetect = new BytesEncodingDetect(); + String code = BytesEncodingDetect.javaname[bytesEncodingDetect.detectEncoding(cBytes)]; + // UTF-16LE 特殊处理 + if ("Unicode".equals(code)) { + if (cBytes[0] == -1) { + code = "UTF-16LE"; + } + } + return code; + } + + /** + * 得到文件的编码 + */ + public static String getJavaEncode(@NonNull String filePath) { + BytesEncodingDetect s = new BytesEncodingDetect(); + String fileCode = BytesEncodingDetect.javaname[s + .detectEncoding(new File(filePath))]; + + // UTF-16LE 特殊处理 + if ("Unicode".equals(fileCode)) { + byte[] tempByte = BytesEncodingDetect.getFileBytes(new File( + filePath)); + if (tempByte[0] == -1) { + fileCode = "UTF-16LE"; + } + } + return fileCode; + } + + /** + * 得到文件的编码 + */ + public static String getJavaEncode(@NonNull File file) { + BytesEncodingDetect s = new BytesEncodingDetect(); + String fileCode = BytesEncodingDetect.javaname[s.detectEncoding(file)]; + // UTF-16LE 特殊处理 + if ("Unicode".equals(fileCode)) { + byte[] tempByte = BytesEncodingDetect.getFileBytes(file); + if (tempByte[0] == -1) { + fileCode = "UTF-16LE"; + } + } + return fileCode; + } + +} + +class BytesEncodingDetect extends Encoding { + // Frequency tables to hold the GB, Big5, and EUC-TW character + // frequencies + int GBFreq[][]; + + int GBKFreq[][]; + + int Big5Freq[][]; + + int Big5PFreq[][]; + + int EUC_TWFreq[][]; + + int KRFreq[][]; + + int JPFreq[][]; + + // int UnicodeFreq[94][128]; + // public static String[] nicename; + // public static String[] codings; + public boolean debug; + + public BytesEncodingDetect() { + super(); + debug = false; + GBFreq = new int[94][94]; + GBKFreq = new int[126][191]; + Big5Freq = new int[94][158]; + Big5PFreq = new int[126][191]; + EUC_TWFreq = new int[94][94]; + KRFreq = new int[94][94]; + JPFreq = new int[94][94]; + // Initialize the Frequency Table for GB, GBK, Big5, EUC-TW, KR, JP + initialize_frequencies(); + } + + /** + * Function : detectEncoding Aruguments: URL Returns : One of the encodings + * from the Encoding enumeration (GB2312, HZ, BIG5, EUC_TW, ASCII, or OTHER) + * Description: This function looks at the URL contents and assigns it a + * probability score for each encoding type. The encoding type with the + * highest probability is returned. + */ + public int detectEncoding(URL testurl) { + byte[] rawtext = new byte[10000]; + int bytesread = 0, byteoffset = 0; + int guess = OTHER; + InputStream chinesestream; + try { + chinesestream = testurl.openStream(); + while ((bytesread = chinesestream.read(rawtext, byteoffset, + rawtext.length - byteoffset)) > 0) { + byteoffset += bytesread; + } + ; + chinesestream.close(); + guess = detectEncoding(rawtext); + } catch (Exception e) { + System.err.println("Error loading or using URL " + e.toString()); + guess = -1; + } + return guess; + } + + /** + * Function : detectEncoding Aruguments: File Returns : One of the encodings + * from the Encoding enumeration (GB2312, HZ, BIG5, EUC_TW, ASCII, or OTHER) + * Description: This function looks at the file and assigns it a probability + * score for each encoding type. The encoding type with the highest + * probability is returned. + */ + public int detectEncoding(File testfile) { + byte[] rawtext = getFileBytes(testfile); + return detectEncoding(rawtext); + } + + public static byte[] getFileBytes(File testfile) { + FileInputStream chinesefile; + byte[] rawtext; + rawtext = new byte[2000]; + try { + chinesefile = new FileInputStream(testfile); + chinesefile.read(rawtext); + chinesefile.close(); + } catch (Exception e) { + System.err.println("Error: " + e); + } + return rawtext; + } + + + /** + * Function : detectEncoding Aruguments: byte array Returns : One of the + * encodings from the Encoding enumeration (GB2312, HZ, BIG5, EUC_TW, ASCII, + * or OTHER) Description: This function looks at the byte array and assigns + * it a probability score for each encoding type. The encoding type with the + * highest probability is returned. + */ + public int detectEncoding(byte[] rawtext) { + int[] scores; + int index, maxscore = 0; + int encoding_guess = OTHER; + scores = new int[TOTALTYPES]; + // Assign Scores + scores[GB2312] = gb2312_probability(rawtext); + scores[GBK] = gbk_probability(rawtext); + scores[GB18030] = gb18030_probability(rawtext); + scores[HZ] = hz_probability(rawtext); + scores[BIG5] = big5_probability(rawtext); + scores[CNS11643] = euc_tw_probability(rawtext); + scores[ISO2022CN] = iso_2022_cn_probability(rawtext); + scores[UTF8] = utf8_probability(rawtext); + scores[UNICODE] = utf16_probability(rawtext); + scores[EUC_KR] = euc_kr_probability(rawtext); + scores[CP949] = cp949_probability(rawtext); + scores[JOHAB] = 0; + scores[ISO2022KR] = iso_2022_kr_probability(rawtext); + scores[ASCII] = ascii_probability(rawtext); + scores[SJIS] = sjis_probability(rawtext); + scores[EUC_JP] = euc_jp_probability(rawtext); + scores[ISO2022JP] = iso_2022_jp_probability(rawtext); + scores[UNICODET] = 0; + scores[UNICODES] = 0; + scores[ISO2022CN_GB] = 0; + scores[ISO2022CN_CNS] = 0; + scores[OTHER] = 0; + // Tabulate Scores + for (index = 0; index < TOTALTYPES; index++) { + if (debug) + System.err.println("Encoding " + nicename[index] + " score " + + scores[index]); + if (scores[index] > maxscore) { + encoding_guess = index; + maxscore = scores[index]; + } + } + // Return OTHER if nothing scored above 50 + if (maxscore <= 50) { + encoding_guess = OTHER; + } + return encoding_guess; + } + + /* + * Function: gb2312_probability Argument: pointer to byte array Returns : + * number from 0 to 100 representing probability text in array uses GB-2312 + * encoding + */ + int gb2312_probability(byte[] rawtext) { + int i, rawtextlen = 0; + int dbchars = 1, gbchars = 1; + long gbfreq = 0, totalfreq = 1; + float rangeval = 0, freqval = 0; + int row, column; + // Stage 1: Check to see if characters fit into acceptable ranges + rawtextlen = rawtext.length; + for (i = 0; i < rawtextlen - 1; i++) { + // System.err.println(rawtext[i]); + if (rawtext[i] >= 0) { + // asciichars++; + } else { + dbchars++; + if ((byte) 0xA1 <= rawtext[i] && rawtext[i] <= (byte) 0xF7 + && (byte) 0xA1 <= rawtext[i + 1] + && rawtext[i + 1] <= (byte) 0xFE) { + gbchars++; + totalfreq += 500; + row = rawtext[i] + 256 - 0xA1; + column = rawtext[i + 1] + 256 - 0xA1; + if (GBFreq[row][column] != 0) { + gbfreq += GBFreq[row][column]; + } else if (15 <= row && row < 55) { + // In GB high-freq character range + gbfreq += 200; + } + } + i++; + } + } + rangeval = 50 * ((float) gbchars / (float) dbchars); + freqval = 50 * ((float) gbfreq / (float) totalfreq); + return (int) (rangeval + freqval); + } + + /* + * Function: gbk_probability Argument: pointer to byte array Returns : + * number from 0 to 100 representing probability text in array uses GBK + * encoding + */ + int gbk_probability(byte[] rawtext) { + int i, rawtextlen = 0; + int dbchars = 1, gbchars = 1; + long gbfreq = 0, totalfreq = 1; + float rangeval = 0, freqval = 0; + int row, column; + // Stage 1: Check to see if characters fit into acceptable ranges + rawtextlen = rawtext.length; + for (i = 0; i < rawtextlen - 1; i++) { + // System.err.println(rawtext[i]); + if (rawtext[i] >= 0) { + // asciichars++; + } else { + dbchars++; + if ((byte) 0xA1 <= rawtext[i] && rawtext[i] <= (byte) 0xF7 + && // Original GB range + (byte) 0xA1 <= rawtext[i + 1] + && rawtext[i + 1] <= (byte) 0xFE) { + gbchars++; + totalfreq += 500; + row = rawtext[i] + 256 - 0xA1; + column = rawtext[i + 1] + 256 - 0xA1; + // System.out.println("original row " + row + " column " + + // column); + if (GBFreq[row][column] != 0) { + gbfreq += GBFreq[row][column]; + } else if (15 <= row && row < 55) { + gbfreq += 200; + } + } else if ((byte) 0x81 <= rawtext[i] + && rawtext[i] <= (byte) 0xFE && // Extended GB range + (((byte) 0x80 <= rawtext[i + 1] && rawtext[i + 1] <= (byte) 0xFE) || ((byte) 0x40 <= rawtext[i + 1] && rawtext[i + 1] <= (byte) 0x7E))) { + gbchars++; + totalfreq += 500; + row = rawtext[i] + 256 - 0x81; + if (0x40 <= rawtext[i + 1] && rawtext[i + 1] <= 0x7E) { + column = rawtext[i + 1] - 0x40; + } else { + column = rawtext[i + 1] + 256 - 0x40; + } + // System.out.println("extended row " + row + " column " + + // column + " rawtext[i] " + rawtext[i]); + if (GBKFreq[row][column] != 0) { + gbfreq += GBKFreq[row][column]; + } + } + i++; + } + } + rangeval = 50 * ((float) gbchars / (float) dbchars); + freqval = 50 * ((float) gbfreq / (float) totalfreq); + // For regular GB files, this would give the same score, so I handicap + // it slightly + return (int) (rangeval + freqval) - 1; + } + + /* + * Function: gb18030_probability Argument: pointer to byte array Returns : + * number from 0 to 100 representing probability text in array uses GBK + * encoding + */ + int gb18030_probability(byte[] rawtext) { + int i, rawtextlen = 0; + int dbchars = 1, gbchars = 1; + long gbfreq = 0, totalfreq = 1; + float rangeval = 0, freqval = 0; + int row, column; + // Stage 1: Check to see if characters fit into acceptable ranges + rawtextlen = rawtext.length; + for (i = 0; i < rawtextlen - 1; i++) { + // System.err.println(rawtext[i]); + if (rawtext[i] >= 0) { + // asciichars++; + } else { + dbchars++; + if ((byte) 0xA1 <= rawtext[i] && rawtext[i] <= (byte) 0xF7 + && // Original GB range + i + 1 < rawtextlen && (byte) 0xA1 <= rawtext[i + 1] + && rawtext[i + 1] <= (byte) 0xFE) { + gbchars++; + totalfreq += 500; + row = rawtext[i] + 256 - 0xA1; + column = rawtext[i + 1] + 256 - 0xA1; + // System.out.println("original row " + row + " column " + + // column); + if (GBFreq[row][column] != 0) { + gbfreq += GBFreq[row][column]; + } else if (15 <= row && row < 55) { + gbfreq += 200; + } + } else if ((byte) 0x81 <= rawtext[i] + && rawtext[i] <= (byte) 0xFE + && // Extended GB range + i + 1 < rawtextlen + && (((byte) 0x80 <= rawtext[i + 1] && rawtext[i + 1] <= (byte) 0xFE) || ((byte) 0x40 <= rawtext[i + 1] && rawtext[i + 1] <= (byte) 0x7E))) { + gbchars++; + totalfreq += 500; + row = rawtext[i] + 256 - 0x81; + if (0x40 <= rawtext[i + 1] && rawtext[i + 1] <= 0x7E) { + column = rawtext[i + 1] - 0x40; + } else { + column = rawtext[i + 1] + 256 - 0x40; + } + // System.out.println("extended row " + row + " column " + + // column + " rawtext[i] " + rawtext[i]); + if (GBKFreq[row][column] != 0) { + gbfreq += GBKFreq[row][column]; + } + } else if ((byte) 0x81 <= rawtext[i] + && rawtext[i] <= (byte) 0xFE + && // Extended GB range + i + 3 < rawtextlen && (byte) 0x30 <= rawtext[i + 1] + && rawtext[i + 1] <= (byte) 0x39 + && (byte) 0x81 <= rawtext[i + 2] + && rawtext[i + 2] <= (byte) 0xFE + && (byte) 0x30 <= rawtext[i + 3] + && rawtext[i + 3] <= (byte) 0x39) { + gbchars++; + /* + * totalfreq += 500; row = rawtext[i] + 256 - 0x81; if (0x40 + * <= rawtext[i+1] && rawtext[i+1] <= 0x7E) { column = + * rawtext[i+1] - 0x40; } else { column = rawtext[i+1] + 256 + * - 0x40; } //System.out.println("extended row " + row + " + * column " + column + " rawtext[i] " + rawtext[i]); if + * (GBKFreq[row][column] != 0) { gbfreq += + * GBKFreq[row][column]; } + */ + } + i++; + } + } + rangeval = 50 * ((float) gbchars / (float) dbchars); + freqval = 50 * ((float) gbfreq / (float) totalfreq); + // For regular GB files, this would give the same score, so I handicap + // it slightly + return (int) (rangeval + freqval) - 1; + } + + /* + * Function: hz_probability Argument: byte array Returns : number from 0 to + * 100 representing probability text in array uses HZ encoding + */ + int hz_probability(byte[] rawtext) { + int i, rawtextlen; + int hzchars = 0, dbchars = 1; + long hzfreq = 0, totalfreq = 1; + float rangeval = 0, freqval = 0; + int hzstart = 0, hzend = 0; + int row, column; + rawtextlen = rawtext.length; + for (i = 0; i < rawtextlen; i++) { + if (rawtext[i] == '~') { + if (rawtext[i + 1] == '{') { + hzstart++; + i += 2; + while (i < rawtextlen - 1) { + if (rawtext[i] == 0x0A || rawtext[i] == 0x0D) { + break; + } else if (rawtext[i] == '~' && rawtext[i + 1] == '}') { + hzend++; + i++; + break; + } else if ((0x21 <= rawtext[i] && rawtext[i] <= 0x77) + && (0x21 <= rawtext[i + 1] && rawtext[i + 1] <= 0x77)) { + hzchars += 2; + row = rawtext[i] - 0x21; + column = rawtext[i + 1] - 0x21; + totalfreq += 500; + if (GBFreq[row][column] != 0) { + hzfreq += GBFreq[row][column]; + } else if (15 <= row && row < 55) { + hzfreq += 200; + } + } else if ((0xA1 <= rawtext[i] && rawtext[i] <= 0xF7) + && (0xA1 <= rawtext[i + 1] && rawtext[i + 1] <= 0xF7)) { + hzchars += 2; + row = rawtext[i] + 256 - 0xA1; + column = rawtext[i + 1] + 256 - 0xA1; + totalfreq += 500; + if (GBFreq[row][column] != 0) { + hzfreq += GBFreq[row][column]; + } else if (15 <= row && row < 55) { + hzfreq += 200; + } + } + dbchars += 2; + i += 2; + } + } else if (rawtext[i + 1] == '}') { + hzend++; + i++; + } else if (rawtext[i + 1] == '~') { + i++; + } + } + } + if (hzstart > 4) { + rangeval = 50; + } else if (hzstart > 1) { + rangeval = 41; + } else if (hzstart > 0) { // Only 39 in case the sequence happened to + // occur + rangeval = 39; // in otherwise non-Hz text + } else { + rangeval = 0; + } + freqval = 50 * ((float) hzfreq / (float) totalfreq); + return (int) (rangeval + freqval); + } + + /** + * Function: big5_probability Argument: byte array Returns : number from 0 + * to 100 representing probability text in array uses Big5 encoding + */ + int big5_probability(byte[] rawtext) { + int i, rawtextlen = 0; + int dbchars = 1, bfchars = 1; + float rangeval = 0, freqval = 0; + long bffreq = 0, totalfreq = 1; + int row, column; + // Check to see if characters fit into acceptable ranges + rawtextlen = rawtext.length; + for (i = 0; i < rawtextlen - 1; i++) { + if (rawtext[i] >= 0) { + // asciichars++; + } else { + dbchars++; + if ((byte) 0xA1 <= rawtext[i] + && rawtext[i] <= (byte) 0xF9 + && (((byte) 0x40 <= rawtext[i + 1] && rawtext[i + 1] <= (byte) 0x7E) || ((byte) 0xA1 <= rawtext[i + 1] && rawtext[i + 1] <= (byte) 0xFE))) { + bfchars++; + totalfreq += 500; + row = rawtext[i] + 256 - 0xA1; + if (0x40 <= rawtext[i + 1] && rawtext[i + 1] <= 0x7E) { + column = rawtext[i + 1] - 0x40; + } else { + column = rawtext[i + 1] + 256 - 0x61; + } + if (Big5Freq[row][column] != 0) { + bffreq += Big5Freq[row][column]; + } else if (3 <= row && row <= 37) { + bffreq += 200; + } + } + i++; + } + } + rangeval = 50 * ((float) bfchars / (float) dbchars); + freqval = 50 * ((float) bffreq / (float) totalfreq); + return (int) (rangeval + freqval); + } + + /* + * Function: big5plus_probability Argument: pointer to unsigned char array + * Returns : number from 0 to 100 representing probability text in array + * uses Big5+ encoding + */ + int big5plus_probability(byte[] rawtext) { + int i, rawtextlen = 0; + int dbchars = 1, bfchars = 1; + long bffreq = 0, totalfreq = 1; + float rangeval = 0, freqval = 0; + int row, column; + // Stage 1: Check to see if characters fit into acceptable ranges + rawtextlen = rawtext.length; + for (i = 0; i < rawtextlen - 1; i++) { + // System.err.println(rawtext[i]); + if (rawtext[i] >= 128) { + // asciichars++; + } else { + dbchars++; + if (0xA1 <= rawtext[i] + && rawtext[i] <= 0xF9 + && // Original Big5 range + ((0x40 <= rawtext[i + 1] && rawtext[i + 1] <= 0x7E) || (0xA1 <= rawtext[i + 1] && rawtext[i + 1] <= 0xFE))) { + bfchars++; + totalfreq += 500; + row = rawtext[i] - 0xA1; + if (0x40 <= rawtext[i + 1] && rawtext[i + 1] <= 0x7E) { + column = rawtext[i + 1] - 0x40; + } else { + column = rawtext[i + 1] - 0x61; + } + // System.out.println("original row " + row + " column " + + // column); + if (Big5Freq[row][column] != 0) { + bffreq += Big5Freq[row][column]; + } else if (3 <= row && row < 37) { + bffreq += 200; + } + } else if (0x81 <= rawtext[i] + && rawtext[i] <= 0xFE + && // Extended Big5 range + ((0x40 <= rawtext[i + 1] && rawtext[i + 1] <= 0x7E) || (0x80 <= rawtext[i + 1] && rawtext[i + 1] <= 0xFE))) { + bfchars++; + totalfreq += 500; + row = rawtext[i] - 0x81; + if (0x40 <= rawtext[i + 1] && rawtext[i + 1] <= 0x7E) { + column = rawtext[i + 1] - 0x40; + } else { + column = rawtext[i + 1] - 0x40; + } + // System.out.println("extended row " + row + " column " + + // column + " rawtext[i] " + rawtext[i]); + if (Big5PFreq[row][column] != 0) { + bffreq += Big5PFreq[row][column]; + } + } + i++; + } + } + rangeval = 50 * ((float) bfchars / (float) dbchars); + freqval = 50 * ((float) bffreq / (float) totalfreq); + // For regular Big5 files, this would give the same score, so I handicap + // it slightly + return (int) (rangeval + freqval) - 1; + } + + /* + * Function: euc_tw_probability Argument: byte array Returns : number from 0 + * to 100 representing probability text in array uses EUC-TW (CNS 11643) + * encoding + */ + int euc_tw_probability(byte[] rawtext) { + int i, rawtextlen = 0; + int dbchars = 1, cnschars = 1; + long cnsfreq = 0, totalfreq = 1; + float rangeval = 0, freqval = 0; + int row, column; + // Check to see if characters fit into acceptable ranges + // and have expected frequency of use + rawtextlen = rawtext.length; + for (i = 0; i < rawtextlen - 1; i++) { + if (rawtext[i] >= 0) { // in ASCII range + // asciichars++; + } else { // high bit set + dbchars++; + if (i + 3 < rawtextlen && (byte) 0x8E == rawtext[i] + && (byte) 0xA1 <= rawtext[i + 1] + && rawtext[i + 1] <= (byte) 0xB0 + && (byte) 0xA1 <= rawtext[i + 2] + && rawtext[i + 2] <= (byte) 0xFE + && (byte) 0xA1 <= rawtext[i + 3] + && rawtext[i + 3] <= (byte) 0xFE) { // Planes 1 - 16 + cnschars++; + // System.out.println("plane 2 or above CNS char"); + // These are all less frequent chars so just ignore freq + i += 3; + } else if ((byte) 0xA1 <= rawtext[i] + && rawtext[i] <= (byte) 0xFE + && // Plane 1 + (byte) 0xA1 <= rawtext[i + 1] + && rawtext[i + 1] <= (byte) 0xFE) { + cnschars++; + totalfreq += 500; + row = rawtext[i] + 256 - 0xA1; + column = rawtext[i + 1] + 256 - 0xA1; + if (EUC_TWFreq[row][column] != 0) { + cnsfreq += EUC_TWFreq[row][column]; + } else if (35 <= row && row <= 92) { + cnsfreq += 150; + } + i++; + } + } + } + rangeval = 50 * ((float) cnschars / (float) dbchars); + freqval = 50 * ((float) cnsfreq / (float) totalfreq); + return (int) (rangeval + freqval); + } + + /* + * Function: iso_2022_cn_probability Argument: byte array Returns : number + * from 0 to 100 representing probability text in array uses ISO 2022-CN + * encoding WORKS FOR BASIC CASES, BUT STILL NEEDS MORE WORK + */ + int iso_2022_cn_probability(byte[] rawtext) { + int i, rawtextlen = 0; + int dbchars = 1, isochars = 1; + long isofreq = 0, totalfreq = 1; + float rangeval = 0, freqval = 0; + int row, column; + // Check to see if characters fit into acceptable ranges + // and have expected frequency of use + rawtextlen = rawtext.length; + for (i = 0; i < rawtextlen - 1; i++) { + if (rawtext[i] == (byte) 0x1B && i + 3 < rawtextlen) { // Escape + // char ESC + if (rawtext[i + 1] == (byte) 0x24 && rawtext[i + 2] == 0x29 + && rawtext[i + 3] == (byte) 0x41) { // GB Escape $ ) A + i += 4; + while (rawtext[i] != (byte) 0x1B) { + dbchars++; + if ((0x21 <= rawtext[i] && rawtext[i] <= 0x77) + && (0x21 <= rawtext[i + 1] && rawtext[i + 1] <= 0x77)) { + isochars++; + row = rawtext[i] - 0x21; + column = rawtext[i + 1] - 0x21; + totalfreq += 500; + if (GBFreq[row][column] != 0) { + isofreq += GBFreq[row][column]; + } else if (15 <= row && row < 55) { + isofreq += 200; + } + i++; + } + i++; + } + } else if (i + 3 < rawtextlen && rawtext[i + 1] == (byte) 0x24 + && rawtext[i + 2] == (byte) 0x29 + && rawtext[i + 3] == (byte) 0x47) { + // CNS Escape $ ) G + i += 4; + while (rawtext[i] != (byte) 0x1B) { + dbchars++; + if ((byte) 0x21 <= rawtext[i] + && rawtext[i] <= (byte) 0x7E + && (byte) 0x21 <= rawtext[i + 1] + && rawtext[i + 1] <= (byte) 0x7E) { + isochars++; + totalfreq += 500; + row = rawtext[i] - 0x21; + column = rawtext[i + 1] - 0x21; + if (EUC_TWFreq[row][column] != 0) { + isofreq += EUC_TWFreq[row][column]; + } else if (35 <= row && row <= 92) { + isofreq += 150; + } + i++; + } + i++; + } + } + if (rawtext[i] == (byte) 0x1B && i + 2 < rawtextlen + && rawtext[i + 1] == (byte) 0x28 + && rawtext[i + 2] == (byte) 0x42) { // ASCII: + // ESC + // ( B + i += 2; + } + } + } + rangeval = 50 * ((float) isochars / (float) dbchars); + freqval = 50 * ((float) isofreq / (float) totalfreq); + // System.out.println("isochars dbchars isofreq totalfreq " + isochars + + // " " + dbchars + " " + isofreq + " " + totalfreq + " + // " + rangeval + " " + freqval); + return (int) (rangeval + freqval); + // return 0; + } + + /* + * Function: utf8_probability Argument: byte array Returns : number from 0 + * to 100 representing probability text in array uses UTF-8 encoding of + * Unicode + */ + int utf8_probability(byte[] rawtext) { + int score = 0; + int i, rawtextlen = 0; + int goodbytes = 0, asciibytes = 0; + // Maybe also use UTF8 Byte Order Mark: EF BB BF + // Check to see if characters fit into acceptable ranges + rawtextlen = rawtext.length; + for (i = 0; i < rawtextlen; i++) { + if ((rawtext[i] & (byte) 0x7F) == rawtext[i]) { // One byte + asciibytes++; + // Ignore ASCII, can throw off count + } else if (-64 <= rawtext[i] && rawtext[i] <= -33 + && // Two bytes + i + 1 < rawtextlen && -128 <= rawtext[i + 1] + && rawtext[i + 1] <= -65) { + goodbytes += 2; + i++; + } else if (-32 <= rawtext[i] + && rawtext[i] <= -17 + && // Three bytes + i + 2 < rawtextlen && -128 <= rawtext[i + 1] + && rawtext[i + 1] <= -65 && -128 <= rawtext[i + 2] + && rawtext[i + 2] <= -65) { + goodbytes += 3; + i += 2; + } + } + if (asciibytes == rawtextlen) { + return 0; + } + score = (int) (100 * ((float) goodbytes / (float) (rawtextlen - asciibytes))); + // System.out.println("rawtextlen " + rawtextlen + " goodbytes " + + // goodbytes + " asciibytes " + asciibytes + " score " + + // score); + // If not above 98, reduce to zero to prevent coincidental matches + // Allows for some (few) bad formed sequences + if (score > 98) { + return score; + } else if (score > 95 && goodbytes > 30) { + return score; + } else { + return 0; + } + } + + /* + * Function: utf16_probability Argument: byte array Returns : number from 0 + * to 100 representing probability text in array uses UTF-16 encoding of + * Unicode, guess based on BOM // NOT VERY GENERAL, NEEDS MUCH MORE WORK + */ + int utf16_probability(byte[] rawtext) { + // int score = 0; + // int i, rawtextlen = 0; + // int goodbytes = 0, asciibytes = 0; + if (rawtext.length > 1 + && ((byte) 0xFE == rawtext[0] && (byte) 0xFF == rawtext[1]) || // Big-endian + ((byte) 0xFF == rawtext[0] && (byte) 0xFE == rawtext[1])) { // Little-endian + return 100; + } + return 0; + /* + * // Check to see if characters fit into acceptable ranges rawtextlen = + * rawtext.length; for (i = 0; i < rawtextlen; i++) { if ((rawtext[i] & + * (byte)0x7F) == rawtext[i]) { // One byte goodbytes += 1; + * asciibytes++; } else if ((rawtext[i] & (byte)0xDF) == rawtext[i]) { + * // Two bytes if (i+1 < rawtextlen && (rawtext[i+1] & (byte)0xBF) == + * rawtext[i+1]) { goodbytes += 2; i++; } } else if ((rawtext[i] & + * (byte)0xEF) == rawtext[i]) { // Three bytes if (i+2 < rawtextlen && + * (rawtext[i+1] & (byte)0xBF) == rawtext[i+1] && (rawtext[i+2] & + * (byte)0xBF) == rawtext[i+2]) { goodbytes += 3; i+=2; } } } + * + * score = (int)(100 * ((float)goodbytes/(float)rawtext.length)); // An + * all ASCII file is also a good UTF8 file, but I'd rather it // get + * identified as ASCII. Can delete following 3 lines otherwise if + * (goodbytes == asciibytes) { score = 0; } // If not above 90, reduce + * to zero to prevent coincidental matches if (score > 90) { return + * score; } else { return 0; } + */ + } + + /* + * Function: ascii_probability Argument: byte array Returns : number from 0 + * to 100 representing probability text in array uses all ASCII Description: + * Sees if array has any characters not in ASCII range, if so, score is + * reduced + */ + int ascii_probability(byte[] rawtext) { + int score = 75; + int i, rawtextlen; + rawtextlen = rawtext.length; + for (i = 0; i < rawtextlen; i++) { + if (rawtext[i] < 0) { + score = score - 5; + } else if (rawtext[i] == (byte) 0x1B) { // ESC (used by ISO 2022) + score = score - 5; + } + if (score <= 0) { + return 0; + } + } + return score; + } + + /* + * Function: euc_kr__probability Argument: pointer to byte array Returns : + * number from 0 to 100 representing probability text in array uses EUC-KR + * encoding + */ + int euc_kr_probability(byte[] rawtext) { + int i, rawtextlen = 0; + int dbchars = 1, krchars = 1; + long krfreq = 0, totalfreq = 1; + float rangeval = 0, freqval = 0; + int row, column; + // Stage 1: Check to see if characters fit into acceptable ranges + rawtextlen = rawtext.length; + for (i = 0; i < rawtextlen - 1; i++) { + // System.err.println(rawtext[i]); + if (rawtext[i] >= 0) { + // asciichars++; + } else { + dbchars++; + if ((byte) 0xA1 <= rawtext[i] && rawtext[i] <= (byte) 0xFE + && (byte) 0xA1 <= rawtext[i + 1] + && rawtext[i + 1] <= (byte) 0xFE) { + krchars++; + totalfreq += 500; + row = rawtext[i] + 256 - 0xA1; + column = rawtext[i + 1] + 256 - 0xA1; + if (KRFreq[row][column] != 0) { + krfreq += KRFreq[row][column]; + } else if (15 <= row && row < 55) { + krfreq += 0; + } + } + i++; + } + } + rangeval = 50 * ((float) krchars / (float) dbchars); + freqval = 50 * ((float) krfreq / (float) totalfreq); + return (int) (rangeval + freqval); + } + + /* + * Function: cp949__probability Argument: pointer to byte array Returns : + * number from 0 to 100 representing probability text in array uses Cp949 + * encoding + */ + int cp949_probability(byte[] rawtext) { + int i, rawtextlen = 0; + int dbchars = 1, krchars = 1; + long krfreq = 0, totalfreq = 1; + float rangeval = 0, freqval = 0; + int row, column; + // Stage 1: Check to see if characters fit into acceptable ranges + rawtextlen = rawtext.length; + for (i = 0; i < rawtextlen - 1; i++) { + // System.err.println(rawtext[i]); + if (rawtext[i] >= 0) { + // asciichars++; + } else { + dbchars++; + if ((byte) 0x81 <= rawtext[i] + && rawtext[i] <= (byte) 0xFE + && ((byte) 0x41 <= rawtext[i + 1] + && rawtext[i + 1] <= (byte) 0x5A + || (byte) 0x61 <= rawtext[i + 1] + && rawtext[i + 1] <= (byte) 0x7A || (byte) 0x81 <= rawtext[i + 1] + && rawtext[i + 1] <= (byte) 0xFE)) { + krchars++; + totalfreq += 500; + if ((byte) 0xA1 <= rawtext[i] && rawtext[i] <= (byte) 0xFE + && (byte) 0xA1 <= rawtext[i + 1] + && rawtext[i + 1] <= (byte) 0xFE) { + row = rawtext[i] + 256 - 0xA1; + column = rawtext[i + 1] + 256 - 0xA1; + if (KRFreq[row][column] != 0) { + krfreq += KRFreq[row][column]; + } + } + } + i++; + } + } + rangeval = 50 * ((float) krchars / (float) dbchars); + freqval = 50 * ((float) krfreq / (float) totalfreq); + return (int) (rangeval + freqval); + } + + int iso_2022_kr_probability(byte[] rawtext) { + int i; + for (i = 0; i < rawtext.length; i++) { + if (i + 3 < rawtext.length && rawtext[i] == 0x1b + && (char) rawtext[i + 1] == '$' + && (char) rawtext[i + 2] == ')' + && (char) rawtext[i + 3] == 'C') { + return 100; + } + } + return 0; + } + + /* + * Function: euc_jp_probability Argument: pointer to byte array Returns : + * number from 0 to 100 representing probability text in array uses EUC-JP + * encoding + */ + int euc_jp_probability(byte[] rawtext) { + int i, rawtextlen = 0; + int dbchars = 1, jpchars = 1; + long jpfreq = 0, totalfreq = 1; + float rangeval = 0, freqval = 0; + int row, column; + // Stage 1: Check to see if characters fit into acceptable ranges + rawtextlen = rawtext.length; + for (i = 0; i < rawtextlen - 1; i++) { + // System.err.println(rawtext[i]); + if (rawtext[i] >= 0) { + // asciichars++; + } else { + dbchars++; + if ((byte) 0xA1 <= rawtext[i] && rawtext[i] <= (byte) 0xFE + && (byte) 0xA1 <= rawtext[i + 1] + && rawtext[i + 1] <= (byte) 0xFE) { + jpchars++; + totalfreq += 500; + row = rawtext[i] + 256 - 0xA1; + column = rawtext[i + 1] + 256 - 0xA1; + if (JPFreq[row][column] != 0) { + jpfreq += JPFreq[row][column]; + } else if (15 <= row && row < 55) { + jpfreq += 0; + } + } + i++; + } + } + rangeval = 50 * ((float) jpchars / (float) dbchars); + freqval = 50 * ((float) jpfreq / (float) totalfreq); + return (int) (rangeval + freqval); + } + + int iso_2022_jp_probability(byte[] rawtext) { + int i; + for (i = 0; i < rawtext.length; i++) { + if (i + 2 < rawtext.length && rawtext[i] == 0x1b + && (char) rawtext[i + 1] == '$' + && (char) rawtext[i + 2] == 'B') { + return 100; + } + } + return 0; + } + + /* + * Function: sjis_probability Argument: pointer to byte array Returns : + * number from 0 to 100 representing probability text in array uses + * Shift-JIS encoding + */ + int sjis_probability(byte[] rawtext) { + int i, rawtextlen = 0; + int dbchars = 1, jpchars = 1; + long jpfreq = 0, totalfreq = 1; + float rangeval = 0, freqval = 0; + int row, column, adjust; + // Stage 1: Check to see if characters fit into acceptable ranges + rawtextlen = rawtext.length; + for (i = 0; i < rawtextlen - 1; i++) { + // System.err.println(rawtext[i]); + if (rawtext[i] >= 0) { + // asciichars++; + } else { + dbchars++; + if (i + 1 < rawtext.length + && (((byte) 0x81 <= rawtext[i] && rawtext[i] <= (byte) 0x9F) || ((byte) 0xE0 <= rawtext[i] && rawtext[i] <= (byte) 0xEF)) + && (((byte) 0x40 <= rawtext[i + 1] && rawtext[i + 1] <= (byte) 0x7E) || ((byte) 0x80 <= rawtext[i + 1] && rawtext[i + 1] <= (byte) 0xFC))) { + jpchars++; + totalfreq += 500; + row = rawtext[i] + 256; + column = rawtext[i + 1] + 256; + if (column < 0x9f) { + adjust = 1; + if (column > 0x7f) { + column -= 0x20; + } else { + column -= 0x19; + } + } else { + adjust = 0; + column -= 0x7e; + } + if (row < 0xa0) { + row = ((row - 0x70) << 1) - adjust; + } else { + row = ((row - 0xb0) << 1) - adjust; + } + row -= 0x20; + column = 0x20; + // System.out.println("original row " + row + " column " + + // column); + if (row < JPFreq.length && column < JPFreq[row].length + && JPFreq[row][column] != 0) { + jpfreq += JPFreq[row][column]; + } + i++; + } else if ((byte) 0xA1 <= rawtext[i] + && rawtext[i] <= (byte) 0xDF) { + // half-width katakana, convert to full-width + } + } + } + rangeval = 50 * ((float) jpchars / (float) dbchars); + freqval = 50 * ((float) jpfreq / (float) totalfreq); + // For regular GB files, this would give the same score, so I handicap + // it slightly + return (int) (rangeval + freqval) - 1; + } + + void initialize_frequencies() { + int i, j; + for (i = 93; i >= 0; i--) { + for (j = 93; j >= 0; j--) { + GBFreq[i][j] = 0; + } + } + for (i = 125; i >= 0; i--) { + for (j = 190; j >= 0; j--) { + GBKFreq[i][j] = 0; + } + } + // for (i = 0; i < 94; i++) { + // for (j = 0; j < 158; j++) { + for (i = 93; i >= 0; i--) { + for (j = 157; j >= 0; j--) { + Big5Freq[i][j] = 0; + } + } + // for (i = 0; i < 126; i++) { + // for (j = 0; j < 191; j++) { + for (i = 125; i >= 0; i--) { + for (j = 190; j >= 0; j--) { + Big5PFreq[i][j] = 0; + } + } + // for (i = 0; i < 94; i++) { + // for (j = 0; j < 94; j++) { + for (i = 93; i >= 0; i--) { + for (j = 93; j >= 0; j--) { + EUC_TWFreq[i][j] = 0; + } + } + for (i = 93; i >= 0; i--) { + for (j = 93; j >= 0; j--) { + JPFreq[i][j] = 0; + } + } + GBFreq[20][35] = 599; + GBFreq[49][26] = 598; + GBFreq[41][38] = 597; + GBFreq[17][26] = 596; + GBFreq[32][42] = 595; + GBFreq[39][42] = 594; + GBFreq[45][49] = 593; + GBFreq[51][57] = 592; + GBFreq[50][47] = 591; + GBFreq[42][90] = 590; + GBFreq[52][65] = 589; + GBFreq[53][47] = 588; + GBFreq[19][82] = 587; + GBFreq[31][19] = 586; + GBFreq[40][46] = 585; + GBFreq[24][89] = 584; + GBFreq[23][85] = 583; + GBFreq[20][28] = 582; + GBFreq[42][20] = 581; + GBFreq[34][38] = 580; + GBFreq[45][9] = 579; + GBFreq[54][50] = 578; + GBFreq[25][44] = 577; + GBFreq[35][66] = 576; + GBFreq[20][55] = 575; + GBFreq[18][85] = 574; + GBFreq[20][31] = 573; + GBFreq[49][17] = 572; + GBFreq[41][16] = 571; + GBFreq[35][73] = 570; + GBFreq[20][34] = 569; + GBFreq[29][44] = 568; + GBFreq[35][38] = 567; + GBFreq[49][9] = 566; + GBFreq[46][33] = 565; + GBFreq[49][51] = 564; + GBFreq[40][89] = 563; + GBFreq[26][64] = 562; + GBFreq[54][51] = 561; + GBFreq[54][36] = 560; + GBFreq[39][4] = 559; + GBFreq[53][13] = 558; + GBFreq[24][92] = 557; + GBFreq[27][49] = 556; + GBFreq[48][6] = 555; + GBFreq[21][51] = 554; + GBFreq[30][40] = 553; + GBFreq[42][92] = 552; + GBFreq[31][78] = 551; + GBFreq[25][82] = 550; + GBFreq[47][0] = 549; + GBFreq[34][19] = 548; + GBFreq[47][35] = 547; + GBFreq[21][63] = 546; + GBFreq[43][75] = 545; + GBFreq[21][87] = 544; + GBFreq[35][59] = 543; + GBFreq[25][34] = 542; + GBFreq[21][27] = 541; + GBFreq[39][26] = 540; + GBFreq[34][26] = 539; + GBFreq[39][52] = 538; + GBFreq[50][57] = 537; + GBFreq[37][79] = 536; + GBFreq[26][24] = 535; + GBFreq[22][1] = 534; + GBFreq[18][40] = 533; + GBFreq[41][33] = 532; + GBFreq[53][26] = 531; + GBFreq[54][86] = 530; + GBFreq[20][16] = 529; + GBFreq[46][74] = 528; + GBFreq[30][19] = 527; + GBFreq[45][35] = 526; + GBFreq[45][61] = 525; + GBFreq[30][9] = 524; + GBFreq[41][53] = 523; + GBFreq[41][13] = 522; + GBFreq[50][34] = 521; + GBFreq[53][86] = 520; + GBFreq[47][47] = 519; + GBFreq[22][28] = 518; + GBFreq[50][53] = 517; + GBFreq[39][70] = 516; + GBFreq[38][15] = 515; + GBFreq[42][88] = 514; + GBFreq[16][29] = 513; + GBFreq[27][90] = 512; + GBFreq[29][12] = 511; + GBFreq[44][22] = 510; + GBFreq[34][69] = 509; + GBFreq[24][10] = 508; + GBFreq[44][11] = 507; + GBFreq[39][92] = 506; + GBFreq[49][48] = 505; + GBFreq[31][46] = 504; + GBFreq[19][50] = 503; + GBFreq[21][14] = 502; + GBFreq[32][28] = 501; + GBFreq[18][3] = 500; + GBFreq[53][9] = 499; + GBFreq[34][80] = 498; + GBFreq[48][88] = 497; + GBFreq[46][53] = 496; + GBFreq[22][53] = 495; + GBFreq[28][10] = 494; + GBFreq[44][65] = 493; + GBFreq[20][10] = 492; + GBFreq[40][76] = 491; + GBFreq[47][8] = 490; + GBFreq[50][74] = 489; + GBFreq[23][62] = 488; + GBFreq[49][65] = 487; + GBFreq[28][87] = 486; + GBFreq[15][48] = 485; + GBFreq[22][7] = 484; + GBFreq[19][42] = 483; + GBFreq[41][20] = 482; + GBFreq[26][55] = 481; + GBFreq[21][93] = 480; + GBFreq[31][76] = 479; + GBFreq[34][31] = 478; + GBFreq[20][66] = 477; + GBFreq[51][33] = 476; + GBFreq[34][86] = 475; + GBFreq[37][67] = 474; + GBFreq[53][53] = 473; + GBFreq[40][88] = 472; + GBFreq[39][10] = 471; + GBFreq[24][3] = 470; + GBFreq[27][25] = 469; + GBFreq[26][15] = 468; + GBFreq[21][88] = 467; + GBFreq[52][62] = 466; + GBFreq[46][81] = 465; + GBFreq[38][72] = 464; + GBFreq[17][30] = 463; + GBFreq[52][92] = 462; + GBFreq[34][90] = 461; + GBFreq[21][7] = 460; + GBFreq[36][13] = 459; + GBFreq[45][41] = 458; + GBFreq[32][5] = 457; + GBFreq[26][89] = 456; + GBFreq[23][87] = 455; + GBFreq[20][39] = 454; + GBFreq[27][23] = 453; + GBFreq[25][59] = 452; + GBFreq[49][20] = 451; + GBFreq[54][77] = 450; + GBFreq[27][67] = 449; + GBFreq[47][33] = 448; + GBFreq[41][17] = 447; + GBFreq[19][81] = 446; + GBFreq[16][66] = 445; + GBFreq[45][26] = 444; + GBFreq[49][81] = 443; + GBFreq[53][55] = 442; + GBFreq[16][26] = 441; + GBFreq[54][62] = 440; + GBFreq[20][70] = 439; + GBFreq[42][35] = 438; + GBFreq[20][57] = 437; + GBFreq[34][36] = 436; + GBFreq[46][63] = 435; + GBFreq[19][45] = 434; + GBFreq[21][10] = 433; + GBFreq[52][93] = 432; + GBFreq[25][2] = 431; + GBFreq[30][57] = 430; + GBFreq[41][24] = 429; + GBFreq[28][43] = 428; + GBFreq[45][86] = 427; + GBFreq[51][56] = 426; + GBFreq[37][28] = 425; + GBFreq[52][69] = 424; + GBFreq[43][92] = 423; + GBFreq[41][31] = 422; + GBFreq[37][87] = 421; + GBFreq[47][36] = 420; + GBFreq[16][16] = 419; + GBFreq[40][56] = 418; + GBFreq[24][55] = 417; + GBFreq[17][1] = 416; + GBFreq[35][57] = 415; + GBFreq[27][50] = 414; + GBFreq[26][14] = 413; + GBFreq[50][40] = 412; + GBFreq[39][19] = 411; + GBFreq[19][89] = 410; + GBFreq[29][91] = 409; + GBFreq[17][89] = 408; + GBFreq[39][74] = 407; + GBFreq[46][39] = 406; + GBFreq[40][28] = 405; + GBFreq[45][68] = 404; + GBFreq[43][10] = 403; + GBFreq[42][13] = 402; + GBFreq[44][81] = 401; + GBFreq[41][47] = 400; + GBFreq[48][58] = 399; + GBFreq[43][68] = 398; + GBFreq[16][79] = 397; + GBFreq[19][5] = 396; + GBFreq[54][59] = 395; + GBFreq[17][36] = 394; + GBFreq[18][0] = 393; + GBFreq[41][5] = 392; + GBFreq[41][72] = 391; + GBFreq[16][39] = 390; + GBFreq[54][0] = 389; + GBFreq[51][16] = 388; + GBFreq[29][36] = 387; + GBFreq[47][5] = 386; + GBFreq[47][51] = 385; + GBFreq[44][7] = 384; + GBFreq[35][30] = 383; + GBFreq[26][9] = 382; + GBFreq[16][7] = 381; + GBFreq[32][1] = 380; + GBFreq[33][76] = 379; + GBFreq[34][91] = 378; + GBFreq[52][36] = 377; + GBFreq[26][77] = 376; + GBFreq[35][48] = 375; + GBFreq[40][80] = 374; + GBFreq[41][92] = 373; + GBFreq[27][93] = 372; + GBFreq[15][17] = 371; + GBFreq[16][76] = 370; + GBFreq[51][12] = 369; + GBFreq[18][20] = 368; + GBFreq[15][54] = 367; + GBFreq[50][5] = 366; + GBFreq[33][22] = 365; + GBFreq[37][57] = 364; + GBFreq[28][47] = 363; + GBFreq[42][31] = 362; + GBFreq[18][2] = 361; + GBFreq[43][64] = 360; + GBFreq[23][47] = 359; + GBFreq[28][79] = 358; + GBFreq[25][45] = 357; + GBFreq[23][91] = 356; + GBFreq[22][19] = 355; + GBFreq[25][46] = 354; + GBFreq[22][36] = 353; + GBFreq[54][85] = 352; + GBFreq[46][20] = 351; + GBFreq[27][37] = 350; + GBFreq[26][81] = 349; + GBFreq[42][29] = 348; + GBFreq[31][90] = 347; + GBFreq[41][59] = 346; + GBFreq[24][65] = 345; + GBFreq[44][84] = 344; + GBFreq[24][90] = 343; + GBFreq[38][54] = 342; + GBFreq[28][70] = 341; + GBFreq[27][15] = 340; + GBFreq[28][80] = 339; + GBFreq[29][8] = 338; + GBFreq[45][80] = 337; + GBFreq[53][37] = 336; + GBFreq[28][65] = 335; + GBFreq[23][86] = 334; + GBFreq[39][45] = 333; + GBFreq[53][32] = 332; + GBFreq[38][68] = 331; + GBFreq[45][78] = 330; + GBFreq[43][7] = 329; + GBFreq[46][82] = 328; + GBFreq[27][38] = 327; + GBFreq[16][62] = 326; + GBFreq[24][17] = 325; + GBFreq[22][70] = 324; + GBFreq[52][28] = 323; + GBFreq[23][40] = 322; + GBFreq[28][50] = 321; + GBFreq[42][91] = 320; + GBFreq[47][76] = 319; + GBFreq[15][42] = 318; + GBFreq[43][55] = 317; + GBFreq[29][84] = 316; + GBFreq[44][90] = 315; + GBFreq[53][16] = 314; + GBFreq[22][93] = 313; + GBFreq[34][10] = 312; + GBFreq[32][53] = 311; + GBFreq[43][65] = 310; + GBFreq[28][7] = 309; + GBFreq[35][46] = 308; + GBFreq[21][39] = 307; + GBFreq[44][18] = 306; + GBFreq[40][10] = 305; + GBFreq[54][53] = 304; + GBFreq[38][74] = 303; + GBFreq[28][26] = 302; + GBFreq[15][13] = 301; + GBFreq[39][34] = 300; + GBFreq[39][46] = 299; + GBFreq[42][66] = 298; + GBFreq[33][58] = 297; + GBFreq[15][56] = 296; + GBFreq[18][51] = 295; + GBFreq[49][68] = 294; + GBFreq[30][37] = 293; + GBFreq[51][84] = 292; + GBFreq[51][9] = 291; + GBFreq[40][70] = 290; + GBFreq[41][84] = 289; + GBFreq[28][64] = 288; + GBFreq[32][88] = 287; + GBFreq[24][5] = 286; + GBFreq[53][23] = 285; + GBFreq[42][27] = 284; + GBFreq[22][38] = 283; + GBFreq[32][86] = 282; + GBFreq[34][30] = 281; + GBFreq[38][63] = 280; + GBFreq[24][59] = 279; + GBFreq[22][81] = 278; + GBFreq[32][11] = 277; + GBFreq[51][21] = 276; + GBFreq[54][41] = 275; + GBFreq[21][50] = 274; + GBFreq[23][89] = 273; + GBFreq[19][87] = 272; + GBFreq[26][7] = 271; + GBFreq[30][75] = 270; + GBFreq[43][84] = 269; + GBFreq[51][25] = 268; + GBFreq[16][67] = 267; + GBFreq[32][9] = 266; + GBFreq[48][51] = 265; + GBFreq[39][7] = 264; + GBFreq[44][88] = 263; + GBFreq[52][24] = 262; + GBFreq[23][34] = 261; + GBFreq[32][75] = 260; + GBFreq[19][10] = 259; + GBFreq[28][91] = 258; + GBFreq[32][83] = 257; + GBFreq[25][75] = 256; + GBFreq[53][45] = 255; + GBFreq[29][85] = 254; + GBFreq[53][59] = 253; + GBFreq[16][2] = 252; + GBFreq[19][78] = 251; + GBFreq[15][75] = 250; + GBFreq[51][42] = 249; + GBFreq[45][67] = 248; + GBFreq[15][74] = 247; + GBFreq[25][81] = 246; + GBFreq[37][62] = 245; + GBFreq[16][55] = 244; + GBFreq[18][38] = 243; + GBFreq[23][23] = 242; + GBFreq[38][30] = 241; + GBFreq[17][28] = 240; + GBFreq[44][73] = 239; + GBFreq[23][78] = 238; + GBFreq[40][77] = 237; + GBFreq[38][87] = 236; + GBFreq[27][19] = 235; + GBFreq[38][82] = 234; + GBFreq[37][22] = 233; + GBFreq[41][30] = 232; + GBFreq[54][9] = 231; + GBFreq[32][30] = 230; + GBFreq[30][52] = 229; + GBFreq[40][84] = 228; + GBFreq[53][57] = 227; + GBFreq[27][27] = 226; + GBFreq[38][64] = 225; + GBFreq[18][43] = 224; + GBFreq[23][69] = 223; + GBFreq[28][12] = 222; + GBFreq[50][78] = 221; + GBFreq[50][1] = 220; + GBFreq[26][88] = 219; + GBFreq[36][40] = 218; + GBFreq[33][89] = 217; + GBFreq[41][28] = 216; + GBFreq[31][77] = 215; + GBFreq[46][1] = 214; + GBFreq[47][19] = 213; + GBFreq[35][55] = 212; + GBFreq[41][21] = 211; + GBFreq[27][10] = 210; + GBFreq[32][77] = 209; + GBFreq[26][37] = 208; + GBFreq[20][33] = 207; + GBFreq[41][52] = 206; + GBFreq[32][18] = 205; + GBFreq[38][13] = 204; + GBFreq[20][18] = 203; + GBFreq[20][24] = 202; + GBFreq[45][19] = 201; + GBFreq[18][53] = 200; + /* + * GBFreq[39][0] = 199; GBFreq[40][71] = 198; GBFreq[41][27] = 197; + * GBFreq[15][69] = 196; GBFreq[42][10] = 195; GBFreq[31][89] = 194; + * GBFreq[51][28] = 193; GBFreq[41][22] = 192; GBFreq[40][43] = 191; + * GBFreq[38][6] = 190; GBFreq[37][11] = 189; GBFreq[39][60] = 188; + * GBFreq[48][47] = 187; GBFreq[46][80] = 186; GBFreq[52][49] = 185; + * GBFreq[50][48] = 184; GBFreq[25][1] = 183; GBFreq[52][29] = 182; + * GBFreq[24][66] = 181; GBFreq[23][35] = 180; GBFreq[49][72] = 179; + * GBFreq[47][45] = 178; GBFreq[45][14] = 177; GBFreq[51][70] = 176; + * GBFreq[22][30] = 175; GBFreq[49][83] = 174; GBFreq[26][79] = 173; + * GBFreq[27][41] = 172; GBFreq[51][81] = 171; GBFreq[41][54] = 170; + * GBFreq[20][4] = 169; GBFreq[29][60] = 168; GBFreq[20][27] = 167; + * GBFreq[50][15] = 166; GBFreq[41][6] = 165; GBFreq[35][34] = 164; + * GBFreq[44][87] = 163; GBFreq[46][66] = 162; GBFreq[42][37] = 161; + * GBFreq[42][24] = 160; GBFreq[54][7] = 159; GBFreq[41][14] = 158; + * GBFreq[39][83] = 157; GBFreq[16][87] = 156; GBFreq[20][59] = 155; + * GBFreq[42][12] = 154; GBFreq[47][2] = 153; GBFreq[21][32] = 152; + * GBFreq[53][29] = 151; GBFreq[22][40] = 150; GBFreq[24][58] = 149; + * GBFreq[52][88] = 148; GBFreq[29][30] = 147; GBFreq[15][91] = 146; + * GBFreq[54][72] = 145; GBFreq[51][75] = 144; GBFreq[33][67] = 143; + * GBFreq[41][50] = 142; GBFreq[27][34] = 141; GBFreq[46][17] = 140; + * GBFreq[31][74] = 139; GBFreq[42][67] = 138; GBFreq[54][87] = 137; + * GBFreq[27][14] = 136; GBFreq[16][63] = 135; GBFreq[16][5] = 134; + * GBFreq[43][23] = 133; GBFreq[23][13] = 132; GBFreq[31][12] = 131; + * GBFreq[25][57] = 130; GBFreq[38][49] = 129; GBFreq[42][69] = 128; + * GBFreq[23][80] = 127; GBFreq[29][0] = 126; GBFreq[28][2] = 125; + * GBFreq[28][17] = 124; GBFreq[17][27] = 123; GBFreq[40][16] = 122; + * GBFreq[45][1] = 121; GBFreq[36][33] = 120; GBFreq[35][23] = 119; + * GBFreq[20][86] = 118; GBFreq[29][53] = 117; GBFreq[23][88] = 116; + * GBFreq[51][87] = 115; GBFreq[54][27] = 114; GBFreq[44][36] = 113; + * GBFreq[21][45] = 112; GBFreq[53][52] = 111; GBFreq[31][53] = 110; + * GBFreq[38][47] = 109; GBFreq[27][21] = 108; GBFreq[30][42] = 107; + * GBFreq[29][10] = 106; GBFreq[35][35] = 105; GBFreq[24][56] = 104; + * GBFreq[41][29] = 103; GBFreq[18][68] = 102; GBFreq[29][24] = 101; + * GBFreq[25][84] = 100; GBFreq[35][47] = 99; GBFreq[29][56] = 98; + * GBFreq[30][44] = 97; GBFreq[53][3] = 96; GBFreq[30][63] = 95; + * GBFreq[52][52] = 94; GBFreq[54][1] = 93; GBFreq[22][48] = 92; + * GBFreq[54][66] = 91; GBFreq[21][90] = 90; GBFreq[52][47] = 89; + * GBFreq[39][25] = 88; GBFreq[39][39] = 87; GBFreq[44][37] = 86; + * GBFreq[44][76] = 85; GBFreq[46][75] = 84; GBFreq[18][37] = 83; + * GBFreq[47][42] = 82; GBFreq[19][92] = 81; GBFreq[51][27] = 80; + * GBFreq[48][83] = 79; GBFreq[23][70] = 78; GBFreq[29][9] = 77; + * GBFreq[33][79] = 76; GBFreq[52][90] = 75; GBFreq[53][6] = 74; + * GBFreq[24][36] = 73; GBFreq[25][25] = 72; GBFreq[44][26] = 71; + * GBFreq[25][36] = 70; GBFreq[29][87] = 69; GBFreq[48][0] = 68; + * GBFreq[15][40] = 67; GBFreq[17][45] = 66; GBFreq[30][14] = 65; + * GBFreq[48][38] = 64; GBFreq[23][19] = 63; GBFreq[40][42] = 62; + * GBFreq[31][63] = 61; GBFreq[16][23] = 60; GBFreq[26][21] = 59; + * GBFreq[32][76] = 58; GBFreq[23][58] = 57; GBFreq[41][37] = 56; + * GBFreq[30][43] = 55; GBFreq[47][38] = 54; GBFreq[21][46] = 53; + * GBFreq[18][33] = 52; GBFreq[52][37] = 51; GBFreq[36][8] = 50; + * GBFreq[49][24] = 49; GBFreq[15][66] = 48; GBFreq[35][77] = 47; + * GBFreq[27][58] = 46; GBFreq[35][51] = 45; GBFreq[24][69] = 44; + * GBFreq[20][54] = 43; GBFreq[24][41] = 42; GBFreq[41][0] = 41; + * GBFreq[33][71] = 40; GBFreq[23][52] = 39; GBFreq[29][67] = 38; + * GBFreq[46][51] = 37; GBFreq[46][90] = 36; GBFreq[49][33] = 35; + * GBFreq[33][28] = 34; GBFreq[37][86] = 33; GBFreq[39][22] = 32; + * GBFreq[37][37] = 31; GBFreq[29][62] = 30; GBFreq[29][50] = 29; + * GBFreq[36][89] = 28; GBFreq[42][44] = 27; GBFreq[51][82] = 26; + * GBFreq[28][83] = 25; GBFreq[15][78] = 24; GBFreq[46][62] = 23; + * GBFreq[19][69] = 22; GBFreq[51][23] = 21; GBFreq[37][69] = 20; + * GBFreq[25][5] = 19; GBFreq[51][85] = 18; GBFreq[48][77] = 17; + * GBFreq[32][46] = 16; GBFreq[53][60] = 15; GBFreq[28][57] = 14; + * GBFreq[54][82] = 13; GBFreq[54][15] = 12; GBFreq[49][54] = 11; + * GBFreq[53][87] = 10; GBFreq[27][16] = 9; GBFreq[29][34] = 8; + * GBFreq[20][44] = 7; GBFreq[42][73] = 6; GBFreq[47][71] = 5; + * GBFreq[29][37] = 4; GBFreq[25][50] = 3; GBFreq[18][84] = 2; + * GBFreq[50][45] = 1; GBFreq[48][46] = 0; + */ + // GBFreq[43][89] = -1; GBFreq[54][68] = -2; + Big5Freq[9][89] = 600; + Big5Freq[11][15] = 599; + Big5Freq[3][66] = 598; + Big5Freq[6][121] = 597; + Big5Freq[3][0] = 596; + Big5Freq[5][82] = 595; + Big5Freq[3][42] = 594; + Big5Freq[5][34] = 593; + Big5Freq[3][8] = 592; + Big5Freq[3][6] = 591; + Big5Freq[3][67] = 590; + Big5Freq[7][139] = 589; + Big5Freq[23][137] = 588; + Big5Freq[12][46] = 587; + Big5Freq[4][8] = 586; + Big5Freq[4][41] = 585; + Big5Freq[18][47] = 584; + Big5Freq[12][114] = 583; + Big5Freq[6][1] = 582; + Big5Freq[22][60] = 581; + Big5Freq[5][46] = 580; + Big5Freq[11][79] = 579; + Big5Freq[3][23] = 578; + Big5Freq[7][114] = 577; + Big5Freq[29][102] = 576; + Big5Freq[19][14] = 575; + Big5Freq[4][133] = 574; + Big5Freq[3][29] = 573; + Big5Freq[4][109] = 572; + Big5Freq[14][127] = 571; + Big5Freq[5][48] = 570; + Big5Freq[13][104] = 569; + Big5Freq[3][132] = 568; + Big5Freq[26][64] = 567; + Big5Freq[7][19] = 566; + Big5Freq[4][12] = 565; + Big5Freq[11][124] = 564; + Big5Freq[7][89] = 563; + Big5Freq[15][124] = 562; + Big5Freq[4][108] = 561; + Big5Freq[19][66] = 560; + Big5Freq[3][21] = 559; + Big5Freq[24][12] = 558; + Big5Freq[28][111] = 557; + Big5Freq[12][107] = 556; + Big5Freq[3][112] = 555; + Big5Freq[8][113] = 554; + Big5Freq[5][40] = 553; + Big5Freq[26][145] = 552; + Big5Freq[3][48] = 551; + Big5Freq[3][70] = 550; + Big5Freq[22][17] = 549; + Big5Freq[16][47] = 548; + Big5Freq[3][53] = 547; + Big5Freq[4][24] = 546; + Big5Freq[32][120] = 545; + Big5Freq[24][49] = 544; + Big5Freq[24][142] = 543; + Big5Freq[18][66] = 542; + Big5Freq[29][150] = 541; + Big5Freq[5][122] = 540; + Big5Freq[5][114] = 539; + Big5Freq[3][44] = 538; + Big5Freq[10][128] = 537; + Big5Freq[15][20] = 536; + Big5Freq[13][33] = 535; + Big5Freq[14][87] = 534; + Big5Freq[3][126] = 533; + Big5Freq[4][53] = 532; + Big5Freq[4][40] = 531; + Big5Freq[9][93] = 530; + Big5Freq[15][137] = 529; + Big5Freq[10][123] = 528; + Big5Freq[4][56] = 527; + Big5Freq[5][71] = 526; + Big5Freq[10][8] = 525; + Big5Freq[5][16] = 524; + Big5Freq[5][146] = 523; + Big5Freq[18][88] = 522; + Big5Freq[24][4] = 521; + Big5Freq[20][47] = 520; + Big5Freq[5][33] = 519; + Big5Freq[9][43] = 518; + Big5Freq[20][12] = 517; + Big5Freq[20][13] = 516; + Big5Freq[5][156] = 515; + Big5Freq[22][140] = 514; + Big5Freq[8][146] = 513; + Big5Freq[21][123] = 512; + Big5Freq[4][90] = 511; + Big5Freq[5][62] = 510; + Big5Freq[17][59] = 509; + Big5Freq[10][37] = 508; + Big5Freq[18][107] = 507; + Big5Freq[14][53] = 506; + Big5Freq[22][51] = 505; + Big5Freq[8][13] = 504; + Big5Freq[5][29] = 503; + Big5Freq[9][7] = 502; + Big5Freq[22][14] = 501; + Big5Freq[8][55] = 500; + Big5Freq[33][9] = 499; + Big5Freq[16][64] = 498; + Big5Freq[7][131] = 497; + Big5Freq[34][4] = 496; + Big5Freq[7][101] = 495; + Big5Freq[11][139] = 494; + Big5Freq[3][135] = 493; + Big5Freq[7][102] = 492; + Big5Freq[17][13] = 491; + Big5Freq[3][20] = 490; + Big5Freq[27][106] = 489; + Big5Freq[5][88] = 488; + Big5Freq[6][33] = 487; + Big5Freq[5][139] = 486; + Big5Freq[6][0] = 485; + Big5Freq[17][58] = 484; + Big5Freq[5][133] = 483; + Big5Freq[9][107] = 482; + Big5Freq[23][39] = 481; + Big5Freq[5][23] = 480; + Big5Freq[3][79] = 479; + Big5Freq[32][97] = 478; + Big5Freq[3][136] = 477; + Big5Freq[4][94] = 476; + Big5Freq[21][61] = 475; + Big5Freq[23][123] = 474; + Big5Freq[26][16] = 473; + Big5Freq[24][137] = 472; + Big5Freq[22][18] = 471; + Big5Freq[5][1] = 470; + Big5Freq[20][119] = 469; + Big5Freq[3][7] = 468; + Big5Freq[10][79] = 467; + Big5Freq[15][105] = 466; + Big5Freq[3][144] = 465; + Big5Freq[12][80] = 464; + Big5Freq[15][73] = 463; + Big5Freq[3][19] = 462; + Big5Freq[8][109] = 461; + Big5Freq[3][15] = 460; + Big5Freq[31][82] = 459; + Big5Freq[3][43] = 458; + Big5Freq[25][119] = 457; + Big5Freq[16][111] = 456; + Big5Freq[7][77] = 455; + Big5Freq[3][95] = 454; + Big5Freq[24][82] = 453; + Big5Freq[7][52] = 452; + Big5Freq[9][151] = 451; + Big5Freq[3][129] = 450; + Big5Freq[5][87] = 449; + Big5Freq[3][55] = 448; + Big5Freq[8][153] = 447; + Big5Freq[4][83] = 446; + Big5Freq[3][114] = 445; + Big5Freq[23][147] = 444; + Big5Freq[15][31] = 443; + Big5Freq[3][54] = 442; + Big5Freq[11][122] = 441; + Big5Freq[4][4] = 440; + Big5Freq[34][149] = 439; + Big5Freq[3][17] = 438; + Big5Freq[21][64] = 437; + Big5Freq[26][144] = 436; + Big5Freq[4][62] = 435; + Big5Freq[8][15] = 434; + Big5Freq[35][80] = 433; + Big5Freq[7][110] = 432; + Big5Freq[23][114] = 431; + Big5Freq[3][108] = 430; + Big5Freq[3][62] = 429; + Big5Freq[21][41] = 428; + Big5Freq[15][99] = 427; + Big5Freq[5][47] = 426; + Big5Freq[4][96] = 425; + Big5Freq[20][122] = 424; + Big5Freq[5][21] = 423; + Big5Freq[4][157] = 422; + Big5Freq[16][14] = 421; + Big5Freq[3][117] = 420; + Big5Freq[7][129] = 419; + Big5Freq[4][27] = 418; + Big5Freq[5][30] = 417; + Big5Freq[22][16] = 416; + Big5Freq[5][64] = 415; + Big5Freq[17][99] = 414; + Big5Freq[17][57] = 413; + Big5Freq[8][105] = 412; + Big5Freq[5][112] = 411; + Big5Freq[20][59] = 410; + Big5Freq[6][129] = 409; + Big5Freq[18][17] = 408; + Big5Freq[3][92] = 407; + Big5Freq[28][118] = 406; + Big5Freq[3][109] = 405; + Big5Freq[31][51] = 404; + Big5Freq[13][116] = 403; + Big5Freq[6][15] = 402; + Big5Freq[36][136] = 401; + Big5Freq[12][74] = 400; + Big5Freq[20][88] = 399; + Big5Freq[36][68] = 398; + Big5Freq[3][147] = 397; + Big5Freq[15][84] = 396; + Big5Freq[16][32] = 395; + Big5Freq[16][58] = 394; + Big5Freq[7][66] = 393; + Big5Freq[23][107] = 392; + Big5Freq[9][6] = 391; + Big5Freq[12][86] = 390; + Big5Freq[23][112] = 389; + Big5Freq[37][23] = 388; + Big5Freq[3][138] = 387; + Big5Freq[20][68] = 386; + Big5Freq[15][116] = 385; + Big5Freq[18][64] = 384; + Big5Freq[12][139] = 383; + Big5Freq[11][155] = 382; + Big5Freq[4][156] = 381; + Big5Freq[12][84] = 380; + Big5Freq[18][49] = 379; + Big5Freq[25][125] = 378; + Big5Freq[25][147] = 377; + Big5Freq[15][110] = 376; + Big5Freq[19][96] = 375; + Big5Freq[30][152] = 374; + Big5Freq[6][31] = 373; + Big5Freq[27][117] = 372; + Big5Freq[3][10] = 371; + Big5Freq[6][131] = 370; + Big5Freq[13][112] = 369; + Big5Freq[36][156] = 368; + Big5Freq[4][60] = 367; + Big5Freq[15][121] = 366; + Big5Freq[4][112] = 365; + Big5Freq[30][142] = 364; + Big5Freq[23][154] = 363; + Big5Freq[27][101] = 362; + Big5Freq[9][140] = 361; + Big5Freq[3][89] = 360; + Big5Freq[18][148] = 359; + Big5Freq[4][69] = 358; + Big5Freq[16][49] = 357; + Big5Freq[6][117] = 356; + Big5Freq[36][55] = 355; + Big5Freq[5][123] = 354; + Big5Freq[4][126] = 353; + Big5Freq[4][119] = 352; + Big5Freq[9][95] = 351; + Big5Freq[5][24] = 350; + Big5Freq[16][133] = 349; + Big5Freq[10][134] = 348; + Big5Freq[26][59] = 347; + Big5Freq[6][41] = 346; + Big5Freq[6][146] = 345; + Big5Freq[19][24] = 344; + Big5Freq[5][113] = 343; + Big5Freq[10][118] = 342; + Big5Freq[34][151] = 341; + Big5Freq[9][72] = 340; + Big5Freq[31][25] = 339; + Big5Freq[18][126] = 338; + Big5Freq[18][28] = 337; + Big5Freq[4][153] = 336; + Big5Freq[3][84] = 335; + Big5Freq[21][18] = 334; + Big5Freq[25][129] = 333; + Big5Freq[6][107] = 332; + Big5Freq[12][25] = 331; + Big5Freq[17][109] = 330; + Big5Freq[7][76] = 329; + Big5Freq[15][15] = 328; + Big5Freq[4][14] = 327; + Big5Freq[23][88] = 326; + Big5Freq[18][2] = 325; + Big5Freq[6][88] = 324; + Big5Freq[16][84] = 323; + Big5Freq[12][48] = 322; + Big5Freq[7][68] = 321; + Big5Freq[5][50] = 320; + Big5Freq[13][54] = 319; + Big5Freq[7][98] = 318; + Big5Freq[11][6] = 317; + Big5Freq[9][80] = 316; + Big5Freq[16][41] = 315; + Big5Freq[7][43] = 314; + Big5Freq[28][117] = 313; + Big5Freq[3][51] = 312; + Big5Freq[7][3] = 311; + Big5Freq[20][81] = 310; + Big5Freq[4][2] = 309; + Big5Freq[11][16] = 308; + Big5Freq[10][4] = 307; + Big5Freq[10][119] = 306; + Big5Freq[6][142] = 305; + Big5Freq[18][51] = 304; + Big5Freq[8][144] = 303; + Big5Freq[10][65] = 302; + Big5Freq[11][64] = 301; + Big5Freq[11][130] = 300; + Big5Freq[9][92] = 299; + Big5Freq[18][29] = 298; + Big5Freq[18][78] = 297; + Big5Freq[18][151] = 296; + Big5Freq[33][127] = 295; + Big5Freq[35][113] = 294; + Big5Freq[10][155] = 293; + Big5Freq[3][76] = 292; + Big5Freq[36][123] = 291; + Big5Freq[13][143] = 290; + Big5Freq[5][135] = 289; + Big5Freq[23][116] = 288; + Big5Freq[6][101] = 287; + Big5Freq[14][74] = 286; + Big5Freq[7][153] = 285; + Big5Freq[3][101] = 284; + Big5Freq[9][74] = 283; + Big5Freq[3][156] = 282; + Big5Freq[4][147] = 281; + Big5Freq[9][12] = 280; + Big5Freq[18][133] = 279; + Big5Freq[4][0] = 278; + Big5Freq[7][155] = 277; + Big5Freq[9][144] = 276; + Big5Freq[23][49] = 275; + Big5Freq[5][89] = 274; + Big5Freq[10][11] = 273; + Big5Freq[3][110] = 272; + Big5Freq[3][40] = 271; + Big5Freq[29][115] = 270; + Big5Freq[9][100] = 269; + Big5Freq[21][67] = 268; + Big5Freq[23][145] = 267; + Big5Freq[10][47] = 266; + Big5Freq[4][31] = 265; + Big5Freq[4][81] = 264; + Big5Freq[22][62] = 263; + Big5Freq[4][28] = 262; + Big5Freq[27][39] = 261; + Big5Freq[27][54] = 260; + Big5Freq[32][46] = 259; + Big5Freq[4][76] = 258; + Big5Freq[26][15] = 257; + Big5Freq[12][154] = 256; + Big5Freq[9][150] = 255; + Big5Freq[15][17] = 254; + Big5Freq[5][129] = 253; + Big5Freq[10][40] = 252; + Big5Freq[13][37] = 251; + Big5Freq[31][104] = 250; + Big5Freq[3][152] = 249; + Big5Freq[5][22] = 248; + Big5Freq[8][48] = 247; + Big5Freq[4][74] = 246; + Big5Freq[6][17] = 245; + Big5Freq[30][82] = 244; + Big5Freq[4][116] = 243; + Big5Freq[16][42] = 242; + Big5Freq[5][55] = 241; + Big5Freq[4][64] = 240; + Big5Freq[14][19] = 239; + Big5Freq[35][82] = 238; + Big5Freq[30][139] = 237; + Big5Freq[26][152] = 236; + Big5Freq[32][32] = 235; + Big5Freq[21][102] = 234; + Big5Freq[10][131] = 233; + Big5Freq[9][128] = 232; + Big5Freq[3][87] = 231; + Big5Freq[4][51] = 230; + Big5Freq[10][15] = 229; + Big5Freq[4][150] = 228; + Big5Freq[7][4] = 227; + Big5Freq[7][51] = 226; + Big5Freq[7][157] = 225; + Big5Freq[4][146] = 224; + Big5Freq[4][91] = 223; + Big5Freq[7][13] = 222; + Big5Freq[17][116] = 221; + Big5Freq[23][21] = 220; + Big5Freq[5][106] = 219; + Big5Freq[14][100] = 218; + Big5Freq[10][152] = 217; + Big5Freq[14][89] = 216; + Big5Freq[6][138] = 215; + Big5Freq[12][157] = 214; + Big5Freq[10][102] = 213; + Big5Freq[19][94] = 212; + Big5Freq[7][74] = 211; + Big5Freq[18][128] = 210; + Big5Freq[27][111] = 209; + Big5Freq[11][57] = 208; + Big5Freq[3][131] = 207; + Big5Freq[30][23] = 206; + Big5Freq[30][126] = 205; + Big5Freq[4][36] = 204; + Big5Freq[26][124] = 203; + Big5Freq[4][19] = 202; + Big5Freq[9][152] = 201; + /* + * Big5Freq[5][0] = 200; Big5Freq[26][57] = 199; Big5Freq[13][155] = + * 198; Big5Freq[3][38] = 197; Big5Freq[9][155] = 196; Big5Freq[28][53] + * = 195; Big5Freq[15][71] = 194; Big5Freq[21][95] = 193; + * Big5Freq[15][112] = 192; Big5Freq[14][138] = 191; Big5Freq[8][18] = + * 190; Big5Freq[20][151] = 189; Big5Freq[37][27] = 188; + * Big5Freq[32][48] = 187; Big5Freq[23][66] = 186; Big5Freq[9][2] = 185; + * Big5Freq[13][133] = 184; Big5Freq[7][127] = 183; Big5Freq[3][11] = + * 182; Big5Freq[12][118] = 181; Big5Freq[13][101] = 180; + * Big5Freq[30][153] = 179; Big5Freq[4][65] = 178; Big5Freq[5][25] = + * 177; Big5Freq[5][140] = 176; Big5Freq[6][25] = 175; Big5Freq[4][52] = + * 174; Big5Freq[30][156] = 173; Big5Freq[16][13] = 172; Big5Freq[21][8] + * = 171; Big5Freq[19][74] = 170; Big5Freq[15][145] = 169; + * Big5Freq[9][15] = 168; Big5Freq[13][82] = 167; Big5Freq[26][86] = + * 166; Big5Freq[18][52] = 165; Big5Freq[6][109] = 164; Big5Freq[10][99] + * = 163; Big5Freq[18][101] = 162; Big5Freq[25][49] = 161; + * Big5Freq[31][79] = 160; Big5Freq[28][20] = 159; Big5Freq[12][115] = + * 158; Big5Freq[15][66] = 157; Big5Freq[11][104] = 156; + * Big5Freq[23][106] = 155; Big5Freq[34][157] = 154; Big5Freq[32][94] = + * 153; Big5Freq[29][88] = 152; Big5Freq[10][46] = 151; + * Big5Freq[13][118] = 150; Big5Freq[20][37] = 149; Big5Freq[12][30] = + * 148; Big5Freq[21][4] = 147; Big5Freq[16][33] = 146; Big5Freq[13][52] + * = 145; Big5Freq[4][7] = 144; Big5Freq[21][49] = 143; Big5Freq[3][27] + * = 142; Big5Freq[16][91] = 141; Big5Freq[5][155] = 140; + * Big5Freq[29][130] = 139; Big5Freq[3][125] = 138; Big5Freq[14][26] = + * 137; Big5Freq[15][39] = 136; Big5Freq[24][110] = 135; + * Big5Freq[7][141] = 134; Big5Freq[21][15] = 133; Big5Freq[32][104] = + * 132; Big5Freq[8][31] = 131; Big5Freq[34][112] = 130; Big5Freq[10][75] + * = 129; Big5Freq[21][23] = 128; Big5Freq[34][131] = 127; + * Big5Freq[12][3] = 126; Big5Freq[10][62] = 125; Big5Freq[9][120] = + * 124; Big5Freq[32][149] = 123; Big5Freq[8][44] = 122; Big5Freq[24][2] + * = 121; Big5Freq[6][148] = 120; Big5Freq[15][103] = 119; + * Big5Freq[36][54] = 118; Big5Freq[36][134] = 117; Big5Freq[11][7] = + * 116; Big5Freq[3][90] = 115; Big5Freq[36][73] = 114; Big5Freq[8][102] + * = 113; Big5Freq[12][87] = 112; Big5Freq[25][64] = 111; Big5Freq[9][1] + * = 110; Big5Freq[24][121] = 109; Big5Freq[5][75] = 108; + * Big5Freq[17][83] = 107; Big5Freq[18][57] = 106; Big5Freq[8][95] = + * 105; Big5Freq[14][36] = 104; Big5Freq[28][113] = 103; + * Big5Freq[12][56] = 102; Big5Freq[14][61] = 101; Big5Freq[25][138] = + * 100; Big5Freq[4][34] = 99; Big5Freq[11][152] = 98; Big5Freq[35][0] = + * 97; Big5Freq[4][15] = 96; Big5Freq[8][82] = 95; Big5Freq[20][73] = + * 94; Big5Freq[25][52] = 93; Big5Freq[24][6] = 92; Big5Freq[21][78] = + * 91; Big5Freq[17][32] = 90; Big5Freq[17][91] = 89; Big5Freq[5][76] = + * 88; Big5Freq[15][60] = 87; Big5Freq[15][150] = 86; Big5Freq[5][80] = + * 85; Big5Freq[15][81] = 84; Big5Freq[28][108] = 83; Big5Freq[18][14] = + * 82; Big5Freq[19][109] = 81; Big5Freq[28][133] = 80; Big5Freq[21][97] + * = 79; Big5Freq[5][105] = 78; Big5Freq[18][114] = 77; Big5Freq[16][95] + * = 76; Big5Freq[5][51] = 75; Big5Freq[3][148] = 74; Big5Freq[22][102] + * = 73; Big5Freq[4][123] = 72; Big5Freq[8][88] = 71; Big5Freq[25][111] + * = 70; Big5Freq[8][149] = 69; Big5Freq[9][48] = 68; Big5Freq[16][126] + * = 67; Big5Freq[33][150] = 66; Big5Freq[9][54] = 65; Big5Freq[29][104] + * = 64; Big5Freq[3][3] = 63; Big5Freq[11][49] = 62; Big5Freq[24][109] = + * 61; Big5Freq[28][116] = 60; Big5Freq[34][113] = 59; Big5Freq[5][3] = + * 58; Big5Freq[21][106] = 57; Big5Freq[4][98] = 56; Big5Freq[12][135] = + * 55; Big5Freq[16][101] = 54; Big5Freq[12][147] = 53; Big5Freq[27][55] + * = 52; Big5Freq[3][5] = 51; Big5Freq[11][101] = 50; Big5Freq[16][157] + * = 49; Big5Freq[22][114] = 48; Big5Freq[18][46] = 47; Big5Freq[4][29] + * = 46; Big5Freq[8][103] = 45; Big5Freq[16][151] = 44; Big5Freq[8][29] + * = 43; Big5Freq[15][114] = 42; Big5Freq[22][70] = 41; + * Big5Freq[13][121] = 40; Big5Freq[7][112] = 39; Big5Freq[20][83] = 38; + * Big5Freq[3][36] = 37; Big5Freq[10][103] = 36; Big5Freq[3][96] = 35; + * Big5Freq[21][79] = 34; Big5Freq[25][120] = 33; Big5Freq[29][121] = + * 32; Big5Freq[23][71] = 31; Big5Freq[21][22] = 30; Big5Freq[18][89] = + * 29; Big5Freq[25][104] = 28; Big5Freq[10][124] = 27; Big5Freq[26][4] = + * 26; Big5Freq[21][136] = 25; Big5Freq[6][112] = 24; Big5Freq[12][103] + * = 23; Big5Freq[17][66] = 22; Big5Freq[13][151] = 21; + * Big5Freq[33][152] = 20; Big5Freq[11][148] = 19; Big5Freq[13][57] = + * 18; Big5Freq[13][41] = 17; Big5Freq[7][60] = 16; Big5Freq[21][29] = + * 15; Big5Freq[9][157] = 14; Big5Freq[24][95] = 13; Big5Freq[15][148] = + * 12; Big5Freq[15][122] = 11; Big5Freq[6][125] = 10; Big5Freq[11][25] = + * 9; Big5Freq[20][55] = 8; Big5Freq[19][84] = 7; Big5Freq[21][82] = 6; + * Big5Freq[24][3] = 5; Big5Freq[13][70] = 4; Big5Freq[6][21] = 3; + * Big5Freq[21][86] = 2; Big5Freq[12][23] = 1; Big5Freq[3][85] = 0; + * EUC_TWFreq[45][90] = 600; + */ + Big5PFreq[41][122] = 600; + Big5PFreq[35][0] = 599; + Big5PFreq[43][15] = 598; + Big5PFreq[35][99] = 597; + Big5PFreq[35][6] = 596; + Big5PFreq[35][8] = 595; + Big5PFreq[38][154] = 594; + Big5PFreq[37][34] = 593; + Big5PFreq[37][115] = 592; + Big5PFreq[36][12] = 591; + Big5PFreq[18][77] = 590; + Big5PFreq[35][100] = 589; + Big5PFreq[35][42] = 588; + Big5PFreq[120][75] = 587; + Big5PFreq[35][23] = 586; + Big5PFreq[13][72] = 585; + Big5PFreq[0][67] = 584; + Big5PFreq[39][172] = 583; + Big5PFreq[22][182] = 582; + Big5PFreq[15][186] = 581; + Big5PFreq[15][165] = 580; + Big5PFreq[35][44] = 579; + Big5PFreq[40][13] = 578; + Big5PFreq[38][1] = 577; + Big5PFreq[37][33] = 576; + Big5PFreq[36][24] = 575; + Big5PFreq[56][4] = 574; + Big5PFreq[35][29] = 573; + Big5PFreq[9][96] = 572; + Big5PFreq[37][62] = 571; + Big5PFreq[48][47] = 570; + Big5PFreq[51][14] = 569; + Big5PFreq[39][122] = 568; + Big5PFreq[44][46] = 567; + Big5PFreq[35][21] = 566; + Big5PFreq[36][8] = 565; + Big5PFreq[36][141] = 564; + Big5PFreq[3][81] = 563; + Big5PFreq[37][155] = 562; + Big5PFreq[42][84] = 561; + Big5PFreq[36][40] = 560; + Big5PFreq[35][103] = 559; + Big5PFreq[11][84] = 558; + Big5PFreq[45][33] = 557; + Big5PFreq[121][79] = 556; + Big5PFreq[2][77] = 555; + Big5PFreq[36][41] = 554; + Big5PFreq[37][47] = 553; + Big5PFreq[39][125] = 552; + Big5PFreq[37][26] = 551; + Big5PFreq[35][48] = 550; + Big5PFreq[35][28] = 549; + Big5PFreq[35][159] = 548; + Big5PFreq[37][40] = 547; + Big5PFreq[35][145] = 546; + Big5PFreq[37][147] = 545; + Big5PFreq[46][160] = 544; + Big5PFreq[37][46] = 543; + Big5PFreq[50][99] = 542; + Big5PFreq[52][13] = 541; + Big5PFreq[10][82] = 540; + Big5PFreq[35][169] = 539; + Big5PFreq[35][31] = 538; + Big5PFreq[47][31] = 537; + Big5PFreq[18][79] = 536; + Big5PFreq[16][113] = 535; + Big5PFreq[37][104] = 534; + Big5PFreq[39][134] = 533; + Big5PFreq[36][53] = 532; + Big5PFreq[38][0] = 531; + Big5PFreq[4][86] = 530; + Big5PFreq[54][17] = 529; + Big5PFreq[43][157] = 528; + Big5PFreq[35][165] = 527; + Big5PFreq[69][147] = 526; + Big5PFreq[117][95] = 525; + Big5PFreq[35][162] = 524; + Big5PFreq[35][17] = 523; + Big5PFreq[36][142] = 522; + Big5PFreq[36][4] = 521; + Big5PFreq[37][166] = 520; + Big5PFreq[35][168] = 519; + Big5PFreq[35][19] = 518; + Big5PFreq[37][48] = 517; + Big5PFreq[42][37] = 516; + Big5PFreq[40][146] = 515; + Big5PFreq[36][123] = 514; + Big5PFreq[22][41] = 513; + Big5PFreq[20][119] = 512; + Big5PFreq[2][74] = 511; + Big5PFreq[44][113] = 510; + Big5PFreq[35][125] = 509; + Big5PFreq[37][16] = 508; + Big5PFreq[35][20] = 507; + Big5PFreq[35][55] = 506; + Big5PFreq[37][145] = 505; + Big5PFreq[0][88] = 504; + Big5PFreq[3][94] = 503; + Big5PFreq[6][65] = 502; + Big5PFreq[26][15] = 501; + Big5PFreq[41][126] = 500; + Big5PFreq[36][129] = 499; + Big5PFreq[31][75] = 498; + Big5PFreq[19][61] = 497; + Big5PFreq[35][128] = 496; + Big5PFreq[29][79] = 495; + Big5PFreq[36][62] = 494; + Big5PFreq[37][189] = 493; + Big5PFreq[39][109] = 492; + Big5PFreq[39][135] = 491; + Big5PFreq[72][15] = 490; + Big5PFreq[47][106] = 489; + Big5PFreq[54][14] = 488; + Big5PFreq[24][52] = 487; + Big5PFreq[38][162] = 486; + Big5PFreq[41][43] = 485; + Big5PFreq[37][121] = 484; + Big5PFreq[14][66] = 483; + Big5PFreq[37][30] = 482; + Big5PFreq[35][7] = 481; + Big5PFreq[49][58] = 480; + Big5PFreq[43][188] = 479; + Big5PFreq[24][66] = 478; + Big5PFreq[35][171] = 477; + Big5PFreq[40][186] = 476; + Big5PFreq[39][164] = 475; + Big5PFreq[78][186] = 474; + Big5PFreq[8][72] = 473; + Big5PFreq[36][190] = 472; + Big5PFreq[35][53] = 471; + Big5PFreq[35][54] = 470; + Big5PFreq[22][159] = 469; + Big5PFreq[35][9] = 468; + Big5PFreq[41][140] = 467; + Big5PFreq[37][22] = 466; + Big5PFreq[48][97] = 465; + Big5PFreq[50][97] = 464; + Big5PFreq[36][127] = 463; + Big5PFreq[37][23] = 462; + Big5PFreq[40][55] = 461; + Big5PFreq[35][43] = 460; + Big5PFreq[26][22] = 459; + Big5PFreq[35][15] = 458; + Big5PFreq[72][179] = 457; + Big5PFreq[20][129] = 456; + Big5PFreq[52][101] = 455; + Big5PFreq[35][12] = 454; + Big5PFreq[42][156] = 453; + Big5PFreq[15][157] = 452; + Big5PFreq[50][140] = 451; + Big5PFreq[26][28] = 450; + Big5PFreq[54][51] = 449; + Big5PFreq[35][112] = 448; + Big5PFreq[36][116] = 447; + Big5PFreq[42][11] = 446; + Big5PFreq[37][172] = 445; + Big5PFreq[37][29] = 444; + Big5PFreq[44][107] = 443; + Big5PFreq[50][17] = 442; + Big5PFreq[39][107] = 441; + Big5PFreq[19][109] = 440; + Big5PFreq[36][60] = 439; + Big5PFreq[49][132] = 438; + Big5PFreq[26][16] = 437; + Big5PFreq[43][155] = 436; + Big5PFreq[37][120] = 435; + Big5PFreq[15][159] = 434; + Big5PFreq[43][6] = 433; + Big5PFreq[45][188] = 432; + Big5PFreq[35][38] = 431; + Big5PFreq[39][143] = 430; + Big5PFreq[48][144] = 429; + Big5PFreq[37][168] = 428; + Big5PFreq[37][1] = 427; + Big5PFreq[36][109] = 426; + Big5PFreq[46][53] = 425; + Big5PFreq[38][54] = 424; + Big5PFreq[36][0] = 423; + Big5PFreq[72][33] = 422; + Big5PFreq[42][8] = 421; + Big5PFreq[36][31] = 420; + Big5PFreq[35][150] = 419; + Big5PFreq[118][93] = 418; + Big5PFreq[37][61] = 417; + Big5PFreq[0][85] = 416; + Big5PFreq[36][27] = 415; + Big5PFreq[35][134] = 414; + Big5PFreq[36][145] = 413; + Big5PFreq[6][96] = 412; + Big5PFreq[36][14] = 411; + Big5PFreq[16][36] = 410; + Big5PFreq[15][175] = 409; + Big5PFreq[35][10] = 408; + Big5PFreq[36][189] = 407; + Big5PFreq[35][51] = 406; + Big5PFreq[35][109] = 405; + Big5PFreq[35][147] = 404; + Big5PFreq[35][180] = 403; + Big5PFreq[72][5] = 402; + Big5PFreq[36][107] = 401; + Big5PFreq[49][116] = 400; + Big5PFreq[73][30] = 399; + Big5PFreq[6][90] = 398; + Big5PFreq[2][70] = 397; + Big5PFreq[17][141] = 396; + Big5PFreq[35][62] = 395; + Big5PFreq[16][180] = 394; + Big5PFreq[4][91] = 393; + Big5PFreq[15][171] = 392; + Big5PFreq[35][177] = 391; + Big5PFreq[37][173] = 390; + Big5PFreq[16][121] = 389; + Big5PFreq[35][5] = 388; + Big5PFreq[46][122] = 387; + Big5PFreq[40][138] = 386; + Big5PFreq[50][49] = 385; + Big5PFreq[36][152] = 384; + Big5PFreq[13][43] = 383; + Big5PFreq[9][88] = 382; + Big5PFreq[36][159] = 381; + Big5PFreq[27][62] = 380; + Big5PFreq[40][18] = 379; + Big5PFreq[17][129] = 378; + Big5PFreq[43][97] = 377; + Big5PFreq[13][131] = 376; + Big5PFreq[46][107] = 375; + Big5PFreq[60][64] = 374; + Big5PFreq[36][179] = 373; + Big5PFreq[37][55] = 372; + Big5PFreq[41][173] = 371; + Big5PFreq[44][172] = 370; + Big5PFreq[23][187] = 369; + Big5PFreq[36][149] = 368; + Big5PFreq[17][125] = 367; + Big5PFreq[55][180] = 366; + Big5PFreq[51][129] = 365; + Big5PFreq[36][51] = 364; + Big5PFreq[37][122] = 363; + Big5PFreq[48][32] = 362; + Big5PFreq[51][99] = 361; + Big5PFreq[54][16] = 360; + Big5PFreq[41][183] = 359; + Big5PFreq[37][179] = 358; + Big5PFreq[38][179] = 357; + Big5PFreq[35][143] = 356; + Big5PFreq[37][24] = 355; + Big5PFreq[40][177] = 354; + Big5PFreq[47][117] = 353; + Big5PFreq[39][52] = 352; + Big5PFreq[22][99] = 351; + Big5PFreq[40][142] = 350; + Big5PFreq[36][49] = 349; + Big5PFreq[38][17] = 348; + Big5PFreq[39][188] = 347; + Big5PFreq[36][186] = 346; + Big5PFreq[35][189] = 345; + Big5PFreq[41][7] = 344; + Big5PFreq[18][91] = 343; + Big5PFreq[43][137] = 342; + Big5PFreq[35][142] = 341; + Big5PFreq[35][117] = 340; + Big5PFreq[39][138] = 339; + Big5PFreq[16][59] = 338; + Big5PFreq[39][174] = 337; + Big5PFreq[55][145] = 336; + Big5PFreq[37][21] = 335; + Big5PFreq[36][180] = 334; + Big5PFreq[37][156] = 333; + Big5PFreq[49][13] = 332; + Big5PFreq[41][107] = 331; + Big5PFreq[36][56] = 330; + Big5PFreq[53][8] = 329; + Big5PFreq[22][114] = 328; + Big5PFreq[5][95] = 327; + Big5PFreq[37][0] = 326; + Big5PFreq[26][183] = 325; + Big5PFreq[22][66] = 324; + Big5PFreq[35][58] = 323; + Big5PFreq[48][117] = 322; + Big5PFreq[36][102] = 321; + Big5PFreq[22][122] = 320; + Big5PFreq[35][11] = 319; + Big5PFreq[46][19] = 318; + Big5PFreq[22][49] = 317; + Big5PFreq[48][166] = 316; + Big5PFreq[41][125] = 315; + Big5PFreq[41][1] = 314; + Big5PFreq[35][178] = 313; + Big5PFreq[41][12] = 312; + Big5PFreq[26][167] = 311; + Big5PFreq[42][152] = 310; + Big5PFreq[42][46] = 309; + Big5PFreq[42][151] = 308; + Big5PFreq[20][135] = 307; + Big5PFreq[37][162] = 306; + Big5PFreq[37][50] = 305; + Big5PFreq[22][185] = 304; + Big5PFreq[36][166] = 303; + Big5PFreq[19][40] = 302; + Big5PFreq[22][107] = 301; + Big5PFreq[22][102] = 300; + Big5PFreq[57][162] = 299; + Big5PFreq[22][124] = 298; + Big5PFreq[37][138] = 297; + Big5PFreq[37][25] = 296; + Big5PFreq[0][69] = 295; + Big5PFreq[43][172] = 294; + Big5PFreq[42][167] = 293; + Big5PFreq[35][120] = 292; + Big5PFreq[41][128] = 291; + Big5PFreq[2][88] = 290; + Big5PFreq[20][123] = 289; + Big5PFreq[35][123] = 288; + Big5PFreq[36][28] = 287; + Big5PFreq[42][188] = 286; + Big5PFreq[42][164] = 285; + Big5PFreq[42][4] = 284; + Big5PFreq[43][57] = 283; + Big5PFreq[39][3] = 282; + Big5PFreq[42][3] = 281; + Big5PFreq[57][158] = 280; + Big5PFreq[35][146] = 279; + Big5PFreq[24][54] = 278; + Big5PFreq[13][110] = 277; + Big5PFreq[23][132] = 276; + Big5PFreq[26][102] = 275; + Big5PFreq[55][178] = 274; + Big5PFreq[17][117] = 273; + Big5PFreq[41][161] = 272; + Big5PFreq[38][150] = 271; + Big5PFreq[10][71] = 270; + Big5PFreq[47][60] = 269; + Big5PFreq[16][114] = 268; + Big5PFreq[21][47] = 267; + Big5PFreq[39][101] = 266; + Big5PFreq[18][45] = 265; + Big5PFreq[40][121] = 264; + Big5PFreq[45][41] = 263; + Big5PFreq[22][167] = 262; + Big5PFreq[26][149] = 261; + Big5PFreq[15][189] = 260; + Big5PFreq[41][177] = 259; + Big5PFreq[46][36] = 258; + Big5PFreq[20][40] = 257; + Big5PFreq[41][54] = 256; + Big5PFreq[3][87] = 255; + Big5PFreq[40][16] = 254; + Big5PFreq[42][15] = 253; + Big5PFreq[11][83] = 252; + Big5PFreq[0][94] = 251; + Big5PFreq[122][81] = 250; + Big5PFreq[41][26] = 249; + Big5PFreq[36][34] = 248; + Big5PFreq[44][148] = 247; + Big5PFreq[35][3] = 246; + Big5PFreq[36][114] = 245; + Big5PFreq[42][112] = 244; + Big5PFreq[35][183] = 243; + Big5PFreq[49][73] = 242; + Big5PFreq[39][2] = 241; + Big5PFreq[38][121] = 240; + Big5PFreq[44][114] = 239; + Big5PFreq[49][32] = 238; + Big5PFreq[1][65] = 237; + Big5PFreq[38][25] = 236; + Big5PFreq[39][4] = 235; + Big5PFreq[42][62] = 234; + Big5PFreq[35][40] = 233; + Big5PFreq[24][2] = 232; + Big5PFreq[53][49] = 231; + Big5PFreq[41][133] = 230; + Big5PFreq[43][134] = 229; + Big5PFreq[3][83] = 228; + Big5PFreq[38][158] = 227; + Big5PFreq[24][17] = 226; + Big5PFreq[52][59] = 225; + Big5PFreq[38][41] = 224; + Big5PFreq[37][127] = 223; + Big5PFreq[22][175] = 222; + Big5PFreq[44][30] = 221; + Big5PFreq[47][178] = 220; + Big5PFreq[43][99] = 219; + Big5PFreq[19][4] = 218; + Big5PFreq[37][97] = 217; + Big5PFreq[38][181] = 216; + Big5PFreq[45][103] = 215; + Big5PFreq[1][86] = 214; + Big5PFreq[40][15] = 213; + Big5PFreq[22][136] = 212; + Big5PFreq[75][165] = 211; + Big5PFreq[36][15] = 210; + Big5PFreq[46][80] = 209; + Big5PFreq[59][55] = 208; + Big5PFreq[37][108] = 207; + Big5PFreq[21][109] = 206; + Big5PFreq[24][165] = 205; + Big5PFreq[79][158] = 204; + Big5PFreq[44][139] = 203; + Big5PFreq[36][124] = 202; + Big5PFreq[42][185] = 201; + Big5PFreq[39][186] = 200; + Big5PFreq[22][128] = 199; + Big5PFreq[40][44] = 198; + Big5PFreq[41][105] = 197; + Big5PFreq[1][70] = 196; + Big5PFreq[1][68] = 195; + Big5PFreq[53][22] = 194; + Big5PFreq[36][54] = 193; + Big5PFreq[47][147] = 192; + Big5PFreq[35][36] = 191; + Big5PFreq[35][185] = 190; + Big5PFreq[45][37] = 189; + Big5PFreq[43][163] = 188; + Big5PFreq[56][115] = 187; + Big5PFreq[38][164] = 186; + Big5PFreq[35][141] = 185; + Big5PFreq[42][132] = 184; + Big5PFreq[46][120] = 183; + Big5PFreq[69][142] = 182; + Big5PFreq[38][175] = 181; + Big5PFreq[22][112] = 180; + Big5PFreq[38][142] = 179; + Big5PFreq[40][37] = 178; + Big5PFreq[37][109] = 177; + Big5PFreq[40][144] = 176; + Big5PFreq[44][117] = 175; + Big5PFreq[35][181] = 174; + Big5PFreq[26][105] = 173; + Big5PFreq[16][48] = 172; + Big5PFreq[44][122] = 171; + Big5PFreq[12][86] = 170; + Big5PFreq[84][53] = 169; + Big5PFreq[17][44] = 168; + Big5PFreq[59][54] = 167; + Big5PFreq[36][98] = 166; + Big5PFreq[45][115] = 165; + Big5PFreq[73][9] = 164; + Big5PFreq[44][123] = 163; + Big5PFreq[37][188] = 162; + Big5PFreq[51][117] = 161; + Big5PFreq[15][156] = 160; + Big5PFreq[36][155] = 159; + Big5PFreq[44][25] = 158; + Big5PFreq[38][12] = 157; + Big5PFreq[38][140] = 156; + Big5PFreq[23][4] = 155; + Big5PFreq[45][149] = 154; + Big5PFreq[22][189] = 153; + Big5PFreq[38][147] = 152; + Big5PFreq[27][5] = 151; + Big5PFreq[22][42] = 150; + Big5PFreq[3][68] = 149; + Big5PFreq[39][51] = 148; + Big5PFreq[36][29] = 147; + Big5PFreq[20][108] = 146; + Big5PFreq[50][57] = 145; + Big5PFreq[55][104] = 144; + Big5PFreq[22][46] = 143; + Big5PFreq[18][164] = 142; + Big5PFreq[50][159] = 141; + Big5PFreq[85][131] = 140; + Big5PFreq[26][79] = 139; + Big5PFreq[38][100] = 138; + Big5PFreq[53][112] = 137; + Big5PFreq[20][190] = 136; + Big5PFreq[14][69] = 135; + Big5PFreq[23][11] = 134; + Big5PFreq[40][114] = 133; + Big5PFreq[40][148] = 132; + Big5PFreq[53][130] = 131; + Big5PFreq[36][2] = 130; + Big5PFreq[66][82] = 129; + Big5PFreq[45][166] = 128; + Big5PFreq[4][88] = 127; + Big5PFreq[16][57] = 126; + Big5PFreq[22][116] = 125; + Big5PFreq[36][108] = 124; + Big5PFreq[13][48] = 123; + Big5PFreq[54][12] = 122; + Big5PFreq[40][136] = 121; + Big5PFreq[36][128] = 120; + Big5PFreq[23][6] = 119; + Big5PFreq[38][125] = 118; + Big5PFreq[45][154] = 117; + Big5PFreq[51][127] = 116; + Big5PFreq[44][163] = 115; + Big5PFreq[16][173] = 114; + Big5PFreq[43][49] = 113; + Big5PFreq[20][112] = 112; + Big5PFreq[15][168] = 111; + Big5PFreq[35][129] = 110; + Big5PFreq[20][45] = 109; + Big5PFreq[38][10] = 108; + Big5PFreq[57][171] = 107; + Big5PFreq[44][190] = 106; + Big5PFreq[40][56] = 105; + Big5PFreq[36][156] = 104; + Big5PFreq[3][88] = 103; + Big5PFreq[50][122] = 102; + Big5PFreq[36][7] = 101; + Big5PFreq[39][43] = 100; + Big5PFreq[15][166] = 99; + Big5PFreq[42][136] = 98; + Big5PFreq[22][131] = 97; + Big5PFreq[44][23] = 96; + Big5PFreq[54][147] = 95; + Big5PFreq[41][32] = 94; + Big5PFreq[23][121] = 93; + Big5PFreq[39][108] = 92; + Big5PFreq[2][78] = 91; + Big5PFreq[40][155] = 90; + Big5PFreq[55][51] = 89; + Big5PFreq[19][34] = 88; + Big5PFreq[48][128] = 87; + Big5PFreq[48][159] = 86; + Big5PFreq[20][70] = 85; + Big5PFreq[34][71] = 84; + Big5PFreq[16][31] = 83; + Big5PFreq[42][157] = 82; + Big5PFreq[20][44] = 81; + Big5PFreq[11][92] = 80; + Big5PFreq[44][180] = 79; + Big5PFreq[84][33] = 78; + Big5PFreq[16][116] = 77; + Big5PFreq[61][163] = 76; + Big5PFreq[35][164] = 75; + Big5PFreq[36][42] = 74; + Big5PFreq[13][40] = 73; + Big5PFreq[43][176] = 72; + Big5PFreq[2][66] = 71; + Big5PFreq[20][133] = 70; + Big5PFreq[36][65] = 69; + Big5PFreq[38][33] = 68; + Big5PFreq[12][91] = 67; + Big5PFreq[36][26] = 66; + Big5PFreq[15][174] = 65; + Big5PFreq[77][32] = 64; + Big5PFreq[16][1] = 63; + Big5PFreq[25][86] = 62; + Big5PFreq[17][13] = 61; + Big5PFreq[5][75] = 60; + Big5PFreq[36][52] = 59; + Big5PFreq[51][164] = 58; + Big5PFreq[12][85] = 57; + Big5PFreq[39][168] = 56; + Big5PFreq[43][16] = 55; + Big5PFreq[40][69] = 54; + Big5PFreq[26][108] = 53; + Big5PFreq[51][56] = 52; + Big5PFreq[16][37] = 51; + Big5PFreq[40][29] = 50; + Big5PFreq[46][171] = 49; + Big5PFreq[40][128] = 48; + Big5PFreq[72][114] = 47; + Big5PFreq[21][103] = 46; + Big5PFreq[22][44] = 45; + Big5PFreq[40][115] = 44; + Big5PFreq[43][7] = 43; + Big5PFreq[43][153] = 42; + Big5PFreq[17][20] = 41; + Big5PFreq[16][49] = 40; + Big5PFreq[36][57] = 39; + Big5PFreq[18][38] = 38; + Big5PFreq[45][184] = 37; + Big5PFreq[37][167] = 36; + Big5PFreq[26][106] = 35; + Big5PFreq[61][121] = 34; + Big5PFreq[89][140] = 33; + Big5PFreq[46][61] = 32; + Big5PFreq[39][163] = 31; + Big5PFreq[40][62] = 30; + Big5PFreq[38][165] = 29; + Big5PFreq[47][37] = 28; + Big5PFreq[18][155] = 27; + Big5PFreq[20][33] = 26; + Big5PFreq[29][90] = 25; + Big5PFreq[20][103] = 24; + Big5PFreq[37][51] = 23; + Big5PFreq[57][0] = 22; + Big5PFreq[40][31] = 21; + Big5PFreq[45][32] = 20; + Big5PFreq[59][23] = 19; + Big5PFreq[18][47] = 18; + Big5PFreq[45][134] = 17; + Big5PFreq[37][59] = 16; + Big5PFreq[21][128] = 15; + Big5PFreq[36][106] = 14; + Big5PFreq[31][39] = 13; + Big5PFreq[40][182] = 12; + Big5PFreq[52][155] = 11; + Big5PFreq[42][166] = 10; + Big5PFreq[35][27] = 9; + Big5PFreq[38][3] = 8; + Big5PFreq[13][44] = 7; + Big5PFreq[58][157] = 6; + Big5PFreq[47][51] = 5; + Big5PFreq[41][37] = 4; + Big5PFreq[41][172] = 3; + Big5PFreq[51][165] = 2; + Big5PFreq[15][161] = 1; + Big5PFreq[24][181] = 0; + EUC_TWFreq[48][49] = 599; + EUC_TWFreq[35][65] = 598; + EUC_TWFreq[41][27] = 597; + EUC_TWFreq[35][0] = 596; + EUC_TWFreq[39][19] = 595; + EUC_TWFreq[35][42] = 594; + EUC_TWFreq[38][66] = 593; + EUC_TWFreq[35][8] = 592; + EUC_TWFreq[35][6] = 591; + EUC_TWFreq[35][66] = 590; + EUC_TWFreq[43][14] = 589; + EUC_TWFreq[69][80] = 588; + EUC_TWFreq[50][48] = 587; + EUC_TWFreq[36][71] = 586; + EUC_TWFreq[37][10] = 585; + EUC_TWFreq[60][52] = 584; + EUC_TWFreq[51][21] = 583; + EUC_TWFreq[40][2] = 582; + EUC_TWFreq[67][35] = 581; + EUC_TWFreq[38][78] = 580; + EUC_TWFreq[49][18] = 579; + EUC_TWFreq[35][23] = 578; + EUC_TWFreq[42][83] = 577; + EUC_TWFreq[79][47] = 576; + EUC_TWFreq[61][82] = 575; + EUC_TWFreq[38][7] = 574; + EUC_TWFreq[35][29] = 573; + EUC_TWFreq[37][77] = 572; + EUC_TWFreq[54][67] = 571; + EUC_TWFreq[38][80] = 570; + EUC_TWFreq[52][74] = 569; + EUC_TWFreq[36][37] = 568; + EUC_TWFreq[74][8] = 567; + EUC_TWFreq[41][83] = 566; + EUC_TWFreq[36][75] = 565; + EUC_TWFreq[49][63] = 564; + EUC_TWFreq[42][58] = 563; + EUC_TWFreq[56][33] = 562; + EUC_TWFreq[37][76] = 561; + EUC_TWFreq[62][39] = 560; + EUC_TWFreq[35][21] = 559; + EUC_TWFreq[70][19] = 558; + EUC_TWFreq[77][88] = 557; + EUC_TWFreq[51][14] = 556; + EUC_TWFreq[36][17] = 555; + EUC_TWFreq[44][51] = 554; + EUC_TWFreq[38][72] = 553; + EUC_TWFreq[74][90] = 552; + EUC_TWFreq[35][48] = 551; + EUC_TWFreq[35][69] = 550; + EUC_TWFreq[66][86] = 549; + EUC_TWFreq[57][20] = 548; + EUC_TWFreq[35][53] = 547; + EUC_TWFreq[36][87] = 546; + EUC_TWFreq[84][67] = 545; + EUC_TWFreq[70][56] = 544; + EUC_TWFreq[71][54] = 543; + EUC_TWFreq[60][70] = 542; + EUC_TWFreq[80][1] = 541; + EUC_TWFreq[39][59] = 540; + EUC_TWFreq[39][51] = 539; + EUC_TWFreq[35][44] = 538; + EUC_TWFreq[48][4] = 537; + EUC_TWFreq[55][24] = 536; + EUC_TWFreq[52][4] = 535; + EUC_TWFreq[54][26] = 534; + EUC_TWFreq[36][31] = 533; + EUC_TWFreq[37][22] = 532; + EUC_TWFreq[37][9] = 531; + EUC_TWFreq[46][0] = 530; + EUC_TWFreq[56][46] = 529; + EUC_TWFreq[47][93] = 528; + EUC_TWFreq[37][25] = 527; + EUC_TWFreq[39][8] = 526; + EUC_TWFreq[46][73] = 525; + EUC_TWFreq[38][48] = 524; + EUC_TWFreq[39][83] = 523; + EUC_TWFreq[60][92] = 522; + EUC_TWFreq[70][11] = 521; + EUC_TWFreq[63][84] = 520; + EUC_TWFreq[38][65] = 519; + EUC_TWFreq[45][45] = 518; + EUC_TWFreq[63][49] = 517; + EUC_TWFreq[63][50] = 516; + EUC_TWFreq[39][93] = 515; + EUC_TWFreq[68][20] = 514; + EUC_TWFreq[44][84] = 513; + EUC_TWFreq[66][34] = 512; + EUC_TWFreq[37][58] = 511; + EUC_TWFreq[39][0] = 510; + EUC_TWFreq[59][1] = 509; + EUC_TWFreq[47][8] = 508; + EUC_TWFreq[61][17] = 507; + EUC_TWFreq[53][87] = 506; + EUC_TWFreq[67][26] = 505; + EUC_TWFreq[43][46] = 504; + EUC_TWFreq[38][61] = 503; + EUC_TWFreq[45][9] = 502; + EUC_TWFreq[66][83] = 501; + EUC_TWFreq[43][88] = 500; + EUC_TWFreq[85][20] = 499; + EUC_TWFreq[57][36] = 498; + EUC_TWFreq[43][6] = 497; + EUC_TWFreq[86][77] = 496; + EUC_TWFreq[42][70] = 495; + EUC_TWFreq[49][78] = 494; + EUC_TWFreq[36][40] = 493; + EUC_TWFreq[42][71] = 492; + EUC_TWFreq[58][49] = 491; + EUC_TWFreq[35][20] = 490; + EUC_TWFreq[76][20] = 489; + EUC_TWFreq[39][25] = 488; + EUC_TWFreq[40][34] = 487; + EUC_TWFreq[39][76] = 486; + EUC_TWFreq[40][1] = 485; + EUC_TWFreq[59][0] = 484; + EUC_TWFreq[39][70] = 483; + EUC_TWFreq[46][14] = 482; + EUC_TWFreq[68][77] = 481; + EUC_TWFreq[38][55] = 480; + EUC_TWFreq[35][78] = 479; + EUC_TWFreq[84][44] = 478; + EUC_TWFreq[36][41] = 477; + EUC_TWFreq[37][62] = 476; + EUC_TWFreq[65][67] = 475; + EUC_TWFreq[69][66] = 474; + EUC_TWFreq[73][55] = 473; + EUC_TWFreq[71][49] = 472; + EUC_TWFreq[66][87] = 471; + EUC_TWFreq[38][33] = 470; + EUC_TWFreq[64][61] = 469; + EUC_TWFreq[35][7] = 468; + EUC_TWFreq[47][49] = 467; + EUC_TWFreq[56][14] = 466; + EUC_TWFreq[36][49] = 465; + EUC_TWFreq[50][81] = 464; + EUC_TWFreq[55][76] = 463; + EUC_TWFreq[35][19] = 462; + EUC_TWFreq[44][47] = 461; + EUC_TWFreq[35][15] = 460; + EUC_TWFreq[82][59] = 459; + EUC_TWFreq[35][43] = 458; + EUC_TWFreq[73][0] = 457; + EUC_TWFreq[57][83] = 456; + EUC_TWFreq[42][46] = 455; + EUC_TWFreq[36][0] = 454; + EUC_TWFreq[70][88] = 453; + EUC_TWFreq[42][22] = 452; + EUC_TWFreq[46][58] = 451; + EUC_TWFreq[36][34] = 450; + EUC_TWFreq[39][24] = 449; + EUC_TWFreq[35][55] = 448; + EUC_TWFreq[44][91] = 447; + EUC_TWFreq[37][51] = 446; + EUC_TWFreq[36][19] = 445; + EUC_TWFreq[69][90] = 444; + EUC_TWFreq[55][35] = 443; + EUC_TWFreq[35][54] = 442; + EUC_TWFreq[49][61] = 441; + EUC_TWFreq[36][67] = 440; + EUC_TWFreq[88][34] = 439; + EUC_TWFreq[35][17] = 438; + EUC_TWFreq[65][69] = 437; + EUC_TWFreq[74][89] = 436; + EUC_TWFreq[37][31] = 435; + EUC_TWFreq[43][48] = 434; + EUC_TWFreq[89][27] = 433; + EUC_TWFreq[42][79] = 432; + EUC_TWFreq[69][57] = 431; + EUC_TWFreq[36][13] = 430; + EUC_TWFreq[35][62] = 429; + EUC_TWFreq[65][47] = 428; + EUC_TWFreq[56][8] = 427; + EUC_TWFreq[38][79] = 426; + EUC_TWFreq[37][64] = 425; + EUC_TWFreq[64][64] = 424; + EUC_TWFreq[38][53] = 423; + EUC_TWFreq[38][31] = 422; + EUC_TWFreq[56][81] = 421; + EUC_TWFreq[36][22] = 420; + EUC_TWFreq[43][4] = 419; + EUC_TWFreq[36][90] = 418; + EUC_TWFreq[38][62] = 417; + EUC_TWFreq[66][85] = 416; + EUC_TWFreq[39][1] = 415; + EUC_TWFreq[59][40] = 414; + EUC_TWFreq[58][93] = 413; + EUC_TWFreq[44][43] = 412; + EUC_TWFreq[39][49] = 411; + EUC_TWFreq[64][2] = 410; + EUC_TWFreq[41][35] = 409; + EUC_TWFreq[60][22] = 408; + EUC_TWFreq[35][91] = 407; + EUC_TWFreq[78][1] = 406; + EUC_TWFreq[36][14] = 405; + EUC_TWFreq[82][29] = 404; + EUC_TWFreq[52][86] = 403; + EUC_TWFreq[40][16] = 402; + EUC_TWFreq[91][52] = 401; + EUC_TWFreq[50][75] = 400; + EUC_TWFreq[64][30] = 399; + EUC_TWFreq[90][78] = 398; + EUC_TWFreq[36][52] = 397; + EUC_TWFreq[55][87] = 396; + EUC_TWFreq[57][5] = 395; + EUC_TWFreq[57][31] = 394; + EUC_TWFreq[42][35] = 393; + EUC_TWFreq[69][50] = 392; + EUC_TWFreq[45][8] = 391; + EUC_TWFreq[50][87] = 390; + EUC_TWFreq[69][55] = 389; + EUC_TWFreq[92][3] = 388; + EUC_TWFreq[36][43] = 387; + EUC_TWFreq[64][10] = 386; + EUC_TWFreq[56][25] = 385; + EUC_TWFreq[60][68] = 384; + EUC_TWFreq[51][46] = 383; + EUC_TWFreq[50][0] = 382; + EUC_TWFreq[38][30] = 381; + EUC_TWFreq[50][85] = 380; + EUC_TWFreq[60][54] = 379; + EUC_TWFreq[73][6] = 378; + EUC_TWFreq[73][28] = 377; + EUC_TWFreq[56][19] = 376; + EUC_TWFreq[62][69] = 375; + EUC_TWFreq[81][66] = 374; + EUC_TWFreq[40][32] = 373; + EUC_TWFreq[76][31] = 372; + EUC_TWFreq[35][10] = 371; + EUC_TWFreq[41][37] = 370; + EUC_TWFreq[52][82] = 369; + EUC_TWFreq[91][72] = 368; + EUC_TWFreq[37][29] = 367; + EUC_TWFreq[56][30] = 366; + EUC_TWFreq[37][80] = 365; + EUC_TWFreq[81][56] = 364; + EUC_TWFreq[70][3] = 363; + EUC_TWFreq[76][15] = 362; + EUC_TWFreq[46][47] = 361; + EUC_TWFreq[35][88] = 360; + EUC_TWFreq[61][58] = 359; + EUC_TWFreq[37][37] = 358; + EUC_TWFreq[57][22] = 357; + EUC_TWFreq[41][23] = 356; + EUC_TWFreq[90][66] = 355; + EUC_TWFreq[39][60] = 354; + EUC_TWFreq[38][0] = 353; + EUC_TWFreq[37][87] = 352; + EUC_TWFreq[46][2] = 351; + EUC_TWFreq[38][56] = 350; + EUC_TWFreq[58][11] = 349; + EUC_TWFreq[48][10] = 348; + EUC_TWFreq[74][4] = 347; + EUC_TWFreq[40][42] = 346; + EUC_TWFreq[41][52] = 345; + EUC_TWFreq[61][92] = 344; + EUC_TWFreq[39][50] = 343; + EUC_TWFreq[47][88] = 342; + EUC_TWFreq[88][36] = 341; + EUC_TWFreq[45][73] = 340; + EUC_TWFreq[82][3] = 339; + EUC_TWFreq[61][36] = 338; + EUC_TWFreq[60][33] = 337; + EUC_TWFreq[38][27] = 336; + EUC_TWFreq[35][83] = 335; + EUC_TWFreq[65][24] = 334; + EUC_TWFreq[73][10] = 333; + EUC_TWFreq[41][13] = 332; + EUC_TWFreq[50][27] = 331; + EUC_TWFreq[59][50] = 330; + EUC_TWFreq[42][45] = 329; + EUC_TWFreq[55][19] = 328; + EUC_TWFreq[36][77] = 327; + EUC_TWFreq[69][31] = 326; + EUC_TWFreq[60][7] = 325; + EUC_TWFreq[40][88] = 324; + EUC_TWFreq[57][56] = 323; + EUC_TWFreq[50][50] = 322; + EUC_TWFreq[42][37] = 321; + EUC_TWFreq[38][82] = 320; + EUC_TWFreq[52][25] = 319; + EUC_TWFreq[42][67] = 318; + EUC_TWFreq[48][40] = 317; + EUC_TWFreq[45][81] = 316; + EUC_TWFreq[57][14] = 315; + EUC_TWFreq[42][13] = 314; + EUC_TWFreq[78][0] = 313; + EUC_TWFreq[35][51] = 312; + EUC_TWFreq[41][67] = 311; + EUC_TWFreq[64][23] = 310; + EUC_TWFreq[36][65] = 309; + EUC_TWFreq[48][50] = 308; + EUC_TWFreq[46][69] = 307; + EUC_TWFreq[47][89] = 306; + EUC_TWFreq[41][48] = 305; + EUC_TWFreq[60][56] = 304; + EUC_TWFreq[44][82] = 303; + EUC_TWFreq[47][35] = 302; + EUC_TWFreq[49][3] = 301; + EUC_TWFreq[49][69] = 300; + EUC_TWFreq[45][93] = 299; + EUC_TWFreq[60][34] = 298; + EUC_TWFreq[60][82] = 297; + EUC_TWFreq[61][61] = 296; + EUC_TWFreq[86][42] = 295; + EUC_TWFreq[89][60] = 294; + EUC_TWFreq[48][31] = 293; + EUC_TWFreq[35][75] = 292; + EUC_TWFreq[91][39] = 291; + EUC_TWFreq[53][19] = 290; + EUC_TWFreq[39][72] = 289; + EUC_TWFreq[69][59] = 288; + EUC_TWFreq[41][7] = 287; + EUC_TWFreq[54][13] = 286; + EUC_TWFreq[43][28] = 285; + EUC_TWFreq[36][6] = 284; + EUC_TWFreq[45][75] = 283; + EUC_TWFreq[36][61] = 282; + EUC_TWFreq[38][21] = 281; + EUC_TWFreq[45][14] = 280; + EUC_TWFreq[61][43] = 279; + EUC_TWFreq[36][63] = 278; + EUC_TWFreq[43][30] = 277; + EUC_TWFreq[46][51] = 276; + EUC_TWFreq[68][87] = 275; + EUC_TWFreq[39][26] = 274; + EUC_TWFreq[46][76] = 273; + EUC_TWFreq[36][15] = 272; + EUC_TWFreq[35][40] = 271; + EUC_TWFreq[79][60] = 270; + EUC_TWFreq[46][7] = 269; + EUC_TWFreq[65][72] = 268; + EUC_TWFreq[69][88] = 267; + EUC_TWFreq[47][18] = 266; + EUC_TWFreq[37][0] = 265; + EUC_TWFreq[37][49] = 264; + EUC_TWFreq[67][37] = 263; + EUC_TWFreq[36][91] = 262; + EUC_TWFreq[75][48] = 261; + EUC_TWFreq[75][63] = 260; + EUC_TWFreq[83][87] = 259; + EUC_TWFreq[37][44] = 258; + EUC_TWFreq[73][54] = 257; + EUC_TWFreq[51][61] = 256; + EUC_TWFreq[46][57] = 255; + EUC_TWFreq[55][21] = 254; + EUC_TWFreq[39][66] = 253; + EUC_TWFreq[47][11] = 252; + EUC_TWFreq[52][8] = 251; + EUC_TWFreq[82][81] = 250; + EUC_TWFreq[36][57] = 249; + EUC_TWFreq[38][54] = 248; + EUC_TWFreq[43][81] = 247; + EUC_TWFreq[37][42] = 246; + EUC_TWFreq[40][18] = 245; + EUC_TWFreq[80][90] = 244; + EUC_TWFreq[37][84] = 243; + EUC_TWFreq[57][15] = 242; + EUC_TWFreq[38][87] = 241; + EUC_TWFreq[37][32] = 240; + EUC_TWFreq[53][53] = 239; + EUC_TWFreq[89][29] = 238; + EUC_TWFreq[81][53] = 237; + EUC_TWFreq[75][3] = 236; + EUC_TWFreq[83][73] = 235; + EUC_TWFreq[66][13] = 234; + EUC_TWFreq[48][7] = 233; + EUC_TWFreq[46][35] = 232; + EUC_TWFreq[35][86] = 231; + EUC_TWFreq[37][20] = 230; + EUC_TWFreq[46][80] = 229; + EUC_TWFreq[38][24] = 228; + EUC_TWFreq[41][68] = 227; + EUC_TWFreq[42][21] = 226; + EUC_TWFreq[43][32] = 225; + EUC_TWFreq[38][20] = 224; + EUC_TWFreq[37][59] = 223; + EUC_TWFreq[41][77] = 222; + EUC_TWFreq[59][57] = 221; + EUC_TWFreq[68][59] = 220; + EUC_TWFreq[39][43] = 219; + EUC_TWFreq[54][39] = 218; + EUC_TWFreq[48][28] = 217; + EUC_TWFreq[54][28] = 216; + EUC_TWFreq[41][44] = 215; + EUC_TWFreq[51][64] = 214; + EUC_TWFreq[47][72] = 213; + EUC_TWFreq[62][67] = 212; + EUC_TWFreq[42][43] = 211; + EUC_TWFreq[61][38] = 210; + EUC_TWFreq[76][25] = 209; + EUC_TWFreq[48][91] = 208; + EUC_TWFreq[36][36] = 207; + EUC_TWFreq[80][32] = 206; + EUC_TWFreq[81][40] = 205; + EUC_TWFreq[37][5] = 204; + EUC_TWFreq[74][69] = 203; + EUC_TWFreq[36][82] = 202; + EUC_TWFreq[46][59] = 201; + /* + * EUC_TWFreq[38][32] = 200; EUC_TWFreq[74][2] = 199; EUC_TWFreq[53][31] + * = 198; EUC_TWFreq[35][38] = 197; EUC_TWFreq[46][62] = 196; + * EUC_TWFreq[77][31] = 195; EUC_TWFreq[55][74] = 194; EUC_TWFreq[66][6] + * = 193; EUC_TWFreq[56][21] = 192; EUC_TWFreq[54][78] = 191; + * EUC_TWFreq[43][51] = 190; EUC_TWFreq[64][93] = 189; EUC_TWFreq[92][7] + * = 188; EUC_TWFreq[83][89] = 187; EUC_TWFreq[69][9] = 186; + * EUC_TWFreq[45][4] = 185; EUC_TWFreq[53][9] = 184; EUC_TWFreq[43][2] = + * 183; EUC_TWFreq[35][11] = 182; EUC_TWFreq[51][25] = 181; + * EUC_TWFreq[52][71] = 180; EUC_TWFreq[81][67] = 179; + * EUC_TWFreq[37][33] = 178; EUC_TWFreq[38][57] = 177; + * EUC_TWFreq[39][77] = 176; EUC_TWFreq[40][26] = 175; + * EUC_TWFreq[37][21] = 174; EUC_TWFreq[81][70] = 173; + * EUC_TWFreq[56][80] = 172; EUC_TWFreq[65][14] = 171; + * EUC_TWFreq[62][47] = 170; EUC_TWFreq[56][54] = 169; + * EUC_TWFreq[45][17] = 168; EUC_TWFreq[52][52] = 167; + * EUC_TWFreq[74][30] = 166; EUC_TWFreq[60][57] = 165; + * EUC_TWFreq[41][15] = 164; EUC_TWFreq[47][69] = 163; + * EUC_TWFreq[61][11] = 162; EUC_TWFreq[72][25] = 161; + * EUC_TWFreq[82][56] = 160; EUC_TWFreq[76][92] = 159; + * EUC_TWFreq[51][22] = 158; EUC_TWFreq[55][69] = 157; + * EUC_TWFreq[49][43] = 156; EUC_TWFreq[69][49] = 155; + * EUC_TWFreq[88][42] = 154; EUC_TWFreq[84][41] = 153; + * EUC_TWFreq[79][33] = 152; EUC_TWFreq[47][17] = 151; + * EUC_TWFreq[52][88] = 150; EUC_TWFreq[63][74] = 149; + * EUC_TWFreq[50][32] = 148; EUC_TWFreq[65][10] = 147; EUC_TWFreq[57][6] + * = 146; EUC_TWFreq[52][23] = 145; EUC_TWFreq[36][70] = 144; + * EUC_TWFreq[65][55] = 143; EUC_TWFreq[35][27] = 142; + * EUC_TWFreq[57][63] = 141; EUC_TWFreq[39][92] = 140; + * EUC_TWFreq[79][75] = 139; EUC_TWFreq[36][30] = 138; + * EUC_TWFreq[53][60] = 137; EUC_TWFreq[55][43] = 136; + * EUC_TWFreq[71][22] = 135; EUC_TWFreq[43][16] = 134; + * EUC_TWFreq[65][21] = 133; EUC_TWFreq[84][51] = 132; + * EUC_TWFreq[43][64] = 131; EUC_TWFreq[87][91] = 130; + * EUC_TWFreq[47][45] = 129; EUC_TWFreq[65][29] = 128; + * EUC_TWFreq[88][16] = 127; EUC_TWFreq[50][5] = 126; EUC_TWFreq[47][33] + * = 125; EUC_TWFreq[46][27] = 124; EUC_TWFreq[85][2] = 123; + * EUC_TWFreq[43][77] = 122; EUC_TWFreq[70][9] = 121; EUC_TWFreq[41][54] + * = 120; EUC_TWFreq[56][12] = 119; EUC_TWFreq[90][65] = 118; + * EUC_TWFreq[91][50] = 117; EUC_TWFreq[48][41] = 116; + * EUC_TWFreq[35][89] = 115; EUC_TWFreq[90][83] = 114; + * EUC_TWFreq[44][40] = 113; EUC_TWFreq[50][88] = 112; + * EUC_TWFreq[72][39] = 111; EUC_TWFreq[45][3] = 110; EUC_TWFreq[71][33] + * = 109; EUC_TWFreq[39][12] = 108; EUC_TWFreq[59][24] = 107; + * EUC_TWFreq[60][62] = 106; EUC_TWFreq[44][33] = 105; + * EUC_TWFreq[53][70] = 104; EUC_TWFreq[77][90] = 103; + * EUC_TWFreq[50][58] = 102; EUC_TWFreq[54][1] = 101; EUC_TWFreq[73][19] + * = 100; EUC_TWFreq[37][3] = 99; EUC_TWFreq[49][91] = 98; + * EUC_TWFreq[88][43] = 97; EUC_TWFreq[36][78] = 96; EUC_TWFreq[44][20] + * = 95; EUC_TWFreq[64][15] = 94; EUC_TWFreq[72][28] = 93; + * EUC_TWFreq[70][13] = 92; EUC_TWFreq[65][83] = 91; EUC_TWFreq[58][68] + * = 90; EUC_TWFreq[59][32] = 89; EUC_TWFreq[39][13] = 88; + * EUC_TWFreq[55][64] = 87; EUC_TWFreq[56][59] = 86; EUC_TWFreq[39][17] + * = 85; EUC_TWFreq[55][84] = 84; EUC_TWFreq[77][85] = 83; + * EUC_TWFreq[60][19] = 82; EUC_TWFreq[62][82] = 81; EUC_TWFreq[78][16] + * = 80; EUC_TWFreq[66][8] = 79; EUC_TWFreq[39][42] = 78; + * EUC_TWFreq[61][24] = 77; EUC_TWFreq[57][67] = 76; EUC_TWFreq[38][83] + * = 75; EUC_TWFreq[36][53] = 74; EUC_TWFreq[67][76] = 73; + * EUC_TWFreq[37][91] = 72; EUC_TWFreq[44][26] = 71; EUC_TWFreq[72][86] + * = 70; EUC_TWFreq[44][87] = 69; EUC_TWFreq[45][50] = 68; + * EUC_TWFreq[58][4] = 67; EUC_TWFreq[86][65] = 66; EUC_TWFreq[45][56] = + * 65; EUC_TWFreq[79][49] = 64; EUC_TWFreq[35][3] = 63; + * EUC_TWFreq[48][83] = 62; EUC_TWFreq[71][21] = 61; EUC_TWFreq[77][93] + * = 60; EUC_TWFreq[87][92] = 59; EUC_TWFreq[38][35] = 58; + * EUC_TWFreq[66][17] = 57; EUC_TWFreq[37][66] = 56; EUC_TWFreq[51][42] + * = 55; EUC_TWFreq[57][73] = 54; EUC_TWFreq[51][54] = 53; + * EUC_TWFreq[75][64] = 52; EUC_TWFreq[35][5] = 51; EUC_TWFreq[49][40] = + * 50; EUC_TWFreq[58][35] = 49; EUC_TWFreq[67][88] = 48; + * EUC_TWFreq[60][51] = 47; EUC_TWFreq[36][92] = 46; EUC_TWFreq[44][41] + * = 45; EUC_TWFreq[58][29] = 44; EUC_TWFreq[43][62] = 43; + * EUC_TWFreq[56][23] = 42; EUC_TWFreq[67][44] = 41; EUC_TWFreq[52][91] + * = 40; EUC_TWFreq[42][81] = 39; EUC_TWFreq[64][25] = 38; + * EUC_TWFreq[35][36] = 37; EUC_TWFreq[47][73] = 36; EUC_TWFreq[36][1] = + * 35; EUC_TWFreq[65][84] = 34; EUC_TWFreq[73][1] = 33; + * EUC_TWFreq[79][66] = 32; EUC_TWFreq[69][14] = 31; EUC_TWFreq[65][28] + * = 30; EUC_TWFreq[60][93] = 29; EUC_TWFreq[72][79] = 28; + * EUC_TWFreq[48][0] = 27; EUC_TWFreq[73][43] = 26; EUC_TWFreq[66][47] = + * 25; EUC_TWFreq[41][18] = 24; EUC_TWFreq[51][10] = 23; + * EUC_TWFreq[59][7] = 22; EUC_TWFreq[53][27] = 21; EUC_TWFreq[86][67] = + * 20; EUC_TWFreq[49][87] = 19; EUC_TWFreq[52][28] = 18; + * EUC_TWFreq[52][12] = 17; EUC_TWFreq[42][30] = 16; EUC_TWFreq[65][35] + * = 15; EUC_TWFreq[46][64] = 14; EUC_TWFreq[71][7] = 13; + * EUC_TWFreq[56][57] = 12; EUC_TWFreq[56][31] = 11; EUC_TWFreq[41][31] + * = 10; EUC_TWFreq[48][59] = 9; EUC_TWFreq[63][92] = 8; + * EUC_TWFreq[62][57] = 7; EUC_TWFreq[65][87] = 6; EUC_TWFreq[70][10] = + * 5; EUC_TWFreq[52][40] = 4; EUC_TWFreq[40][22] = 3; EUC_TWFreq[65][91] + * = 2; EUC_TWFreq[50][25] = 1; EUC_TWFreq[35][84] = 0; + */ + GBKFreq[52][132] = 600; + GBKFreq[73][135] = 599; + GBKFreq[49][123] = 598; + GBKFreq[77][146] = 597; + GBKFreq[81][123] = 596; + GBKFreq[82][144] = 595; + GBKFreq[51][179] = 594; + GBKFreq[83][154] = 593; + GBKFreq[71][139] = 592; + GBKFreq[64][139] = 591; + GBKFreq[85][144] = 590; + GBKFreq[52][125] = 589; + GBKFreq[88][25] = 588; + GBKFreq[81][106] = 587; + GBKFreq[81][148] = 586; + GBKFreq[62][137] = 585; + GBKFreq[94][0] = 584; + GBKFreq[1][64] = 583; + GBKFreq[67][163] = 582; + GBKFreq[20][190] = 581; + GBKFreq[57][131] = 580; + GBKFreq[29][169] = 579; + GBKFreq[72][143] = 578; + GBKFreq[0][173] = 577; + GBKFreq[11][23] = 576; + GBKFreq[61][141] = 575; + GBKFreq[60][123] = 574; + GBKFreq[81][114] = 573; + GBKFreq[82][131] = 572; + GBKFreq[67][156] = 571; + GBKFreq[71][167] = 570; + GBKFreq[20][50] = 569; + GBKFreq[77][132] = 568; + GBKFreq[84][38] = 567; + GBKFreq[26][29] = 566; + GBKFreq[74][187] = 565; + GBKFreq[62][116] = 564; + GBKFreq[67][135] = 563; + GBKFreq[5][86] = 562; + GBKFreq[72][186] = 561; + GBKFreq[75][161] = 560; + GBKFreq[78][130] = 559; + GBKFreq[94][30] = 558; + GBKFreq[84][72] = 557; + GBKFreq[1][67] = 556; + GBKFreq[75][172] = 555; + GBKFreq[74][185] = 554; + GBKFreq[53][160] = 553; + GBKFreq[123][14] = 552; + GBKFreq[79][97] = 551; + GBKFreq[85][110] = 550; + GBKFreq[78][171] = 549; + GBKFreq[52][131] = 548; + GBKFreq[56][100] = 547; + GBKFreq[50][182] = 546; + GBKFreq[94][64] = 545; + GBKFreq[106][74] = 544; + GBKFreq[11][102] = 543; + GBKFreq[53][124] = 542; + GBKFreq[24][3] = 541; + GBKFreq[86][148] = 540; + GBKFreq[53][184] = 539; + GBKFreq[86][147] = 538; + GBKFreq[96][161] = 537; + GBKFreq[82][77] = 536; + GBKFreq[59][146] = 535; + GBKFreq[84][126] = 534; + GBKFreq[79][132] = 533; + GBKFreq[85][123] = 532; + GBKFreq[71][101] = 531; + GBKFreq[85][106] = 530; + GBKFreq[6][184] = 529; + GBKFreq[57][156] = 528; + GBKFreq[75][104] = 527; + GBKFreq[50][137] = 526; + GBKFreq[79][133] = 525; + GBKFreq[76][108] = 524; + GBKFreq[57][142] = 523; + GBKFreq[84][130] = 522; + GBKFreq[52][128] = 521; + GBKFreq[47][44] = 520; + GBKFreq[52][152] = 519; + GBKFreq[54][104] = 518; + GBKFreq[30][47] = 517; + GBKFreq[71][123] = 516; + GBKFreq[52][107] = 515; + GBKFreq[45][84] = 514; + GBKFreq[107][118] = 513; + GBKFreq[5][161] = 512; + GBKFreq[48][126] = 511; + GBKFreq[67][170] = 510; + GBKFreq[43][6] = 509; + GBKFreq[70][112] = 508; + GBKFreq[86][174] = 507; + GBKFreq[84][166] = 506; + GBKFreq[79][130] = 505; + GBKFreq[57][141] = 504; + GBKFreq[81][178] = 503; + GBKFreq[56][187] = 502; + GBKFreq[81][162] = 501; + GBKFreq[53][104] = 500; + GBKFreq[123][35] = 499; + GBKFreq[70][169] = 498; + GBKFreq[69][164] = 497; + GBKFreq[109][61] = 496; + GBKFreq[73][130] = 495; + GBKFreq[62][134] = 494; + GBKFreq[54][125] = 493; + GBKFreq[79][105] = 492; + GBKFreq[70][165] = 491; + GBKFreq[71][189] = 490; + GBKFreq[23][147] = 489; + GBKFreq[51][139] = 488; + GBKFreq[47][137] = 487; + GBKFreq[77][123] = 486; + GBKFreq[86][183] = 485; + GBKFreq[63][173] = 484; + GBKFreq[79][144] = 483; + GBKFreq[84][159] = 482; + GBKFreq[60][91] = 481; + GBKFreq[66][187] = 480; + GBKFreq[73][114] = 479; + GBKFreq[85][56] = 478; + GBKFreq[71][149] = 477; + GBKFreq[84][189] = 476; + GBKFreq[104][31] = 475; + GBKFreq[83][82] = 474; + GBKFreq[68][35] = 473; + GBKFreq[11][77] = 472; + GBKFreq[15][155] = 471; + GBKFreq[83][153] = 470; + GBKFreq[71][1] = 469; + GBKFreq[53][190] = 468; + GBKFreq[50][135] = 467; + GBKFreq[3][147] = 466; + GBKFreq[48][136] = 465; + GBKFreq[66][166] = 464; + GBKFreq[55][159] = 463; + GBKFreq[82][150] = 462; + GBKFreq[58][178] = 461; + GBKFreq[64][102] = 460; + GBKFreq[16][106] = 459; + GBKFreq[68][110] = 458; + GBKFreq[54][14] = 457; + GBKFreq[60][140] = 456; + GBKFreq[91][71] = 455; + GBKFreq[54][150] = 454; + GBKFreq[78][177] = 453; + GBKFreq[78][117] = 452; + GBKFreq[104][12] = 451; + GBKFreq[73][150] = 450; + GBKFreq[51][142] = 449; + GBKFreq[81][145] = 448; + GBKFreq[66][183] = 447; + GBKFreq[51][178] = 446; + GBKFreq[75][107] = 445; + GBKFreq[65][119] = 444; + GBKFreq[69][176] = 443; + GBKFreq[59][122] = 442; + GBKFreq[78][160] = 441; + GBKFreq[85][183] = 440; + GBKFreq[105][16] = 439; + GBKFreq[73][110] = 438; + GBKFreq[104][39] = 437; + GBKFreq[119][16] = 436; + GBKFreq[76][162] = 435; + GBKFreq[67][152] = 434; + GBKFreq[82][24] = 433; + GBKFreq[73][121] = 432; + GBKFreq[83][83] = 431; + GBKFreq[82][145] = 430; + GBKFreq[49][133] = 429; + GBKFreq[94][13] = 428; + GBKFreq[58][139] = 427; + GBKFreq[74][189] = 426; + GBKFreq[66][177] = 425; + GBKFreq[85][184] = 424; + GBKFreq[55][183] = 423; + GBKFreq[71][107] = 422; + GBKFreq[11][98] = 421; + GBKFreq[72][153] = 420; + GBKFreq[2][137] = 419; + GBKFreq[59][147] = 418; + GBKFreq[58][152] = 417; + GBKFreq[55][144] = 416; + GBKFreq[73][125] = 415; + GBKFreq[52][154] = 414; + GBKFreq[70][178] = 413; + GBKFreq[79][148] = 412; + GBKFreq[63][143] = 411; + GBKFreq[50][140] = 410; + GBKFreq[47][145] = 409; + GBKFreq[48][123] = 408; + GBKFreq[56][107] = 407; + GBKFreq[84][83] = 406; + GBKFreq[59][112] = 405; + GBKFreq[124][72] = 404; + GBKFreq[79][99] = 403; + GBKFreq[3][37] = 402; + GBKFreq[114][55] = 401; + GBKFreq[85][152] = 400; + GBKFreq[60][47] = 399; + GBKFreq[65][96] = 398; + GBKFreq[74][110] = 397; + GBKFreq[86][182] = 396; + GBKFreq[50][99] = 395; + GBKFreq[67][186] = 394; + GBKFreq[81][74] = 393; + GBKFreq[80][37] = 392; + GBKFreq[21][60] = 391; + GBKFreq[110][12] = 390; + GBKFreq[60][162] = 389; + GBKFreq[29][115] = 388; + GBKFreq[83][130] = 387; + GBKFreq[52][136] = 386; + GBKFreq[63][114] = 385; + GBKFreq[49][127] = 384; + GBKFreq[83][109] = 383; + GBKFreq[66][128] = 382; + GBKFreq[78][136] = 381; + GBKFreq[81][180] = 380; + GBKFreq[76][104] = 379; + GBKFreq[56][156] = 378; + GBKFreq[61][23] = 377; + GBKFreq[4][30] = 376; + GBKFreq[69][154] = 375; + GBKFreq[100][37] = 374; + GBKFreq[54][177] = 373; + GBKFreq[23][119] = 372; + GBKFreq[71][171] = 371; + GBKFreq[84][146] = 370; + GBKFreq[20][184] = 369; + GBKFreq[86][76] = 368; + GBKFreq[74][132] = 367; + GBKFreq[47][97] = 366; + GBKFreq[82][137] = 365; + GBKFreq[94][56] = 364; + GBKFreq[92][30] = 363; + GBKFreq[19][117] = 362; + GBKFreq[48][173] = 361; + GBKFreq[2][136] = 360; + GBKFreq[7][182] = 359; + GBKFreq[74][188] = 358; + GBKFreq[14][132] = 357; + GBKFreq[62][172] = 356; + GBKFreq[25][39] = 355; + GBKFreq[85][129] = 354; + GBKFreq[64][98] = 353; + GBKFreq[67][127] = 352; + GBKFreq[72][167] = 351; + GBKFreq[57][143] = 350; + GBKFreq[76][187] = 349; + GBKFreq[83][181] = 348; + GBKFreq[84][10] = 347; + GBKFreq[55][166] = 346; + GBKFreq[55][188] = 345; + GBKFreq[13][151] = 344; + GBKFreq[62][124] = 343; + GBKFreq[53][136] = 342; + GBKFreq[106][57] = 341; + GBKFreq[47][166] = 340; + GBKFreq[109][30] = 339; + GBKFreq[78][114] = 338; + GBKFreq[83][19] = 337; + GBKFreq[56][162] = 336; + GBKFreq[60][177] = 335; + GBKFreq[88][9] = 334; + GBKFreq[74][163] = 333; + GBKFreq[52][156] = 332; + GBKFreq[71][180] = 331; + GBKFreq[60][57] = 330; + GBKFreq[72][173] = 329; + GBKFreq[82][91] = 328; + GBKFreq[51][186] = 327; + GBKFreq[75][86] = 326; + GBKFreq[75][78] = 325; + GBKFreq[76][170] = 324; + GBKFreq[60][147] = 323; + GBKFreq[82][75] = 322; + GBKFreq[80][148] = 321; + GBKFreq[86][150] = 320; + GBKFreq[13][95] = 319; + GBKFreq[0][11] = 318; + GBKFreq[84][190] = 317; + GBKFreq[76][166] = 316; + GBKFreq[14][72] = 315; + GBKFreq[67][144] = 314; + GBKFreq[84][44] = 313; + GBKFreq[72][125] = 312; + GBKFreq[66][127] = 311; + GBKFreq[60][25] = 310; + GBKFreq[70][146] = 309; + GBKFreq[79][135] = 308; + GBKFreq[54][135] = 307; + GBKFreq[60][104] = 306; + GBKFreq[55][132] = 305; + GBKFreq[94][2] = 304; + GBKFreq[54][133] = 303; + GBKFreq[56][190] = 302; + GBKFreq[58][174] = 301; + GBKFreq[80][144] = 300; + GBKFreq[85][113] = 299; + /* + * GBKFreq[83][15] = 298; GBKFreq[105][80] = 297; GBKFreq[7][179] = 296; + * GBKFreq[93][4] = 295; GBKFreq[123][40] = 294; GBKFreq[85][120] = 293; + * GBKFreq[77][165] = 292; GBKFreq[86][67] = 291; GBKFreq[25][162] = + * 290; GBKFreq[77][183] = 289; GBKFreq[83][71] = 288; GBKFreq[78][99] = + * 287; GBKFreq[72][177] = 286; GBKFreq[71][97] = 285; GBKFreq[58][111] + * = 284; GBKFreq[77][175] = 283; GBKFreq[76][181] = 282; + * GBKFreq[71][142] = 281; GBKFreq[64][150] = 280; GBKFreq[5][142] = + * 279; GBKFreq[73][128] = 278; GBKFreq[73][156] = 277; GBKFreq[60][188] + * = 276; GBKFreq[64][56] = 275; GBKFreq[74][128] = 274; + * GBKFreq[48][163] = 273; GBKFreq[54][116] = 272; GBKFreq[73][127] = + * 271; GBKFreq[16][176] = 270; GBKFreq[62][149] = 269; GBKFreq[105][96] + * = 268; GBKFreq[55][186] = 267; GBKFreq[4][51] = 266; GBKFreq[48][113] + * = 265; GBKFreq[48][152] = 264; GBKFreq[23][9] = 263; GBKFreq[56][102] + * = 262; GBKFreq[11][81] = 261; GBKFreq[82][112] = 260; GBKFreq[65][85] + * = 259; GBKFreq[69][125] = 258; GBKFreq[68][31] = 257; GBKFreq[5][20] + * = 256; GBKFreq[60][176] = 255; GBKFreq[82][81] = 254; + * GBKFreq[72][107] = 253; GBKFreq[3][52] = 252; GBKFreq[71][157] = 251; + * GBKFreq[24][46] = 250; GBKFreq[69][108] = 249; GBKFreq[78][178] = + * 248; GBKFreq[9][69] = 247; GBKFreq[73][144] = 246; GBKFreq[63][187] = + * 245; GBKFreq[68][36] = 244; GBKFreq[47][151] = 243; GBKFreq[14][74] = + * 242; GBKFreq[47][114] = 241; GBKFreq[80][171] = 240; GBKFreq[75][152] + * = 239; GBKFreq[86][40] = 238; GBKFreq[93][43] = 237; GBKFreq[2][50] = + * 236; GBKFreq[62][66] = 235; GBKFreq[1][183] = 234; GBKFreq[74][124] = + * 233; GBKFreq[58][104] = 232; GBKFreq[83][106] = 231; GBKFreq[60][144] + * = 230; GBKFreq[48][99] = 229; GBKFreq[54][157] = 228; + * GBKFreq[70][179] = 227; GBKFreq[61][127] = 226; GBKFreq[57][135] = + * 225; GBKFreq[59][190] = 224; GBKFreq[77][116] = 223; GBKFreq[26][17] + * = 222; GBKFreq[60][13] = 221; GBKFreq[71][38] = 220; GBKFreq[85][177] + * = 219; GBKFreq[59][73] = 218; GBKFreq[50][150] = 217; + * GBKFreq[79][102] = 216; GBKFreq[76][118] = 215; GBKFreq[67][132] = + * 214; GBKFreq[73][146] = 213; GBKFreq[83][184] = 212; GBKFreq[86][159] + * = 211; GBKFreq[95][120] = 210; GBKFreq[23][139] = 209; + * GBKFreq[64][183] = 208; GBKFreq[85][103] = 207; GBKFreq[41][90] = + * 206; GBKFreq[87][72] = 205; GBKFreq[62][104] = 204; GBKFreq[79][168] + * = 203; GBKFreq[79][150] = 202; GBKFreq[104][20] = 201; + * GBKFreq[56][114] = 200; GBKFreq[84][26] = 199; GBKFreq[57][99] = 198; + * GBKFreq[62][154] = 197; GBKFreq[47][98] = 196; GBKFreq[61][64] = 195; + * GBKFreq[112][18] = 194; GBKFreq[123][19] = 193; GBKFreq[4][98] = 192; + * GBKFreq[47][163] = 191; GBKFreq[66][188] = 190; GBKFreq[81][85] = + * 189; GBKFreq[82][30] = 188; GBKFreq[65][83] = 187; GBKFreq[67][24] = + * 186; GBKFreq[68][179] = 185; GBKFreq[55][177] = 184; GBKFreq[2][122] + * = 183; GBKFreq[47][139] = 182; GBKFreq[79][158] = 181; + * GBKFreq[64][143] = 180; GBKFreq[100][24] = 179; GBKFreq[73][103] = + * 178; GBKFreq[50][148] = 177; GBKFreq[86][97] = 176; GBKFreq[59][116] + * = 175; GBKFreq[64][173] = 174; GBKFreq[99][91] = 173; GBKFreq[11][99] + * = 172; GBKFreq[78][179] = 171; GBKFreq[18][17] = 170; + * GBKFreq[58][185] = 169; GBKFreq[47][165] = 168; GBKFreq[67][131] = + * 167; GBKFreq[94][40] = 166; GBKFreq[74][153] = 165; GBKFreq[79][142] + * = 164; GBKFreq[57][98] = 163; GBKFreq[1][164] = 162; GBKFreq[55][168] + * = 161; GBKFreq[13][141] = 160; GBKFreq[51][31] = 159; + * GBKFreq[57][178] = 158; GBKFreq[50][189] = 157; GBKFreq[60][167] = + * 156; GBKFreq[80][34] = 155; GBKFreq[109][80] = 154; GBKFreq[85][54] = + * 153; GBKFreq[69][183] = 152; GBKFreq[67][143] = 151; GBKFreq[47][120] + * = 150; GBKFreq[45][75] = 149; GBKFreq[82][98] = 148; GBKFreq[83][22] + * = 147; GBKFreq[13][103] = 146; GBKFreq[49][174] = 145; + * GBKFreq[57][181] = 144; GBKFreq[64][127] = 143; GBKFreq[61][131] = + * 142; GBKFreq[52][180] = 141; GBKFreq[74][134] = 140; GBKFreq[84][187] + * = 139; GBKFreq[81][189] = 138; GBKFreq[47][160] = 137; + * GBKFreq[66][148] = 136; GBKFreq[7][4] = 135; GBKFreq[85][134] = 134; + * GBKFreq[88][13] = 133; GBKFreq[88][80] = 132; GBKFreq[69][166] = 131; + * GBKFreq[86][18] = 130; GBKFreq[79][141] = 129; GBKFreq[50][108] = + * 128; GBKFreq[94][69] = 127; GBKFreq[81][110] = 126; GBKFreq[69][119] + * = 125; GBKFreq[72][161] = 124; GBKFreq[106][45] = 123; + * GBKFreq[73][124] = 122; GBKFreq[94][28] = 121; GBKFreq[63][174] = + * 120; GBKFreq[3][149] = 119; GBKFreq[24][160] = 118; GBKFreq[113][94] + * = 117; GBKFreq[56][138] = 116; GBKFreq[64][185] = 115; + * GBKFreq[86][56] = 114; GBKFreq[56][150] = 113; GBKFreq[110][55] = + * 112; GBKFreq[28][13] = 111; GBKFreq[54][190] = 110; GBKFreq[8][180] = + * 109; GBKFreq[73][149] = 108; GBKFreq[80][155] = 107; GBKFreq[83][172] + * = 106; GBKFreq[67][174] = 105; GBKFreq[64][180] = 104; + * GBKFreq[84][46] = 103; GBKFreq[91][74] = 102; GBKFreq[69][134] = 101; + * GBKFreq[61][107] = 100; GBKFreq[47][171] = 99; GBKFreq[59][51] = 98; + * GBKFreq[109][74] = 97; GBKFreq[64][174] = 96; GBKFreq[52][151] = 95; + * GBKFreq[51][176] = 94; GBKFreq[80][157] = 93; GBKFreq[94][31] = 92; + * GBKFreq[79][155] = 91; GBKFreq[72][174] = 90; GBKFreq[69][113] = 89; + * GBKFreq[83][167] = 88; GBKFreq[83][122] = 87; GBKFreq[8][178] = 86; + * GBKFreq[70][186] = 85; GBKFreq[59][153] = 84; GBKFreq[84][68] = 83; + * GBKFreq[79][39] = 82; GBKFreq[47][180] = 81; GBKFreq[88][53] = 80; + * GBKFreq[57][154] = 79; GBKFreq[47][153] = 78; GBKFreq[3][153] = 77; + * GBKFreq[76][134] = 76; GBKFreq[51][166] = 75; GBKFreq[58][176] = 74; + * GBKFreq[27][138] = 73; GBKFreq[73][126] = 72; GBKFreq[76][185] = 71; + * GBKFreq[52][186] = 70; GBKFreq[81][151] = 69; GBKFreq[26][50] = 68; + * GBKFreq[76][173] = 67; GBKFreq[106][56] = 66; GBKFreq[85][142] = 65; + * GBKFreq[11][103] = 64; GBKFreq[69][159] = 63; GBKFreq[53][142] = 62; + * GBKFreq[7][6] = 61; GBKFreq[84][59] = 60; GBKFreq[86][3] = 59; + * GBKFreq[64][144] = 58; GBKFreq[1][187] = 57; GBKFreq[82][128] = 56; + * GBKFreq[3][66] = 55; GBKFreq[68][133] = 54; GBKFreq[55][167] = 53; + * GBKFreq[52][130] = 52; GBKFreq[61][133] = 51; GBKFreq[72][181] = 50; + * GBKFreq[25][98] = 49; GBKFreq[84][149] = 48; GBKFreq[91][91] = 47; + * GBKFreq[47][188] = 46; GBKFreq[68][130] = 45; GBKFreq[22][44] = 44; + * GBKFreq[81][121] = 43; GBKFreq[72][140] = 42; GBKFreq[55][133] = 41; + * GBKFreq[55][185] = 40; GBKFreq[56][105] = 39; GBKFreq[60][30] = 38; + * GBKFreq[70][103] = 37; GBKFreq[62][141] = 36; GBKFreq[70][144] = 35; + * GBKFreq[59][111] = 34; GBKFreq[54][17] = 33; GBKFreq[18][190] = 32; + * GBKFreq[65][164] = 31; GBKFreq[83][125] = 30; GBKFreq[61][121] = 29; + * GBKFreq[48][13] = 28; GBKFreq[51][189] = 27; GBKFreq[65][68] = 26; + * GBKFreq[7][0] = 25; GBKFreq[76][188] = 24; GBKFreq[85][117] = 23; + * GBKFreq[45][33] = 22; GBKFreq[78][187] = 21; GBKFreq[106][48] = 20; + * GBKFreq[59][52] = 19; GBKFreq[86][185] = 18; GBKFreq[84][121] = 17; + * GBKFreq[82][189] = 16; GBKFreq[68][156] = 15; GBKFreq[55][125] = 14; + * GBKFreq[65][175] = 13; GBKFreq[7][140] = 12; GBKFreq[50][106] = 11; + * GBKFreq[59][124] = 10; GBKFreq[67][115] = 9; GBKFreq[82][114] = 8; + * GBKFreq[74][121] = 7; GBKFreq[106][69] = 6; GBKFreq[94][27] = 5; + * GBKFreq[78][98] = 4; GBKFreq[85][186] = 3; GBKFreq[108][90] = 2; + * GBKFreq[62][160] = 1; GBKFreq[60][169] = 0; + */ + KRFreq[31][43] = 600; + KRFreq[19][56] = 599; + KRFreq[38][46] = 598; + KRFreq[3][3] = 597; + KRFreq[29][77] = 596; + KRFreq[19][33] = 595; + KRFreq[30][0] = 594; + KRFreq[29][89] = 593; + KRFreq[31][26] = 592; + KRFreq[31][38] = 591; + KRFreq[32][85] = 590; + KRFreq[15][0] = 589; + KRFreq[16][54] = 588; + KRFreq[15][76] = 587; + KRFreq[31][25] = 586; + KRFreq[23][13] = 585; + KRFreq[28][34] = 584; + KRFreq[18][9] = 583; + KRFreq[29][37] = 582; + KRFreq[22][45] = 581; + KRFreq[19][46] = 580; + KRFreq[16][65] = 579; + KRFreq[23][5] = 578; + KRFreq[26][70] = 577; + KRFreq[31][53] = 576; + KRFreq[27][12] = 575; + KRFreq[30][67] = 574; + KRFreq[31][57] = 573; + KRFreq[20][20] = 572; + KRFreq[30][31] = 571; + KRFreq[20][72] = 570; + KRFreq[15][51] = 569; + KRFreq[3][8] = 568; + KRFreq[32][53] = 567; + KRFreq[27][85] = 566; + KRFreq[25][23] = 565; + KRFreq[15][44] = 564; + KRFreq[32][3] = 563; + KRFreq[31][68] = 562; + KRFreq[30][24] = 561; + KRFreq[29][49] = 560; + KRFreq[27][49] = 559; + KRFreq[23][23] = 558; + KRFreq[31][91] = 557; + KRFreq[31][46] = 556; + KRFreq[19][74] = 555; + KRFreq[27][27] = 554; + KRFreq[3][17] = 553; + KRFreq[20][38] = 552; + KRFreq[21][82] = 551; + KRFreq[28][25] = 550; + KRFreq[32][5] = 549; + KRFreq[31][23] = 548; + KRFreq[25][45] = 547; + KRFreq[32][87] = 546; + KRFreq[18][26] = 545; + KRFreq[24][10] = 544; + KRFreq[26][82] = 543; + KRFreq[15][89] = 542; + KRFreq[28][36] = 541; + KRFreq[28][31] = 540; + KRFreq[16][23] = 539; + KRFreq[16][77] = 538; + KRFreq[19][84] = 537; + KRFreq[23][72] = 536; + KRFreq[38][48] = 535; + KRFreq[23][2] = 534; + KRFreq[30][20] = 533; + KRFreq[38][47] = 532; + KRFreq[39][12] = 531; + KRFreq[23][21] = 530; + KRFreq[18][17] = 529; + KRFreq[30][87] = 528; + KRFreq[29][62] = 527; + KRFreq[29][87] = 526; + KRFreq[34][53] = 525; + KRFreq[32][29] = 524; + KRFreq[35][0] = 523; + KRFreq[24][43] = 522; + KRFreq[36][44] = 521; + KRFreq[20][30] = 520; + KRFreq[39][86] = 519; + KRFreq[22][14] = 518; + KRFreq[29][39] = 517; + KRFreq[28][38] = 516; + KRFreq[23][79] = 515; + KRFreq[24][56] = 514; + KRFreq[29][63] = 513; + KRFreq[31][45] = 512; + KRFreq[23][26] = 511; + KRFreq[15][87] = 510; + KRFreq[30][74] = 509; + KRFreq[24][69] = 508; + KRFreq[20][4] = 507; + KRFreq[27][50] = 506; + KRFreq[30][75] = 505; + KRFreq[24][13] = 504; + KRFreq[30][8] = 503; + KRFreq[31][6] = 502; + KRFreq[25][80] = 501; + KRFreq[36][8] = 500; + KRFreq[15][18] = 499; + KRFreq[39][23] = 498; + KRFreq[16][24] = 497; + KRFreq[31][89] = 496; + KRFreq[15][71] = 495; + KRFreq[15][57] = 494; + KRFreq[30][11] = 493; + KRFreq[15][36] = 492; + KRFreq[16][60] = 491; + KRFreq[24][45] = 490; + KRFreq[37][35] = 489; + KRFreq[24][87] = 488; + KRFreq[20][45] = 487; + KRFreq[31][90] = 486; + KRFreq[32][21] = 485; + KRFreq[19][70] = 484; + KRFreq[24][15] = 483; + KRFreq[26][92] = 482; + KRFreq[37][13] = 481; + KRFreq[39][2] = 480; + KRFreq[23][70] = 479; + KRFreq[27][25] = 478; + KRFreq[15][69] = 477; + KRFreq[19][61] = 476; + KRFreq[31][58] = 475; + KRFreq[24][57] = 474; + KRFreq[36][74] = 473; + KRFreq[21][6] = 472; + KRFreq[30][44] = 471; + KRFreq[15][91] = 470; + KRFreq[27][16] = 469; + KRFreq[29][42] = 468; + KRFreq[33][86] = 467; + KRFreq[29][41] = 466; + KRFreq[20][68] = 465; + KRFreq[25][47] = 464; + KRFreq[22][0] = 463; + KRFreq[18][14] = 462; + KRFreq[31][28] = 461; + KRFreq[15][2] = 460; + KRFreq[23][76] = 459; + KRFreq[38][32] = 458; + KRFreq[29][82] = 457; + KRFreq[21][86] = 456; + KRFreq[24][62] = 455; + KRFreq[31][64] = 454; + KRFreq[38][26] = 453; + KRFreq[32][86] = 452; + KRFreq[22][32] = 451; + KRFreq[19][59] = 450; + KRFreq[34][18] = 449; + KRFreq[18][54] = 448; + KRFreq[38][63] = 447; + KRFreq[36][23] = 446; + KRFreq[35][35] = 445; + KRFreq[32][62] = 444; + KRFreq[28][35] = 443; + KRFreq[27][13] = 442; + KRFreq[31][59] = 441; + KRFreq[29][29] = 440; + KRFreq[15][64] = 439; + KRFreq[26][84] = 438; + KRFreq[21][90] = 437; + KRFreq[20][24] = 436; + KRFreq[16][18] = 435; + KRFreq[22][23] = 434; + KRFreq[31][14] = 433; + KRFreq[15][1] = 432; + KRFreq[18][63] = 431; + KRFreq[19][10] = 430; + KRFreq[25][49] = 429; + KRFreq[36][57] = 428; + KRFreq[20][22] = 427; + KRFreq[15][15] = 426; + KRFreq[31][51] = 425; + KRFreq[24][60] = 424; + KRFreq[31][70] = 423; + KRFreq[15][7] = 422; + KRFreq[28][40] = 421; + KRFreq[18][41] = 420; + KRFreq[15][38] = 419; + KRFreq[32][0] = 418; + KRFreq[19][51] = 417; + KRFreq[34][62] = 416; + KRFreq[16][27] = 415; + KRFreq[20][70] = 414; + KRFreq[22][33] = 413; + KRFreq[26][73] = 412; + KRFreq[20][79] = 411; + KRFreq[23][6] = 410; + KRFreq[24][85] = 409; + KRFreq[38][51] = 408; + KRFreq[29][88] = 407; + KRFreq[38][55] = 406; + KRFreq[32][32] = 405; + KRFreq[27][18] = 404; + KRFreq[23][87] = 403; + KRFreq[35][6] = 402; + KRFreq[34][27] = 401; + KRFreq[39][35] = 400; + KRFreq[30][88] = 399; + KRFreq[32][92] = 398; + KRFreq[32][49] = 397; + KRFreq[24][61] = 396; + KRFreq[18][74] = 395; + KRFreq[23][77] = 394; + KRFreq[23][50] = 393; + KRFreq[23][32] = 392; + KRFreq[23][36] = 391; + KRFreq[38][38] = 390; + KRFreq[29][86] = 389; + KRFreq[36][15] = 388; + KRFreq[31][50] = 387; + KRFreq[15][86] = 386; + KRFreq[39][13] = 385; + KRFreq[34][26] = 384; + KRFreq[19][34] = 383; + KRFreq[16][3] = 382; + KRFreq[26][93] = 381; + KRFreq[19][67] = 380; + KRFreq[24][72] = 379; + KRFreq[29][17] = 378; + KRFreq[23][24] = 377; + KRFreq[25][19] = 376; + KRFreq[18][65] = 375; + KRFreq[30][78] = 374; + KRFreq[27][52] = 373; + KRFreq[22][18] = 372; + KRFreq[16][38] = 371; + KRFreq[21][26] = 370; + KRFreq[34][20] = 369; + KRFreq[15][42] = 368; + KRFreq[16][71] = 367; + KRFreq[17][17] = 366; + KRFreq[24][71] = 365; + KRFreq[18][84] = 364; + KRFreq[15][40] = 363; + KRFreq[31][62] = 362; + KRFreq[15][8] = 361; + KRFreq[16][69] = 360; + KRFreq[29][79] = 359; + KRFreq[38][91] = 358; + KRFreq[31][92] = 357; + KRFreq[20][77] = 356; + KRFreq[3][16] = 355; + KRFreq[27][87] = 354; + KRFreq[16][25] = 353; + KRFreq[36][33] = 352; + KRFreq[37][76] = 351; + KRFreq[30][12] = 350; + KRFreq[26][75] = 349; + KRFreq[25][14] = 348; + KRFreq[32][26] = 347; + KRFreq[23][22] = 346; + KRFreq[20][90] = 345; + KRFreq[19][8] = 344; + KRFreq[38][41] = 343; + KRFreq[34][2] = 342; + KRFreq[39][4] = 341; + KRFreq[27][89] = 340; + KRFreq[28][41] = 339; + KRFreq[28][44] = 338; + KRFreq[24][92] = 337; + KRFreq[34][65] = 336; + KRFreq[39][14] = 335; + KRFreq[21][38] = 334; + KRFreq[19][31] = 333; + KRFreq[37][39] = 332; + KRFreq[33][41] = 331; + KRFreq[38][4] = 330; + KRFreq[23][80] = 329; + KRFreq[25][24] = 328; + KRFreq[37][17] = 327; + KRFreq[22][16] = 326; + KRFreq[22][46] = 325; + KRFreq[33][91] = 324; + KRFreq[24][89] = 323; + KRFreq[30][52] = 322; + KRFreq[29][38] = 321; + KRFreq[38][85] = 320; + KRFreq[15][12] = 319; + KRFreq[27][58] = 318; + KRFreq[29][52] = 317; + KRFreq[37][38] = 316; + KRFreq[34][41] = 315; + KRFreq[31][65] = 314; + KRFreq[29][53] = 313; + KRFreq[22][47] = 312; + KRFreq[22][19] = 311; + KRFreq[26][0] = 310; + KRFreq[37][86] = 309; + KRFreq[35][4] = 308; + KRFreq[36][54] = 307; + KRFreq[20][76] = 306; + KRFreq[30][9] = 305; + KRFreq[30][33] = 304; + KRFreq[23][17] = 303; + KRFreq[23][33] = 302; + KRFreq[38][52] = 301; + KRFreq[15][19] = 300; + KRFreq[28][45] = 299; + KRFreq[29][78] = 298; + KRFreq[23][15] = 297; + KRFreq[33][5] = 296; + KRFreq[17][40] = 295; + KRFreq[30][83] = 294; + KRFreq[18][1] = 293; + KRFreq[30][81] = 292; + KRFreq[19][40] = 291; + KRFreq[24][47] = 290; + KRFreq[17][56] = 289; + KRFreq[39][80] = 288; + KRFreq[30][46] = 287; + KRFreq[16][61] = 286; + KRFreq[26][78] = 285; + KRFreq[26][57] = 284; + KRFreq[20][46] = 283; + KRFreq[25][15] = 282; + KRFreq[25][91] = 281; + KRFreq[21][83] = 280; + KRFreq[30][77] = 279; + KRFreq[35][30] = 278; + KRFreq[30][34] = 277; + KRFreq[20][69] = 276; + KRFreq[35][10] = 275; + KRFreq[29][70] = 274; + KRFreq[22][50] = 273; + KRFreq[18][0] = 272; + KRFreq[22][64] = 271; + KRFreq[38][65] = 270; + KRFreq[22][70] = 269; + KRFreq[24][58] = 268; + KRFreq[19][66] = 267; + KRFreq[30][59] = 266; + KRFreq[37][14] = 265; + KRFreq[16][56] = 264; + KRFreq[29][85] = 263; + KRFreq[31][15] = 262; + KRFreq[36][84] = 261; + KRFreq[39][15] = 260; + KRFreq[39][90] = 259; + KRFreq[18][12] = 258; + KRFreq[21][93] = 257; + KRFreq[24][66] = 256; + KRFreq[27][90] = 255; + KRFreq[25][90] = 254; + KRFreq[22][24] = 253; + KRFreq[36][67] = 252; + KRFreq[33][90] = 251; + KRFreq[15][60] = 250; + KRFreq[23][85] = 249; + KRFreq[34][1] = 248; + KRFreq[39][37] = 247; + KRFreq[21][18] = 246; + KRFreq[34][4] = 245; + KRFreq[28][33] = 244; + KRFreq[15][13] = 243; + KRFreq[32][22] = 242; + KRFreq[30][76] = 241; + KRFreq[20][21] = 240; + KRFreq[38][66] = 239; + KRFreq[32][55] = 238; + KRFreq[32][89] = 237; + KRFreq[25][26] = 236; + KRFreq[16][80] = 235; + KRFreq[15][43] = 234; + KRFreq[38][54] = 233; + KRFreq[39][68] = 232; + KRFreq[22][88] = 231; + KRFreq[21][84] = 230; + KRFreq[21][17] = 229; + KRFreq[20][28] = 228; + KRFreq[32][1] = 227; + KRFreq[33][87] = 226; + KRFreq[38][71] = 225; + KRFreq[37][47] = 224; + KRFreq[18][77] = 223; + KRFreq[37][58] = 222; + KRFreq[34][74] = 221; + KRFreq[32][54] = 220; + KRFreq[27][33] = 219; + KRFreq[32][93] = 218; + KRFreq[23][51] = 217; + KRFreq[20][57] = 216; + KRFreq[22][37] = 215; + KRFreq[39][10] = 214; + KRFreq[39][17] = 213; + KRFreq[33][4] = 212; + KRFreq[32][84] = 211; + KRFreq[34][3] = 210; + KRFreq[28][27] = 209; + KRFreq[15][79] = 208; + KRFreq[34][21] = 207; + KRFreq[34][69] = 206; + KRFreq[21][62] = 205; + KRFreq[36][24] = 204; + KRFreq[16][89] = 203; + KRFreq[18][48] = 202; + KRFreq[38][15] = 201; + KRFreq[36][58] = 200; + KRFreq[21][56] = 199; + KRFreq[34][48] = 198; + KRFreq[21][15] = 197; + KRFreq[39][3] = 196; + KRFreq[16][44] = 195; + KRFreq[18][79] = 194; + KRFreq[25][13] = 193; + KRFreq[29][47] = 192; + KRFreq[38][88] = 191; + KRFreq[20][71] = 190; + KRFreq[16][58] = 189; + KRFreq[35][57] = 188; + KRFreq[29][30] = 187; + KRFreq[29][23] = 186; + KRFreq[34][93] = 185; + KRFreq[30][85] = 184; + KRFreq[15][80] = 183; + KRFreq[32][78] = 182; + KRFreq[37][82] = 181; + KRFreq[22][40] = 180; + KRFreq[21][69] = 179; + KRFreq[26][85] = 178; + KRFreq[31][31] = 177; + KRFreq[28][64] = 176; + KRFreq[38][13] = 175; + KRFreq[25][2] = 174; + KRFreq[22][34] = 173; + KRFreq[28][28] = 172; + KRFreq[24][91] = 171; + KRFreq[33][74] = 170; + KRFreq[29][40] = 169; + KRFreq[15][77] = 168; + KRFreq[32][80] = 167; + KRFreq[30][41] = 166; + KRFreq[23][30] = 165; + KRFreq[24][63] = 164; + KRFreq[30][53] = 163; + KRFreq[39][70] = 162; + KRFreq[23][61] = 161; + KRFreq[37][27] = 160; + KRFreq[16][55] = 159; + KRFreq[22][74] = 158; + KRFreq[26][50] = 157; + KRFreq[16][10] = 156; + KRFreq[34][63] = 155; + KRFreq[35][14] = 154; + KRFreq[17][7] = 153; + KRFreq[15][59] = 152; + KRFreq[27][23] = 151; + KRFreq[18][70] = 150; + KRFreq[32][56] = 149; + KRFreq[37][87] = 148; + KRFreq[17][61] = 147; + KRFreq[18][83] = 146; + KRFreq[23][86] = 145; + KRFreq[17][31] = 144; + KRFreq[23][83] = 143; + KRFreq[35][2] = 142; + KRFreq[18][64] = 141; + KRFreq[27][43] = 140; + KRFreq[32][42] = 139; + KRFreq[25][76] = 138; + KRFreq[19][85] = 137; + KRFreq[37][81] = 136; + KRFreq[38][83] = 135; + KRFreq[35][7] = 134; + KRFreq[16][51] = 133; + KRFreq[27][22] = 132; + KRFreq[16][76] = 131; + KRFreq[22][4] = 130; + KRFreq[38][84] = 129; + KRFreq[17][83] = 128; + KRFreq[24][46] = 127; + KRFreq[33][15] = 126; + KRFreq[20][48] = 125; + KRFreq[17][30] = 124; + KRFreq[30][93] = 123; + KRFreq[28][11] = 122; + KRFreq[28][30] = 121; + KRFreq[15][62] = 120; + KRFreq[17][87] = 119; + KRFreq[32][81] = 118; + KRFreq[23][37] = 117; + KRFreq[30][22] = 116; + KRFreq[32][66] = 115; + KRFreq[33][78] = 114; + KRFreq[21][4] = 113; + KRFreq[31][17] = 112; + KRFreq[39][61] = 111; + KRFreq[18][76] = 110; + KRFreq[15][85] = 109; + KRFreq[31][47] = 108; + KRFreq[19][57] = 107; + KRFreq[23][55] = 106; + KRFreq[27][29] = 105; + KRFreq[29][46] = 104; + KRFreq[33][0] = 103; + KRFreq[16][83] = 102; + KRFreq[39][78] = 101; + KRFreq[32][77] = 100; + KRFreq[36][25] = 99; + KRFreq[34][19] = 98; + KRFreq[38][49] = 97; + KRFreq[19][25] = 96; + KRFreq[23][53] = 95; + KRFreq[28][43] = 94; + KRFreq[31][44] = 93; + KRFreq[36][34] = 92; + KRFreq[16][34] = 91; + KRFreq[35][1] = 90; + KRFreq[19][87] = 89; + KRFreq[18][53] = 88; + KRFreq[29][54] = 87; + KRFreq[22][41] = 86; + KRFreq[38][18] = 85; + KRFreq[22][2] = 84; + KRFreq[20][3] = 83; + KRFreq[39][69] = 82; + KRFreq[30][29] = 81; + KRFreq[28][19] = 80; + KRFreq[29][90] = 79; + KRFreq[17][86] = 78; + KRFreq[15][9] = 77; + KRFreq[39][73] = 76; + KRFreq[15][37] = 75; + KRFreq[35][40] = 74; + KRFreq[33][77] = 73; + KRFreq[27][86] = 72; + KRFreq[36][79] = 71; + KRFreq[23][18] = 70; + KRFreq[34][87] = 69; + KRFreq[39][24] = 68; + KRFreq[26][8] = 67; + KRFreq[33][48] = 66; + KRFreq[39][30] = 65; + KRFreq[33][28] = 64; + KRFreq[16][67] = 63; + KRFreq[31][78] = 62; + KRFreq[32][23] = 61; + KRFreq[24][55] = 60; + KRFreq[30][68] = 59; + KRFreq[18][60] = 58; + KRFreq[15][17] = 57; + KRFreq[23][34] = 56; + KRFreq[20][49] = 55; + KRFreq[15][78] = 54; + KRFreq[24][14] = 53; + KRFreq[19][41] = 52; + KRFreq[31][55] = 51; + KRFreq[21][39] = 50; + KRFreq[35][9] = 49; + KRFreq[30][15] = 48; + KRFreq[20][52] = 47; + KRFreq[35][71] = 46; + KRFreq[20][7] = 45; + KRFreq[29][72] = 44; + KRFreq[37][77] = 43; + KRFreq[22][35] = 42; + KRFreq[20][61] = 41; + KRFreq[31][60] = 40; + KRFreq[20][93] = 39; + KRFreq[27][92] = 38; + KRFreq[28][16] = 37; + KRFreq[36][26] = 36; + KRFreq[18][89] = 35; + KRFreq[21][63] = 34; + KRFreq[22][52] = 33; + KRFreq[24][65] = 32; + KRFreq[31][8] = 31; + KRFreq[31][49] = 30; + KRFreq[33][30] = 29; + KRFreq[37][15] = 28; + KRFreq[18][18] = 27; + KRFreq[25][50] = 26; + KRFreq[29][20] = 25; + KRFreq[35][48] = 24; + KRFreq[38][75] = 23; + KRFreq[26][83] = 22; + KRFreq[21][87] = 21; + KRFreq[27][71] = 20; + KRFreq[32][91] = 19; + KRFreq[25][73] = 18; + KRFreq[16][84] = 17; + KRFreq[25][31] = 16; + KRFreq[17][90] = 15; + KRFreq[18][40] = 14; + KRFreq[17][77] = 13; + KRFreq[17][35] = 12; + KRFreq[23][52] = 11; + KRFreq[23][35] = 10; + KRFreq[16][5] = 9; + KRFreq[23][58] = 8; + KRFreq[19][60] = 7; + KRFreq[30][32] = 6; + KRFreq[38][34] = 5; + KRFreq[23][4] = 4; + KRFreq[23][1] = 3; + KRFreq[27][57] = 2; + KRFreq[39][38] = 1; + KRFreq[32][33] = 0; + JPFreq[3][74] = 600; + JPFreq[3][45] = 599; + JPFreq[3][3] = 598; + JPFreq[3][24] = 597; + JPFreq[3][30] = 596; + JPFreq[3][42] = 595; + JPFreq[3][46] = 594; + JPFreq[3][39] = 593; + JPFreq[3][11] = 592; + JPFreq[3][37] = 591; + JPFreq[3][38] = 590; + JPFreq[3][31] = 589; + JPFreq[3][41] = 588; + JPFreq[3][5] = 587; + JPFreq[3][10] = 586; + JPFreq[3][75] = 585; + JPFreq[3][65] = 584; + JPFreq[3][72] = 583; + JPFreq[37][91] = 582; + JPFreq[0][27] = 581; + JPFreq[3][18] = 580; + JPFreq[3][22] = 579; + JPFreq[3][61] = 578; + JPFreq[3][14] = 577; + JPFreq[24][80] = 576; + JPFreq[4][82] = 575; + JPFreq[17][80] = 574; + JPFreq[30][44] = 573; + JPFreq[3][73] = 572; + JPFreq[3][64] = 571; + JPFreq[38][14] = 570; + JPFreq[33][70] = 569; + JPFreq[3][1] = 568; + JPFreq[3][16] = 567; + JPFreq[3][35] = 566; + JPFreq[3][40] = 565; + JPFreq[4][74] = 564; + JPFreq[4][24] = 563; + JPFreq[42][59] = 562; + JPFreq[3][7] = 561; + JPFreq[3][71] = 560; + JPFreq[3][12] = 559; + JPFreq[15][75] = 558; + JPFreq[3][20] = 557; + JPFreq[4][39] = 556; + JPFreq[34][69] = 555; + JPFreq[3][28] = 554; + JPFreq[35][24] = 553; + JPFreq[3][82] = 552; + JPFreq[28][47] = 551; + JPFreq[3][67] = 550; + JPFreq[37][16] = 549; + JPFreq[26][93] = 548; + JPFreq[4][1] = 547; + JPFreq[26][85] = 546; + JPFreq[31][14] = 545; + JPFreq[4][3] = 544; + JPFreq[4][72] = 543; + JPFreq[24][51] = 542; + JPFreq[27][51] = 541; + JPFreq[27][49] = 540; + JPFreq[22][77] = 539; + JPFreq[27][10] = 538; + JPFreq[29][68] = 537; + JPFreq[20][35] = 536; + JPFreq[41][11] = 535; + JPFreq[24][70] = 534; + JPFreq[36][61] = 533; + JPFreq[31][23] = 532; + JPFreq[43][16] = 531; + JPFreq[23][68] = 530; + JPFreq[32][15] = 529; + JPFreq[3][32] = 528; + JPFreq[19][53] = 527; + JPFreq[40][83] = 526; + JPFreq[4][14] = 525; + JPFreq[36][9] = 524; + JPFreq[4][73] = 523; + JPFreq[23][10] = 522; + JPFreq[3][63] = 521; + JPFreq[39][14] = 520; + JPFreq[3][78] = 519; + JPFreq[33][47] = 518; + JPFreq[21][39] = 517; + JPFreq[34][46] = 516; + JPFreq[36][75] = 515; + JPFreq[41][92] = 514; + JPFreq[37][93] = 513; + JPFreq[4][34] = 512; + JPFreq[15][86] = 511; + JPFreq[46][1] = 510; + JPFreq[37][65] = 509; + JPFreq[3][62] = 508; + JPFreq[32][73] = 507; + JPFreq[21][65] = 506; + JPFreq[29][75] = 505; + JPFreq[26][51] = 504; + JPFreq[3][34] = 503; + JPFreq[4][10] = 502; + JPFreq[30][22] = 501; + JPFreq[35][73] = 500; + JPFreq[17][82] = 499; + JPFreq[45][8] = 498; + JPFreq[27][73] = 497; + JPFreq[18][55] = 496; + JPFreq[25][2] = 495; + JPFreq[3][26] = 494; + JPFreq[45][46] = 493; + JPFreq[4][22] = 492; + JPFreq[4][40] = 491; + JPFreq[18][10] = 490; + JPFreq[32][9] = 489; + JPFreq[26][49] = 488; + JPFreq[3][47] = 487; + JPFreq[24][65] = 486; + JPFreq[4][76] = 485; + JPFreq[43][67] = 484; + JPFreq[3][9] = 483; + JPFreq[41][37] = 482; + JPFreq[33][68] = 481; + JPFreq[43][31] = 480; + JPFreq[19][55] = 479; + JPFreq[4][30] = 478; + JPFreq[27][33] = 477; + JPFreq[16][62] = 476; + JPFreq[36][35] = 475; + JPFreq[37][15] = 474; + JPFreq[27][70] = 473; + JPFreq[22][71] = 472; + JPFreq[33][45] = 471; + JPFreq[31][78] = 470; + JPFreq[43][59] = 469; + JPFreq[32][19] = 468; + JPFreq[17][28] = 467; + JPFreq[40][28] = 466; + JPFreq[20][93] = 465; + JPFreq[18][15] = 464; + JPFreq[4][23] = 463; + JPFreq[3][23] = 462; + JPFreq[26][64] = 461; + JPFreq[44][92] = 460; + JPFreq[17][27] = 459; + JPFreq[3][56] = 458; + JPFreq[25][38] = 457; + JPFreq[23][31] = 456; + JPFreq[35][43] = 455; + JPFreq[4][54] = 454; + JPFreq[35][19] = 453; + JPFreq[22][47] = 452; + JPFreq[42][0] = 451; + JPFreq[23][28] = 450; + JPFreq[46][33] = 449; + JPFreq[36][85] = 448; + JPFreq[31][12] = 447; + JPFreq[3][76] = 446; + JPFreq[4][75] = 445; + JPFreq[36][56] = 444; + JPFreq[4][64] = 443; + JPFreq[25][77] = 442; + JPFreq[15][52] = 441; + JPFreq[33][73] = 440; + JPFreq[3][55] = 439; + JPFreq[43][82] = 438; + JPFreq[27][82] = 437; + JPFreq[20][3] = 436; + JPFreq[40][51] = 435; + JPFreq[3][17] = 434; + JPFreq[27][71] = 433; + JPFreq[4][52] = 432; + JPFreq[44][48] = 431; + JPFreq[27][2] = 430; + JPFreq[17][39] = 429; + JPFreq[31][8] = 428; + JPFreq[44][54] = 427; + JPFreq[43][18] = 426; + JPFreq[43][77] = 425; + JPFreq[4][61] = 424; + JPFreq[19][91] = 423; + JPFreq[31][13] = 422; + JPFreq[44][71] = 421; + JPFreq[20][0] = 420; + JPFreq[23][87] = 419; + JPFreq[21][14] = 418; + JPFreq[29][13] = 417; + JPFreq[3][58] = 416; + JPFreq[26][18] = 415; + JPFreq[4][47] = 414; + JPFreq[4][18] = 413; + JPFreq[3][53] = 412; + JPFreq[26][92] = 411; + JPFreq[21][7] = 410; + JPFreq[4][37] = 409; + JPFreq[4][63] = 408; + JPFreq[36][51] = 407; + JPFreq[4][32] = 406; + JPFreq[28][73] = 405; + JPFreq[4][50] = 404; + JPFreq[41][60] = 403; + JPFreq[23][1] = 402; + JPFreq[36][92] = 401; + JPFreq[15][41] = 400; + JPFreq[21][71] = 399; + JPFreq[41][30] = 398; + JPFreq[32][76] = 397; + JPFreq[17][34] = 396; + JPFreq[26][15] = 395; + JPFreq[26][25] = 394; + JPFreq[31][77] = 393; + JPFreq[31][3] = 392; + JPFreq[46][34] = 391; + JPFreq[27][84] = 390; + JPFreq[23][8] = 389; + JPFreq[16][0] = 388; + JPFreq[28][80] = 387; + JPFreq[26][54] = 386; + JPFreq[33][18] = 385; + JPFreq[31][20] = 384; + JPFreq[31][62] = 383; + JPFreq[30][41] = 382; + JPFreq[33][30] = 381; + JPFreq[45][45] = 380; + JPFreq[37][82] = 379; + JPFreq[15][33] = 378; + JPFreq[20][12] = 377; + JPFreq[18][5] = 376; + JPFreq[28][86] = 375; + JPFreq[30][19] = 374; + JPFreq[42][43] = 373; + JPFreq[36][31] = 372; + JPFreq[17][93] = 371; + JPFreq[4][15] = 370; + JPFreq[21][20] = 369; + JPFreq[23][21] = 368; + JPFreq[28][72] = 367; + JPFreq[4][20] = 366; + JPFreq[26][55] = 365; + JPFreq[21][5] = 364; + JPFreq[19][16] = 363; + JPFreq[23][64] = 362; + JPFreq[40][59] = 361; + JPFreq[37][26] = 360; + JPFreq[26][56] = 359; + JPFreq[4][12] = 358; + JPFreq[33][71] = 357; + JPFreq[32][39] = 356; + JPFreq[38][40] = 355; + JPFreq[22][74] = 354; + JPFreq[3][25] = 353; + JPFreq[15][48] = 352; + JPFreq[41][82] = 351; + JPFreq[41][9] = 350; + JPFreq[25][48] = 349; + JPFreq[31][71] = 348; + JPFreq[43][29] = 347; + JPFreq[26][80] = 346; + JPFreq[4][5] = 345; + JPFreq[18][71] = 344; + JPFreq[29][0] = 343; + JPFreq[43][43] = 342; + JPFreq[23][81] = 341; + JPFreq[4][42] = 340; + JPFreq[44][28] = 339; + JPFreq[23][93] = 338; + JPFreq[17][81] = 337; + JPFreq[25][25] = 336; + JPFreq[41][23] = 335; + JPFreq[34][35] = 334; + JPFreq[4][53] = 333; + JPFreq[28][36] = 332; + JPFreq[4][41] = 331; + JPFreq[25][60] = 330; + JPFreq[23][20] = 329; + JPFreq[3][43] = 328; + JPFreq[24][79] = 327; + JPFreq[29][41] = 326; + JPFreq[30][83] = 325; + JPFreq[3][50] = 324; + JPFreq[22][18] = 323; + JPFreq[18][3] = 322; + JPFreq[39][30] = 321; + JPFreq[4][28] = 320; + JPFreq[21][64] = 319; + JPFreq[4][68] = 318; + JPFreq[17][71] = 317; + JPFreq[27][0] = 316; + JPFreq[39][28] = 315; + JPFreq[30][13] = 314; + JPFreq[36][70] = 313; + JPFreq[20][82] = 312; + JPFreq[33][38] = 311; + JPFreq[44][87] = 310; + JPFreq[34][45] = 309; + JPFreq[4][26] = 308; + JPFreq[24][44] = 307; + JPFreq[38][67] = 306; + JPFreq[38][6] = 305; + JPFreq[30][68] = 304; + JPFreq[15][89] = 303; + JPFreq[24][93] = 302; + JPFreq[40][41] = 301; + JPFreq[38][3] = 300; + JPFreq[28][23] = 299; + JPFreq[26][17] = 298; + JPFreq[4][38] = 297; + JPFreq[22][78] = 296; + JPFreq[15][37] = 295; + JPFreq[25][85] = 294; + JPFreq[4][9] = 293; + JPFreq[4][7] = 292; + JPFreq[27][53] = 291; + JPFreq[39][29] = 290; + JPFreq[41][43] = 289; + JPFreq[25][62] = 288; + JPFreq[4][48] = 287; + JPFreq[28][28] = 286; + JPFreq[21][40] = 285; + JPFreq[36][73] = 284; + JPFreq[26][39] = 283; + JPFreq[22][54] = 282; + JPFreq[33][5] = 281; + JPFreq[19][21] = 280; + JPFreq[46][31] = 279; + JPFreq[20][64] = 278; + JPFreq[26][63] = 277; + JPFreq[22][23] = 276; + JPFreq[25][81] = 275; + JPFreq[4][62] = 274; + JPFreq[37][31] = 273; + JPFreq[40][52] = 272; + JPFreq[29][79] = 271; + JPFreq[41][48] = 270; + JPFreq[31][57] = 269; + JPFreq[32][92] = 268; + JPFreq[36][36] = 267; + JPFreq[27][7] = 266; + JPFreq[35][29] = 265; + JPFreq[37][34] = 264; + JPFreq[34][42] = 263; + JPFreq[27][15] = 262; + JPFreq[33][27] = 261; + JPFreq[31][38] = 260; + JPFreq[19][79] = 259; + JPFreq[4][31] = 258; + JPFreq[4][66] = 257; + JPFreq[17][32] = 256; + JPFreq[26][67] = 255; + JPFreq[16][30] = 254; + JPFreq[26][46] = 253; + JPFreq[24][26] = 252; + JPFreq[35][10] = 251; + JPFreq[18][37] = 250; + JPFreq[3][19] = 249; + JPFreq[33][69] = 248; + JPFreq[31][9] = 247; + JPFreq[45][29] = 246; + JPFreq[3][15] = 245; + JPFreq[18][54] = 244; + JPFreq[3][44] = 243; + JPFreq[31][29] = 242; + JPFreq[18][45] = 241; + JPFreq[38][28] = 240; + JPFreq[24][12] = 239; + JPFreq[35][82] = 238; + JPFreq[17][43] = 237; + JPFreq[28][9] = 236; + JPFreq[23][25] = 235; + JPFreq[44][37] = 234; + JPFreq[23][75] = 233; + JPFreq[23][92] = 232; + JPFreq[0][24] = 231; + JPFreq[19][74] = 230; + JPFreq[45][32] = 229; + JPFreq[16][72] = 228; + JPFreq[16][93] = 227; + JPFreq[45][13] = 226; + JPFreq[24][8] = 225; + JPFreq[25][47] = 224; + JPFreq[28][26] = 223; + JPFreq[43][81] = 222; + JPFreq[32][71] = 221; + JPFreq[18][41] = 220; + JPFreq[26][62] = 219; + JPFreq[41][24] = 218; + JPFreq[40][11] = 217; + JPFreq[43][57] = 216; + JPFreq[34][53] = 215; + JPFreq[20][32] = 214; + JPFreq[34][43] = 213; + JPFreq[41][91] = 212; + JPFreq[29][57] = 211; + JPFreq[15][43] = 210; + JPFreq[22][89] = 209; + JPFreq[33][83] = 208; + JPFreq[43][20] = 207; + JPFreq[25][58] = 206; + JPFreq[30][30] = 205; + JPFreq[4][56] = 204; + JPFreq[17][64] = 203; + JPFreq[23][0] = 202; + JPFreq[44][12] = 201; + JPFreq[25][37] = 200; + JPFreq[35][13] = 199; + JPFreq[20][30] = 198; + JPFreq[21][84] = 197; + JPFreq[29][14] = 196; + JPFreq[30][5] = 195; + JPFreq[37][2] = 194; + JPFreq[4][78] = 193; + JPFreq[29][78] = 192; + JPFreq[29][84] = 191; + JPFreq[32][86] = 190; + JPFreq[20][68] = 189; + JPFreq[30][39] = 188; + JPFreq[15][69] = 187; + JPFreq[4][60] = 186; + JPFreq[20][61] = 185; + JPFreq[41][67] = 184; + JPFreq[16][35] = 183; + JPFreq[36][57] = 182; + JPFreq[39][80] = 181; + JPFreq[4][59] = 180; + JPFreq[4][44] = 179; + JPFreq[40][54] = 178; + JPFreq[30][8] = 177; + JPFreq[44][30] = 176; + JPFreq[31][93] = 175; + JPFreq[31][47] = 174; + JPFreq[16][70] = 173; + JPFreq[21][0] = 172; + JPFreq[17][35] = 171; + JPFreq[21][67] = 170; + JPFreq[44][18] = 169; + JPFreq[36][29] = 168; + JPFreq[18][67] = 167; + JPFreq[24][28] = 166; + JPFreq[36][24] = 165; + JPFreq[23][5] = 164; + JPFreq[31][65] = 163; + JPFreq[26][59] = 162; + JPFreq[28][2] = 161; + JPFreq[39][69] = 160; + JPFreq[42][40] = 159; + JPFreq[37][80] = 158; + JPFreq[15][66] = 157; + JPFreq[34][38] = 156; + JPFreq[28][48] = 155; + JPFreq[37][77] = 154; + JPFreq[29][34] = 153; + JPFreq[33][12] = 152; + JPFreq[4][65] = 151; + JPFreq[30][31] = 150; + JPFreq[27][92] = 149; + JPFreq[4][2] = 148; + JPFreq[4][51] = 147; + JPFreq[23][77] = 146; + JPFreq[4][35] = 145; + JPFreq[3][13] = 144; + JPFreq[26][26] = 143; + JPFreq[44][4] = 142; + JPFreq[39][53] = 141; + JPFreq[20][11] = 140; + JPFreq[40][33] = 139; + JPFreq[45][7] = 138; + JPFreq[4][70] = 137; + JPFreq[3][49] = 136; + JPFreq[20][59] = 135; + JPFreq[21][12] = 134; + JPFreq[33][53] = 133; + JPFreq[20][14] = 132; + JPFreq[37][18] = 131; + JPFreq[18][17] = 130; + JPFreq[36][23] = 129; + JPFreq[18][57] = 128; + JPFreq[26][74] = 127; + JPFreq[35][2] = 126; + JPFreq[38][58] = 125; + JPFreq[34][68] = 124; + JPFreq[29][81] = 123; + JPFreq[20][69] = 122; + JPFreq[39][86] = 121; + JPFreq[4][16] = 120; + JPFreq[16][49] = 119; + JPFreq[15][72] = 118; + JPFreq[26][35] = 117; + JPFreq[32][14] = 116; + JPFreq[40][90] = 115; + JPFreq[33][79] = 114; + JPFreq[35][4] = 113; + JPFreq[23][33] = 112; + JPFreq[19][19] = 111; + JPFreq[31][41] = 110; + JPFreq[44][1] = 109; + JPFreq[22][56] = 108; + JPFreq[31][27] = 107; + JPFreq[32][18] = 106; + JPFreq[27][32] = 105; + JPFreq[37][39] = 104; + JPFreq[42][11] = 103; + JPFreq[29][71] = 102; + JPFreq[32][58] = 101; + JPFreq[46][10] = 100; + JPFreq[17][30] = 99; + JPFreq[38][15] = 98; + JPFreq[29][60] = 97; + JPFreq[4][11] = 96; + JPFreq[38][31] = 95; + JPFreq[40][79] = 94; + JPFreq[28][49] = 93; + JPFreq[28][84] = 92; + JPFreq[26][77] = 91; + JPFreq[22][32] = 90; + JPFreq[33][17] = 89; + JPFreq[23][18] = 88; + JPFreq[32][64] = 87; + JPFreq[4][6] = 86; + JPFreq[33][51] = 85; + JPFreq[44][77] = 84; + JPFreq[29][5] = 83; + JPFreq[46][25] = 82; + JPFreq[19][58] = 81; + JPFreq[4][46] = 80; + JPFreq[15][71] = 79; + JPFreq[18][58] = 78; + JPFreq[26][45] = 77; + JPFreq[45][66] = 76; + JPFreq[34][10] = 75; + JPFreq[19][37] = 74; + JPFreq[33][65] = 73; + JPFreq[44][52] = 72; + JPFreq[16][38] = 71; + JPFreq[36][46] = 70; + JPFreq[20][26] = 69; + JPFreq[30][37] = 68; + JPFreq[4][58] = 67; + JPFreq[43][2] = 66; + JPFreq[30][18] = 65; + JPFreq[19][35] = 64; + JPFreq[15][68] = 63; + JPFreq[3][36] = 62; + JPFreq[35][40] = 61; + JPFreq[36][32] = 60; + JPFreq[37][14] = 59; + JPFreq[17][11] = 58; + JPFreq[19][78] = 57; + JPFreq[37][11] = 56; + JPFreq[28][63] = 55; + JPFreq[29][61] = 54; + JPFreq[33][3] = 53; + JPFreq[41][52] = 52; + JPFreq[33][63] = 51; + JPFreq[22][41] = 50; + JPFreq[4][19] = 49; + JPFreq[32][41] = 48; + JPFreq[24][4] = 47; + JPFreq[31][28] = 46; + JPFreq[43][30] = 45; + JPFreq[17][3] = 44; + JPFreq[43][70] = 43; + JPFreq[34][19] = 42; + JPFreq[20][77] = 41; + JPFreq[18][83] = 40; + JPFreq[17][15] = 39; + JPFreq[23][61] = 38; + JPFreq[40][27] = 37; + JPFreq[16][48] = 36; + JPFreq[39][78] = 35; + JPFreq[41][53] = 34; + JPFreq[40][91] = 33; + JPFreq[40][72] = 32; + JPFreq[18][52] = 31; + JPFreq[35][66] = 30; + JPFreq[39][93] = 29; + JPFreq[19][48] = 28; + JPFreq[26][36] = 27; + JPFreq[27][25] = 26; + JPFreq[42][71] = 25; + JPFreq[42][85] = 24; + JPFreq[26][48] = 23; + JPFreq[28][15] = 22; + JPFreq[3][66] = 21; + JPFreq[25][24] = 20; + JPFreq[27][43] = 19; + JPFreq[27][78] = 18; + JPFreq[45][43] = 17; + JPFreq[27][72] = 16; + JPFreq[40][29] = 15; + JPFreq[41][0] = 14; + JPFreq[19][57] = 13; + JPFreq[15][59] = 12; + JPFreq[29][29] = 11; + JPFreq[4][25] = 10; + JPFreq[21][42] = 9; + JPFreq[23][35] = 8; + JPFreq[33][1] = 7; + JPFreq[4][57] = 6; + JPFreq[17][60] = 5; + JPFreq[25][19] = 4; + JPFreq[22][65] = 3; + JPFreq[42][29] = 2; + JPFreq[27][66] = 1; + JPFreq[26][89] = 0; + } +} + +class Encoding { + // Supported Encoding Types + public static int GB2312 = 0; + + public static int GBK = 1; + + public static int GB18030 = 2; + + public static int HZ = 3; + + public static int BIG5 = 4; + + public static int CNS11643 = 5; + + public static int UTF8 = 6; + + public static int UTF8T = 7; + + public static int UTF8S = 8; + + public static int UNICODE = 9; + + public static int UNICODET = 10; + + public static int UNICODES = 11; + + public static int ISO2022CN = 12; + + public static int ISO2022CN_CNS = 13; + + public static int ISO2022CN_GB = 14; + + public static int EUC_KR = 15; + + public static int CP949 = 16; + + public static int ISO2022KR = 17; + + public static int JOHAB = 18; + + public static int SJIS = 19; + + public static int EUC_JP = 20; + + public static int ISO2022JP = 21; + + public static int ASCII = 22; + + public static int OTHER = 23; + + public static int TOTALTYPES = 24; + + public final static int SIMP = 0; + + public final static int TRAD = 1; + + // Names of the encodings as understood by Java + public static String[] javaname; + + // Names of the encodings for human viewing + public static String[] nicename; + + // Names of charsets as used in charset parameter of HTML Meta tag + public static String[] htmlname; + + // Constructor + public Encoding() { + javaname = new String[TOTALTYPES]; + nicename = new String[TOTALTYPES]; + htmlname = new String[TOTALTYPES]; + // Assign encoding names + javaname[GB2312] = "GB2312"; + javaname[GBK] = "GBK"; + javaname[GB18030] = "GB18030"; + javaname[HZ] = "ASCII"; // What to put here? Sun doesn't support HZ + javaname[ISO2022CN_GB] = "ISO2022CN_GB"; + javaname[BIG5] = "BIG5"; + javaname[CNS11643] = "EUC-TW"; + javaname[ISO2022CN_CNS] = "ISO2022CN_CNS"; + javaname[ISO2022CN] = "ISO2022CN"; + javaname[UTF8] = "UTF-8"; + javaname[UTF8T] = "UTF-8"; + javaname[UTF8S] = "UTF-8"; + javaname[UNICODE] = "Unicode"; + javaname[UNICODET] = "Unicode"; + javaname[UNICODES] = "Unicode"; + javaname[EUC_KR] = "EUC_KR"; + javaname[CP949] = "MS949"; + javaname[ISO2022KR] = "ISO2022KR"; + javaname[JOHAB] = "Johab"; + javaname[SJIS] = "SJIS"; + javaname[EUC_JP] = "EUC_JP"; + javaname[ISO2022JP] = "ISO2022JP"; + javaname[ASCII] = "ASCII"; + javaname[OTHER] = "ISO8859_1"; + // Assign encoding names + htmlname[GB2312] = "GB2312"; + htmlname[GBK] = "GBK"; + htmlname[GB18030] = "GB18030"; + htmlname[HZ] = "HZ-GB-2312"; + htmlname[ISO2022CN_GB] = "ISO-2022-CN-EXT"; + htmlname[BIG5] = "BIG5"; + htmlname[CNS11643] = "EUC-TW"; + htmlname[ISO2022CN_CNS] = "ISO-2022-CN-EXT"; + htmlname[ISO2022CN] = "ISO-2022-CN"; + htmlname[UTF8] = "UTF-8"; + htmlname[UTF8T] = "UTF-8"; + htmlname[UTF8S] = "UTF-8"; + htmlname[UNICODE] = "UTF-16"; + htmlname[UNICODET] = "UTF-16"; + htmlname[UNICODES] = "UTF-16"; + htmlname[EUC_KR] = "EUC-KR"; + htmlname[CP949] = "x-windows-949"; + htmlname[ISO2022KR] = "ISO-2022-KR"; + htmlname[JOHAB] = "x-Johab"; + htmlname[SJIS] = "Shift_JIS"; + htmlname[EUC_JP] = "EUC-JP"; + htmlname[ISO2022JP] = "ISO-2022-JP"; + htmlname[ASCII] = "ASCII"; + htmlname[OTHER] = "ISO8859-1"; + // Assign Human readable names + nicename[GB2312] = "GB-2312"; + nicename[GBK] = "GBK"; + nicename[GB18030] = "GB18030"; + nicename[HZ] = "HZ"; + nicename[ISO2022CN_GB] = "ISO2022CN-GB"; + nicename[BIG5] = "Big5"; + nicename[CNS11643] = "CNS11643"; + nicename[ISO2022CN_CNS] = "ISO2022CN-CNS"; + nicename[ISO2022CN] = "ISO2022 CN"; + nicename[UTF8] = "UTF-8"; + nicename[UTF8T] = "UTF-8 (Trad)"; + nicename[UTF8S] = "UTF-8 (Simp)"; + nicename[UNICODE] = "Unicode"; + nicename[UNICODET] = "Unicode (Trad)"; + nicename[UNICODES] = "Unicode (Simp)"; + nicename[EUC_KR] = "EUC-KR"; + nicename[CP949] = "CP949"; + nicename[ISO2022KR] = "ISO 2022 KR"; + nicename[JOHAB] = "Johab"; + nicename[SJIS] = "Shift-JIS"; + nicename[EUC_JP] = "EUC-JP"; + nicename[ISO2022JP] = "ISO 2022 JP"; + nicename[ASCII] = "ASCII"; + nicename[OTHER] = "OTHER"; + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/http/HttpHelper.kt b/app/src/main/java/io/legado/app/help/http/HttpHelper.kt index 59d92b07d..eeb54986e 100644 --- a/app/src/main/java/io/legado/app/help/http/HttpHelper.kt +++ b/app/src/main/java/io/legado/app/help/http/HttpHelper.kt @@ -7,46 +7,56 @@ import java.util.concurrent.TimeUnit object HttpHelper { - val client: OkHttpClient = getOkHttpClient() - - - fun getApiService(baseUrl: String, clazz: Class): T { - return getRetrofit(baseUrl).create(clazz) - } - - fun getRetrofit(baseUrl: String): Retrofit { - return Retrofit.Builder().baseUrl(baseUrl) - //增加返回值为字符串的支持(以实体类返回) -// .addConverterFactory(EncodeConverter.create()) - //增加返回值为Observable的支持 - .addCallAdapterFactory(CoroutinesCallAdapterFactory.invoke()) - .client(client) - .build() - } - - private fun getOkHttpClient(): OkHttpClient { - val cs = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) + val client: OkHttpClient by lazy { + val default = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) .tlsVersions(TlsVersion.TLS_1_2) .build() val specs = ArrayList() - specs.add(cs) + specs.add(default) specs.add(ConnectionSpec.COMPATIBLE_TLS) specs.add(ConnectionSpec.CLEARTEXT) - val sslParams = SSLHelper.getSslSocketFactory() - return OkHttpClient.Builder() + val builder = OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) .writeTimeout(15, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS) + .sslSocketFactory(SSLHelper.unsafeSSLSocketFactory, SSLHelper.unsafeTrustManager) .retryOnConnectionFailure(true) - .sslSocketFactory(sslParams.sSLSocketFactory, sslParams.trustManager) .hostnameVerifier(SSLHelper.unsafeHostnameVerifier) .connectionSpecs(specs) .followRedirects(true) .followSslRedirects(true) .protocols(listOf(Protocol.HTTP_1_1)) .addInterceptor(getHeaderInterceptor()) + + builder.build() + } + + inline fun getApiService(baseUrl: String): T { + return getRetrofit(baseUrl).create(T::class.java) + } + + inline fun getApiService(baseUrl: String, encode: String): T { + return getRetrofit(baseUrl, encode).create(T::class.java) + } + + fun getRetrofit(baseUrl: String, encode: String? = null): Retrofit { + return Retrofit.Builder().baseUrl(baseUrl) + //增加返回值为字符串的支持(以实体类返回) + .addConverterFactory(EncodeConverter(encode)) + //增加返回值为Observable的支持 + .addCallAdapterFactory(CoroutinesCallAdapterFactory.create()) + .client(client) + .build() + } + + fun getByteRetrofit(baseUrl: String): Retrofit { + return Retrofit.Builder().baseUrl(baseUrl) + .addConverterFactory(ByteConverter()) + //增加返回值为Observable的支持 + .addCallAdapterFactory(CoroutinesCallAdapterFactory.create()) + .client(client) .build() } diff --git a/app/src/main/java/io/legado/app/help/http/SSLHelper.kt b/app/src/main/java/io/legado/app/help/http/SSLHelper.kt index dcf5b7709..ef70ac1b5 100644 --- a/app/src/main/java/io/legado/app/help/http/SSLHelper.kt +++ b/app/src/main/java/io/legado/app/help/http/SSLHelper.kt @@ -1,18 +1,20 @@ package io.legado.app.help.http -import javax.net.ssl.* +import android.annotation.SuppressLint import java.io.IOException import java.io.InputStream import java.security.KeyManagementException import java.security.KeyStore import java.security.NoSuchAlgorithmException +import java.security.SecureRandom import java.security.cert.CertificateException import java.security.cert.CertificateFactory import java.security.cert.X509Certificate +import javax.net.ssl.* object SSLHelper { - val sslSocketFactory: SSLParams + val sslSocketFactory: SSLParams? get() = getSslSocketFactoryBase(null, null, null) /** @@ -20,10 +22,12 @@ object SSLHelper { * 这是一种有很大安全漏洞的办法 */ val unsafeTrustManager: X509TrustManager = object : X509TrustManager { + @SuppressLint("TrustAllX509TrustManager") @Throws(CertificateException::class) override fun checkClientTrusted(chain: Array, authType: String) { } + @SuppressLint("TrustAllX509TrustManager") @Throws(CertificateException::class) override fun checkServerTrusted(chain: Array, authType: String) { } @@ -33,6 +37,16 @@ object SSLHelper { } } + val unsafeSSLSocketFactory: SSLSocketFactory by lazy { + try { + val sslContext = SSLContext.getInstance("SSL") + sslContext.init(null, arrayOf(unsafeTrustManager), SecureRandom()) + sslContext.socketFactory + } catch (e: Exception) { + throw RuntimeException(e) + } + } + /** * 此类是用于主机名验证的基接口。 在握手期间,如果 URL 的主机名和服务器的标识主机名不匹配, * 则验证机制可以回调此接口的实现程序来确定是否应该允许此连接。策略可以是基于证书的或依赖于其他验证方案。 @@ -41,15 +55,15 @@ object SSLHelper { val unsafeHostnameVerifier: HostnameVerifier = HostnameVerifier { _, _ -> true } class SSLParams { - var sSLSocketFactory: SSLSocketFactory? = null - var trustManager: X509TrustManager? = null + lateinit var sSLSocketFactory: SSLSocketFactory + lateinit var trustManager: X509TrustManager } /** * https单向认证 * 可以额外配置信任服务端的证书策略,否则默认是按CA证书去验证的,若不是CA可信任的证书,则无法通过验证 */ - fun getSslSocketFactory(trustManager: X509TrustManager): SSLParams { + fun getSslSocketFactory(trustManager: X509TrustManager): SSLParams? { return getSslSocketFactoryBase(trustManager, null, null) } @@ -57,7 +71,7 @@ object SSLHelper { * https单向认证 * 用含有服务端公钥的证书校验服务端证书 */ - fun getSslSocketFactory(vararg certificates: InputStream): SSLParams { + fun getSslSocketFactory(vararg certificates: InputStream): SSLParams? { return getSslSocketFactoryBase(null, null, null, *certificates) } @@ -66,7 +80,7 @@ object SSLHelper { * bksFile 和 password -> 客户端使用bks证书校验服务端证书 * certificates -> 用含有服务端公钥的证书校验服务端证书 */ - fun getSslSocketFactory(bksFile: InputStream, password: String, vararg certificates: InputStream): SSLParams { + fun getSslSocketFactory(bksFile: InputStream, password: String, vararg certificates: InputStream): SSLParams? { return getSslSocketFactoryBase(null, bksFile, password, *certificates) } @@ -75,7 +89,7 @@ object SSLHelper { * bksFile 和 password -> 客户端使用bks证书校验服务端证书 * X509TrustManager -> 如果需要自己校验,那么可以自己实现相关校验,如果不需要自己校验,那么传null即可 */ - fun getSslSocketFactory(bksFile: InputStream, password: String, trustManager: X509TrustManager): SSLParams { + fun getSslSocketFactory(bksFile: InputStream, password: String, trustManager: X509TrustManager): SSLParams? { return getSslSocketFactoryBase(trustManager, bksFile, password) } @@ -84,35 +98,27 @@ object SSLHelper { bksFile: InputStream?, password: String?, vararg certificates: InputStream - ): SSLParams { + ): SSLParams? { val sslParams = SSLParams() try { val keyManagers = prepareKeyManager(bksFile, password) val trustManagers = prepareTrustManager(*certificates) - val manager: X509TrustManager? - manager = //优先使用用户自定义的TrustManager - trustManager ?: if (trustManagers != null) { - //然后使用默认的TrustManager - chooseTrustManager(trustManagers) - } else { - //否则使用不安全的TrustManager - unsafeTrustManager - } + val manager: X509TrustManager = trustManager ?: chooseTrustManager(trustManagers) // 创建TLS类型的SSLContext对象, that uses our TrustManager val sslContext = SSLContext.getInstance("TLS") // 用上面得到的trustManagers初始化SSLContext,这样sslContext就会信任keyStore中的证书 // 第一个参数是授权的密钥管理器,用来授权验证,比如授权自签名的证书验证。第二个是被授权的证书管理器,用来验证服务器端的证书 - sslContext.init(keyManagers, manager?.let { arrayOf(it) }, null) + sslContext.init(keyManagers, arrayOf(manager), null) // 通过sslContext获取SSLSocketFactory对象 sslParams.sSLSocketFactory = sslContext.socketFactory sslParams.trustManager = manager return sslParams } catch (e: NoSuchAlgorithmException) { - throw AssertionError(e) + e.printStackTrace() } catch (e: KeyManagementException) { - throw AssertionError(e) + e.printStackTrace() } - + return null } private fun prepareKeyManager(bksFile: InputStream?, password: String?): Array? { @@ -126,50 +132,40 @@ object SSLHelper { } catch (e: Exception) { e.printStackTrace() } - return null } - private fun prepareTrustManager(vararg certificates: InputStream): Array? { - if (certificates.isEmpty()) return null - try { - val certificateFactory = CertificateFactory.getInstance("X.509") - // 创建一个默认类型的KeyStore,存储我们信任的证书 - val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) - keyStore.load(null) - var index = 0 - for (certStream in certificates) { - val certificateAlias = Integer.toString(index++) - // 证书工厂根据证书文件的流生成证书 cert - val cert = certificateFactory.generateCertificate(certStream) - // 将 cert 作为可信证书放入到keyStore中 - keyStore.setCertificateEntry(certificateAlias, cert) - try { - certStream?.close() - } catch (e: IOException) { - e.printStackTrace() - } - + private fun prepareTrustManager(vararg certificates: InputStream): Array { + val certificateFactory = CertificateFactory.getInstance("X.509") + // 创建一个默认类型的KeyStore,存储我们信任的证书 + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) + keyStore.load(null) + for ((index, certStream) in certificates.withIndex()) { + val certificateAlias = Integer.toString(index) + // 证书工厂根据证书文件的流生成证书 cert + val cert = certificateFactory.generateCertificate(certStream) + // 将 cert 作为可信证书放入到keyStore中 + keyStore.setCertificateEntry(certificateAlias, cert) + try { + certStream.close() + } catch (e: IOException) { + e.printStackTrace() } - //我们创建一个默认类型的TrustManagerFactory - val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) - //用我们之前的keyStore实例初始化TrustManagerFactory,这样tmf就会信任keyStore中的证书 - tmf.init(keyStore) - //通过tmf获取TrustManager数组,TrustManager也会信任keyStore中的证书 - return tmf.trustManagers - } catch (e: Exception) { - e.printStackTrace() } - - return null + //我们创建一个默认类型的TrustManagerFactory + val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + //用我们之前的keyStore实例初始化TrustManagerFactory,这样tmf就会信任keyStore中的证书 + tmf.init(keyStore) + //通过tmf获取TrustManager数组,TrustManager也会信任keyStore中的证书 + return tmf.trustManagers } - private fun chooseTrustManager(trustManagers: Array): X509TrustManager? { + private fun chooseTrustManager(trustManagers: Array): X509TrustManager { for (trustManager in trustManagers) { if (trustManager is X509TrustManager) { return trustManager } } - return null + throw NullPointerException() } } diff --git a/app/src/main/java/io/legado/app/help/permission/ActivitySource.kt b/app/src/main/java/io/legado/app/help/permission/ActivitySource.kt new file mode 100644 index 000000000..e0618067a --- /dev/null +++ b/app/src/main/java/io/legado/app/help/permission/ActivitySource.kt @@ -0,0 +1,20 @@ +package io.legado.app.help.permission + +import android.content.Context +import android.content.Intent +import androidx.appcompat.app.AppCompatActivity + +import java.lang.ref.WeakReference + +internal class ActivitySource(activity: AppCompatActivity) : RequestSource { + + private val actRef: WeakReference = WeakReference(activity) + + override val context: Context? + get() = actRef.get() + + override fun startActivity(intent: Intent) { + actRef.get()?.startActivity(intent) + } + +} diff --git a/app/src/main/java/io/legado/app/help/permission/FragmentSource.kt b/app/src/main/java/io/legado/app/help/permission/FragmentSource.kt new file mode 100644 index 000000000..b66e11b98 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/permission/FragmentSource.kt @@ -0,0 +1,19 @@ +package io.legado.app.help.permission + +import android.content.Context +import android.content.Intent +import androidx.fragment.app.Fragment + +import java.lang.ref.WeakReference + +internal class FragmentSource(fragment: Fragment) : RequestSource { + + private val fragRef: WeakReference = WeakReference(fragment) + + override val context: Context? + get() = fragRef.get()?.requireContext() + + override fun startActivity(intent: Intent) { + fragRef.get()?.startActivity(intent) + } +} diff --git a/app/src/main/java/io/legado/app/help/permission/OnPermissionsDeniedCallback.kt b/app/src/main/java/io/legado/app/help/permission/OnPermissionsDeniedCallback.kt new file mode 100644 index 000000000..d6e81a68f --- /dev/null +++ b/app/src/main/java/io/legado/app/help/permission/OnPermissionsDeniedCallback.kt @@ -0,0 +1,5 @@ +package io.legado.app.help.permission + +interface OnPermissionsDeniedCallback { + fun onPermissionsDenied(requestCode: Int, deniedPermissions: Array) +} diff --git a/app/src/main/java/io/legado/app/help/permission/OnPermissionsGrantedCallback.kt b/app/src/main/java/io/legado/app/help/permission/OnPermissionsGrantedCallback.kt new file mode 100644 index 000000000..59f6977d4 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/permission/OnPermissionsGrantedCallback.kt @@ -0,0 +1,7 @@ +package io.legado.app.help.permission + +interface OnPermissionsGrantedCallback { + + fun onPermissionsGranted(requestCode: Int) + +} diff --git a/app/src/main/java/io/legado/app/help/permission/OnPermissionsResultCallback.kt b/app/src/main/java/io/legado/app/help/permission/OnPermissionsResultCallback.kt new file mode 100644 index 000000000..3d7afa600 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/permission/OnPermissionsResultCallback.kt @@ -0,0 +1,9 @@ +package io.legado.app.help.permission + +interface OnPermissionsResultCallback { + + fun onPermissionsGranted(requestCode: Int) + + fun onPermissionsDenied(requestCode: Int, deniedPermissions: Array) + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/permission/OnRequestPermissionsResultCallback.kt b/app/src/main/java/io/legado/app/help/permission/OnRequestPermissionsResultCallback.kt new file mode 100644 index 000000000..3674461bc --- /dev/null +++ b/app/src/main/java/io/legado/app/help/permission/OnRequestPermissionsResultCallback.kt @@ -0,0 +1,10 @@ +package io.legado.app.help.permission + +import android.content.Intent + +interface OnRequestPermissionsResultCallback { + + fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) + + fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) +} diff --git a/app/src/main/java/io/legado/app/help/permission/PermissionActivity.kt b/app/src/main/java/io/legado/app/help/permission/PermissionActivity.kt new file mode 100644 index 000000000..04722997b --- /dev/null +++ b/app/src/main/java/io/legado/app/help/permission/PermissionActivity.kt @@ -0,0 +1,76 @@ +package io.legado.app.help.permission + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.Settings +import android.view.KeyEvent +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import io.legado.app.R +import org.jetbrains.anko.toast + +class PermissionActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + when (intent.getIntExtra(KEY_INPUT_REQUEST_TYPE, Request.TYPE_REQUEST_PERMISSION)) { + Request.TYPE_REQUEST_PERMISSION//权限请求 + -> { + val requestCode = intent.getIntExtra(KEY_INPUT_PERMISSIONS_CODE, 1000) + val permissions = intent.getStringArrayExtra(KEY_INPUT_PERMISSIONS) + if (permissions != null) { + ActivityCompat.requestPermissions(this, permissions, requestCode) + } else { + finish() + } + } + Request.TYPE_REQUEST_SETTING//跳转到设置界面 + -> try { + val settingIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + settingIntent.data = Uri.fromParts("package", packageName, null) + startActivityForResult(settingIntent, Request.TYPE_REQUEST_SETTING) + } catch (e: Exception) { + toast(R.string.tip_cannot_jump_setting_page) + finish() + } + + } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + RequestPlugins.sRequestCallback?.onRequestPermissionsResult(requestCode, permissions, grantResults) + finish() + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + RequestPlugins.sRequestCallback?.onActivityResult(requestCode, resultCode, data) + finish() + } + + override fun startActivity(intent: Intent) { + super.startActivity(intent) + overridePendingTransition(0, 0) + } + + override fun finish() { + super.finish() + overridePendingTransition(0, 0) + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + return if (keyCode == KeyEvent.KEYCODE_BACK) { + true + } else super.onKeyDown(keyCode, event) + } + + companion object { + + const val KEY_INPUT_REQUEST_TYPE = "KEY_INPUT_REQUEST_TYPE" + const val KEY_INPUT_PERMISSIONS_CODE = "KEY_INPUT_PERMISSIONS_CODE" + const val KEY_INPUT_PERMISSIONS = "KEY_INPUT_PERMISSIONS" + } +} diff --git a/app/src/main/java/io/legado/app/help/permission/Permissions.kt b/app/src/main/java/io/legado/app/help/permission/Permissions.kt new file mode 100644 index 000000000..e1c0893e3 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/permission/Permissions.kt @@ -0,0 +1,72 @@ +package io.legado.app.help.permission + +object Permissions { + + const val READ_CALENDAR = "android.permission.READ_CALENDAR" + const val WRITE_CALENDAR = "android.permission.WRITE_CALENDAR" + + const val CAMERA = "android.permission.CAMERA" + + const val READ_CONTACTS = "android.permission.READ_CONTACTS" + const val WRITE_CONTACTS = "android.permission.WRITE_CONTACTS" + const val GET_ACCOUNTS = "android.permission.GET_ACCOUNTS" + + const val ACCESS_FINE_LOCATION = "android.permission.ACCESS_FINE_LOCATION" + const val ACCESS_COARSE_LOCATION = "android.permission.ACCESS_COARSE_LOCATION" + + const val RECORD_AUDIO = "android.permission.RECORD_AUDIO" + + const val READ_PHONE_STATE = "android.permission.READ_PHONE_STATE" + const val CALL_PHONE = "android.permission.CALL_PHONE" + const val READ_CALL_LOG = "android.permission.READ_CALL_LOG" + const val WRITE_CALL_LOG = "android.permission.WRITE_CALL_LOG" + const val ADD_VOICEMAIL = "com.android.voicemail.permission.ADD_VOICEMAIL" + const val USE_SIP = "android.permission.USE_SIP" + const val PROCESS_OUTGOING_CALLS = "android.permission.PROCESS_OUTGOING_CALLS" + + const val BODY_SENSORS = "android.permission.BODY_SENSORS" + + const val SEND_SMS = "android.permission.SEND_SMS" + const val RECEIVE_SMS = "android.permission.RECEIVE_SMS" + const val READ_SMS = "android.permission.READ_SMS" + const val RECEIVE_WAP_PUSH = "android.permission.RECEIVE_WAP_PUSH" + const val RECEIVE_MMS = "android.permission.RECEIVE_MMS" + + const val READ_EXTERNAL_STORAGE = "android.permission.READ_EXTERNAL_STORAGE" + const val WRITE_EXTERNAL_STORAGE = "android.permission.WRITE_EXTERNAL_STORAGE" + + object Group { + val CALENDAR = arrayOf(READ_CALENDAR, WRITE_CALENDAR) + + val CAMERA = arrayOf(Permissions.CAMERA) + + val CONTACTS = arrayOf(READ_CONTACTS, WRITE_CONTACTS, GET_ACCOUNTS) + + val LOCATION = arrayOf(ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION) + + val MICROPHONE = arrayOf(RECORD_AUDIO) + + val PHONE = arrayOf( + READ_PHONE_STATE, + CALL_PHONE, + READ_CALL_LOG, + WRITE_CALL_LOG, + ADD_VOICEMAIL, + USE_SIP, + PROCESS_OUTGOING_CALLS + ) + + val SENSORS = arrayOf(BODY_SENSORS) + + val SMS = arrayOf( + SEND_SMS, + RECEIVE_SMS, + READ_SMS, + RECEIVE_WAP_PUSH, + RECEIVE_MMS + ) + + val STORAGE = arrayOf(READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE) + } + +} diff --git a/app/src/main/java/io/legado/app/help/permission/PermissionsCompat.kt b/app/src/main/java/io/legado/app/help/permission/PermissionsCompat.kt new file mode 100644 index 000000000..a5ccdb266 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/permission/PermissionsCompat.kt @@ -0,0 +1,78 @@ +package io.legado.app.help.permission + +import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment + +class PermissionsCompat private constructor() { + + private var request: Request? = null + + fun request() { + RequestManager.pushRequest(request) + } + + class Builder { + private val request: Request + + constructor(activity: AppCompatActivity) { + request = Request(activity) + } + + constructor(fragment: Fragment) { + request = Request(fragment) + } + + fun addPermissions(vararg permissions: String): Builder { + request.addPermissions(*permissions) + return this + } + + fun requestCode(requestCode: Int): Builder { + request.setRequestCode(requestCode) + return this + } + + fun onGranted(callback: (requestCode: Int) -> Unit): Builder { + request.setOnGrantedCallback(object : OnPermissionsGrantedCallback { + override fun onPermissionsGranted(requestCode: Int) { + callback(requestCode) + } + }) + return this + } + + fun onDenied(callback: (requestCode: Int, deniedPermissions: Array) -> Unit): Builder { + request.setOnDeniedCallback(object : OnPermissionsDeniedCallback { + override fun onPermissionsDenied(requestCode: Int, deniedPermissions: Array) { + callback(requestCode, deniedPermissions) + } + }) + return this + } + + fun rationale(rationale: CharSequence): Builder { + request.setRationale(rationale) + return this + } + + fun rationale(@StringRes resId: Int): Builder { + request.setRationale(resId) + return this + } + + fun build(): PermissionsCompat { + val compat = PermissionsCompat() + compat.request = request + return compat + } + + fun request(): PermissionsCompat { + val compat = build() + compat.request = request + compat.request() + return compat + } + } + +} diff --git a/app/src/main/java/io/legado/app/help/permission/Request.kt b/app/src/main/java/io/legado/app/help/permission/Request.kt new file mode 100644 index 000000000..1fc765bf9 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/permission/Request.kt @@ -0,0 +1,190 @@ +package io.legado.app.help.permission + +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import io.legado.app.R +import org.jetbrains.anko.startActivity +import java.util.* + +internal class Request : OnRequestPermissionsResultCallback { + + internal val requestTime: Long + private var requestCode: Int = TYPE_REQUEST_PERMISSION + private var source: RequestSource? = null + private var permissions: ArrayList? = null + private var grantedCallback: OnPermissionsGrantedCallback? = null + private var deniedCallback: OnPermissionsDeniedCallback? = null + private var rationaleResId: Int = 0 + private var rationale: CharSequence? = null + + private var rationaleDialog: AlertDialog? = null + + private val deniedPermissions: Array? + get() { + return getDeniedPermissions(this.permissions?.toTypedArray()) + } + + constructor(activity: AppCompatActivity) { + source = ActivitySource(activity) + permissions = ArrayList() + requestTime = System.currentTimeMillis() + } + + constructor(fragment: Fragment) { + source = FragmentSource(fragment) + permissions = ArrayList() + requestTime = System.currentTimeMillis() + } + + fun addPermissions(vararg permissions: String) { + this.permissions?.addAll(Arrays.asList(*permissions)) + } + + fun setRequestCode(requestCode: Int) { + this.requestCode = requestCode + } + + fun setOnGrantedCallback(callback: OnPermissionsGrantedCallback) { + grantedCallback = callback + } + + fun setOnDeniedCallback(callback: OnPermissionsDeniedCallback) { + deniedCallback = callback + } + + fun setRationale(@StringRes resId: Int) { + rationaleResId = resId + rationale = null + } + + fun setRationale(rationale: CharSequence) { + this.rationale = rationale + rationaleResId = 0 + } + + fun start() { + RequestPlugins.setOnRequestPermissionsCallback(this) + + val deniedPermissions = deniedPermissions + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + if (deniedPermissions == null) { + onPermissionsGranted(requestCode) + } else { + val rationale = if (rationaleResId != 0) source?.context?.getText(rationaleResId) else rationale + if (rationale != null) { + showSettingDialog(rationale) { onPermissionsDenied(requestCode, deniedPermissions) } + } else { + onPermissionsDenied(requestCode, deniedPermissions) + } + } + } else { + if (deniedPermissions != null) { + source?.context?.startActivity( + PermissionActivity.KEY_INPUT_REQUEST_TYPE to TYPE_REQUEST_PERMISSION, + PermissionActivity.KEY_INPUT_PERMISSIONS_CODE to requestCode, + PermissionActivity.KEY_INPUT_PERMISSIONS to deniedPermissions + ) + } else { + onPermissionsGranted(requestCode) + } + } + } + + fun clear() { + grantedCallback = null + deniedCallback = null + } + + private fun getDeniedPermissions(permissions: Array?): Array? { + if (permissions != null) { + val deniedPermissionList = ArrayList() + for (permission in permissions) { + if (source?.context?.let { + ContextCompat.checkSelfPermission( + it, + permission + ) + } != PackageManager.PERMISSION_GRANTED + ) { + deniedPermissionList.add(permission) + } + } + val size = deniedPermissionList.size + if (size > 0) { + return deniedPermissionList.toTypedArray() + } + } + return null + } + + private fun showSettingDialog(rationale: CharSequence, cancel: () -> Unit) { + rationaleDialog?.dismiss() + source?.context?.let { + runCatching { + rationaleDialog = AlertDialog.Builder(it) + .setTitle(R.string.dialog_title) + .setMessage(rationale) + .setPositiveButton(R.string.dialog_setting) { _, _ -> + it.startActivity( + PermissionActivity.KEY_INPUT_REQUEST_TYPE to TYPE_REQUEST_SETTING + ) + } + .setNegativeButton(R.string.dialog_cancel) { _, _ -> cancel() } + .show() + } + } + } + + private fun onPermissionsGranted(requestCode: Int) { + try { + grantedCallback?.onPermissionsGranted(requestCode) + } catch (ignore: Exception) { + } + + RequestPlugins.sResultCallback?.onPermissionsGranted(requestCode) + } + + private fun onPermissionsDenied(requestCode: Int, deniedPermissions: Array) { + try { + deniedCallback?.onPermissionsDenied(requestCode, deniedPermissions) + } catch (ignore: Exception) { + } + + RequestPlugins.sResultCallback?.onPermissionsDenied(requestCode, deniedPermissions) + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + val deniedPermissions = getDeniedPermissions(permissions) + if (deniedPermissions != null) { + val rationale = if (rationaleResId != 0) source?.context?.getText(rationaleResId) else rationale + if (rationale != null) { + showSettingDialog(rationale) { onPermissionsDenied(requestCode, deniedPermissions) } + } else { + onPermissionsDenied(requestCode, deniedPermissions) + } + } else { + onPermissionsGranted(requestCode) + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + val deniedPermissions = deniedPermissions + if (deniedPermissions == null) { + onPermissionsGranted(this.requestCode) + } else { + onPermissionsDenied(this.requestCode, deniedPermissions) + } + } + + companion object { + const val TYPE_REQUEST_PERMISSION = 1 + const val TYPE_REQUEST_SETTING = 2 + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/permission/RequestManager.kt b/app/src/main/java/io/legado/app/help/permission/RequestManager.kt new file mode 100644 index 000000000..b3fede03f --- /dev/null +++ b/app/src/main/java/io/legado/app/help/permission/RequestManager.kt @@ -0,0 +1,67 @@ +package io.legado.app.help.permission + +import android.os.Handler +import java.util.* + +internal object RequestManager : OnPermissionsResultCallback { + + private var requests: Stack? = null + private var request: Request? = null + + private val handler = Handler() + + private val requestRunnable = Runnable { + request?.start() + } + + private val isCurrentRequestInvalid: Boolean + get() = request?.let { System.currentTimeMillis() - it.requestTime > 5 * 1000L } ?: true + + init { + RequestPlugins.setOnPermissionsResultCallback(this) + } + + fun pushRequest(request: Request?) { + if (request == null) return + + if (requests == null) { + requests = Stack() + } + + requests?.let { + val index = it.indexOf(request) + if (index >= 0) { + val to = it.size - 1 + if (index != to) { + Collections.swap(requests, index, to) + } + } else { + it.push(request) + } + + if (!it.empty() && isCurrentRequestInvalid) { + this.request = it.pop() + handler.post(requestRunnable) + } + } + } + + private fun startNextRequest() { + request?.clear() + request = null + + requests?.let { + request = if (it.empty()) null else it.pop() + request?.let { handler.post(requestRunnable) } + } + } + + override fun onPermissionsGranted(requestCode: Int) { + startNextRequest() + } + + override fun onPermissionsDenied(requestCode: Int, deniedPermissions: Array) { + startNextRequest() + } + +} diff --git a/app/src/main/java/io/legado/app/help/permission/RequestPlugins.kt b/app/src/main/java/io/legado/app/help/permission/RequestPlugins.kt new file mode 100644 index 000000000..16370193f --- /dev/null +++ b/app/src/main/java/io/legado/app/help/permission/RequestPlugins.kt @@ -0,0 +1,20 @@ +package io.legado.app.help.permission + +internal object RequestPlugins { + + @Volatile + var sRequestCallback: OnRequestPermissionsResultCallback? = null + + @Volatile + var sResultCallback: OnPermissionsResultCallback? = null + + fun setOnRequestPermissionsCallback(callback: OnRequestPermissionsResultCallback) { + sRequestCallback = callback + } + + fun setOnPermissionsResultCallback(callback: OnPermissionsResultCallback) { + sResultCallback = callback + } + + +} diff --git a/app/src/main/java/io/legado/app/help/permission/RequestSource.kt b/app/src/main/java/io/legado/app/help/permission/RequestSource.kt new file mode 100644 index 000000000..a822ff5ad --- /dev/null +++ b/app/src/main/java/io/legado/app/help/permission/RequestSource.kt @@ -0,0 +1,12 @@ +package io.legado.app.help.permission + +import android.content.Context +import android.content.Intent + +interface RequestSource { + + val context: Context? + + fun startActivity(intent: Intent) + +} diff --git a/app/src/main/java/io/legado/app/help/storage/Backup.kt b/app/src/main/java/io/legado/app/help/storage/Backup.kt new file mode 100644 index 000000000..0ac19a1e8 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/storage/Backup.kt @@ -0,0 +1,115 @@ +package io.legado.app.help.storage + +import io.legado.app.App +import io.legado.app.R +import io.legado.app.help.FileHelp +import io.legado.app.help.ReadBookConfig +import io.legado.app.utils.FileUtils +import io.legado.app.utils.GSON +import org.jetbrains.anko.defaultSharedPreferences +import org.jetbrains.anko.doAsync +import org.jetbrains.anko.toast +import org.jetbrains.anko.uiThread +import java.io.File + + +object Backup { + + val defaultPath by lazy { + FileUtils.getSdCardPath() + File.separator + "YueDu" + File.separator + "legadoBackUp" + } + + fun backup() { + doAsync { + val path = defaultPath + backupBookshelf(path) + backupBookSource(path) + backupRssSource(path) + backupReplaceRule(path) + backupReadConfig(path) + backupPreference(path) + WebDavHelp.backUpWebDav(path) + uiThread { + App.INSTANCE.toast(R.string.backup_success) + } + } + } + + fun autoBackup() { + doAsync { + val path = defaultPath + backupBookshelf(path) + backupBookSource(path) + backupRssSource(path) + backupReplaceRule(path) + backupReadConfig(path) + backupPreference(path) + WebDavHelp.backUpWebDav(path) + } + } + + private fun backupBookshelf(path: String) { + App.db.bookDao().allBooks.let { + if (it.isNotEmpty()) { + val json = GSON.toJson(it) + val file = FileHelp.getFile(path + File.separator + "bookshelf.json") + file.writeText(json) + } + } + } + + private fun backupBookSource(path: String) { + App.db.bookSourceDao().all.let { + if (it.isNotEmpty()) { + val json = GSON.toJson(it) + val file = FileHelp.getFile(path + File.separator + "bookSource.json") + file.writeText(json) + } + } + } + + private fun backupRssSource(path: String) { + App.db.rssSourceDao().all.let { + if (it.isNotEmpty()) { + val json = GSON.toJson(it) + val file = FileHelp.getFile(path + File.separator + "rssSource.json") + file.writeText(json) + } + } + } + + private fun backupReplaceRule(path: String) { + App.db.replaceRuleDao().all.let { + if (it.isNotEmpty()) { + val json = GSON.toJson(it) + val file = FileHelp.getFile(path + File.separator + "replaceRule.json") + file.writeText(json) + } + } + } + + private fun backupReadConfig(path: String) { + GSON.toJson(ReadBookConfig.configList)?.let { + FileHelp.getFile(path + File.separator + ReadBookConfig.readConfigFileName) + .writeText(it) + } + } + + private fun backupPreference(path: String) { + Preferences.getSharedPreferences(App.INSTANCE, path, "config")?.let { sp -> + val edit = sp.edit() + App.INSTANCE.defaultSharedPreferences.all.map { + when (val value = it.value) { + is Int -> edit.putInt(it.key, value) + is Boolean -> edit.putBoolean(it.key, value) + is Long -> edit.putLong(it.key, value) + is Float -> edit.putFloat(it.key, value) + is String -> edit.putString(it.key, value) + else -> Unit + } + } + edit.commit() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/storage/OldRule.kt b/app/src/main/java/io/legado/app/help/storage/OldRule.kt new file mode 100644 index 000000000..7418ea6c6 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/storage/OldRule.kt @@ -0,0 +1,138 @@ +package io.legado.app.help.storage + +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.rule.* +import io.legado.app.help.storage.Restore.jsonPath +import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonObject +import io.legado.app.utils.readInt +import io.legado.app.utils.readString +import java.util.regex.Pattern + +object OldRule { + private val headerPattern = Pattern.compile("@Header:\\{.+?\\}", Pattern.CASE_INSENSITIVE) + private val jsPattern = Pattern.compile("\\{\\{.+?\\}\\}", Pattern.CASE_INSENSITIVE) + + fun jsonToBookSource(json: String): BookSource? { + var source: BookSource? = null + runCatching { + source = GSON.fromJsonObject(json) + } + runCatching { + if (source == null || source?.searchUrl.isNullOrBlank()) { + source = BookSource().apply { + val jsonItem = jsonPath.parse(json) + bookSourceUrl = jsonItem.readString("bookSourceUrl") ?: "" + bookSourceName = jsonItem.readString("bookSourceName") ?: "" + bookSourceGroup = jsonItem.readString("bookSourceGroup") ?: "" + loginUrl = jsonItem.readString("loginUrl") + bookUrlPattern = jsonItem.readString("ruleBookUrlPattern") + customOrder = jsonItem.readInt("serialNumber") ?: 0 + header = uaToHeader(jsonItem.readString("httpUserAgent")) + searchUrl = toNewUrl(jsonItem.readString("ruleSearchUrl")) + exploreUrl = toNewUrl(jsonItem.readString("ruleFindUrl")) + if (exploreUrl.isNullOrBlank()) { + enabledExplore = false + } + val searchRule = SearchRule( + bookList = jsonItem.readString("ruleSearchList"), + name = jsonItem.readString("ruleSearchName"), + author = jsonItem.readString("ruleSearchAuthor"), + intro = jsonItem.readString("ruleSearchIntroduce"), + kind = jsonItem.readString("ruleSearchKind"), + bookUrl = jsonItem.readString("ruleSearchNoteUrl"), + coverUrl = jsonItem.readString("ruleSearchCoverUrl"), + lastChapter = jsonItem.readString("ruleSearchLastChapter") + ) + ruleSearch = GSON.toJson(searchRule) + val exploreRule = ExploreRule( + bookList = jsonItem.readString("ruleFindList"), + name = jsonItem.readString("ruleFindName"), + author = jsonItem.readString("ruleFindAuthor"), + intro = jsonItem.readString("ruleFindIntroduce"), + kind = jsonItem.readString("ruleFindKind"), + bookUrl = jsonItem.readString("ruleFindNoteUrl"), + coverUrl = jsonItem.readString("ruleFindCoverUrl"), + lastChapter = jsonItem.readString("ruleFindLastChapter") + ) + ruleExplore = GSON.toJson(exploreRule) + val bookInfoRule = BookInfoRule( + init = jsonItem.readString("ruleBookInfoInit"), + name = jsonItem.readString("ruleBookName"), + author = jsonItem.readString("ruleBookAuthor"), + intro = jsonItem.readString("ruleIntroduce"), + kind = jsonItem.readString("ruleBookKind"), + coverUrl = jsonItem.readString("ruleCoverUrl"), + lastChapter = jsonItem.readString("ruleBookLastChapter"), + tocUrl = jsonItem.readString("ruleChapterUrl") + ) + ruleBookInfo = GSON.toJson(bookInfoRule) + val chapterRule = TocRule( + chapterList = jsonItem.readString("ruleChapterList"), + chapterName = jsonItem.readString("ruleChapterName"), + chapterUrl = jsonItem.readString("ruleContentUrl"), + nextTocUrl = jsonItem.readString("ruleChapterUrlNext") + ) + ruleToc = GSON.toJson(chapterRule) + val contentRule = ContentRule( + content = jsonItem.readString("ruleBookContent"), + nextContentUrl = jsonItem.readString("ruleContentUrlNext") + ) + ruleContent = GSON.toJson(contentRule) + } + } + } + return source + } + + private fun toNewUrl(oldUrl: String?): String? { + if (oldUrl == null) return null + var url: String = oldUrl + if (oldUrl.startsWith("", true)) { + url = url.replace("=searchKey", "={{key}}") + .replace("=searchPage", "={{page}}") + return url + } + val map = HashMap() + var mather = headerPattern.matcher(url) + if (mather.find()) { + val header = mather.group() + url = url.replace(header, "") + map["headers"] = header.substring(8) + } + var urlList = url.split("|") + url = urlList[0] + if (urlList.size > 1) { + map["charset"] = urlList[1].split("=")[1] + } + mather = jsPattern.matcher(url) + val jsList = arrayListOf() + while (mather.find()) { + jsList.add(mather.group()) + url = url.replace(jsList.last(), "$${jsList.size - 1}") + } + url = url.replace("{", "<").replace("}", ">") + url = url.replace("searchKey", "{{key}}") + url = url.replace("searchPage", "{{page}}") + for ((index, item) in jsList.withIndex()) { + url = url.replace("$$index", item.replace("searchKey", "key").replace("searchPage", "page")) + } + urlList = url.split("@") + url = urlList[0] + if (urlList.size > 1) { + map["method"] = "POST" + map["body"] = urlList[1] + } + if (map.size > 0) { + url += "," + GSON.toJson(map) + } + return url + } + + private fun uaToHeader(ua: String?): String? { + if (ua.isNullOrEmpty()) return null + val map = mapOf(Pair("User-Agent", ua)) + return GSON.toJson(map) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/storage/Preferences.kt b/app/src/main/java/io/legado/app/help/storage/Preferences.kt new file mode 100644 index 000000000..11907e83f --- /dev/null +++ b/app/src/main/java/io/legado/app/help/storage/Preferences.kt @@ -0,0 +1,48 @@ +package io.legado.app.help.storage + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.SharedPreferences +import java.io.File + +object Preferences { + + /** + * 用反射生成 SharedPreferences + * @param context + * @param dir + * @param fileName 文件名,不需要 '.xml' 后缀 + * @return + */ + fun getSharedPreferences( + context: Context, + dir: String, + fileName: String + ): SharedPreferences? { + try { + // 获取 ContextWrapper对象中的mBase变量。该变量保存了 ContextImpl 对象 + val fieldMBase = ContextWrapper::class.java.getDeclaredField("mBase") + fieldMBase.isAccessible = true + // 获取 mBase变量 + val objMBase = fieldMBase.get(context) + // 获取 ContextImpl。mPreferencesDir变量,该变量保存了数据文件的保存路径 + val fieldMPreferencesDir = objMBase.javaClass.getDeclaredField("mPreferencesDir") + fieldMPreferencesDir.isAccessible = true + // 创建自定义路径 + // String FILE_PATH = Environment.getExternalStorageDirectory().getAbsolutePath()+"/Android"; + val file = File(dir) + // 修改mPreferencesDir变量的值 + fieldMPreferencesDir.set(objMBase, file) + // 返回修改路径以后的 SharedPreferences :%FILE_PATH%/%fileName%.xml + return context.getSharedPreferences(fileName, Activity.MODE_PRIVATE) + } catch (e: NoSuchFieldException) { + e.printStackTrace() + } catch (e: IllegalArgumentException) { + e.printStackTrace() + } catch (e: IllegalAccessException) { + e.printStackTrace() + } + return null + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/storage/Restore.kt b/app/src/main/java/io/legado/app/help/storage/Restore.kt new file mode 100644 index 000000000..cc18a7238 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/storage/Restore.kt @@ -0,0 +1,206 @@ +package io.legado.app.help.storage + +import android.content.Context +import android.util.Log +import com.jayway.jsonpath.Configuration +import com.jayway.jsonpath.JsonPath +import com.jayway.jsonpath.Option +import com.jayway.jsonpath.ParseContext +import io.legado.app.App +import io.legado.app.constant.AppConst +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.ReplaceRule +import io.legado.app.data.entities.RssSource +import io.legado.app.help.FileHelp +import io.legado.app.help.ReadBookConfig +import io.legado.app.help.storage.Backup.defaultPath +import io.legado.app.utils.* +import org.jetbrains.anko.defaultSharedPreferences +import org.jetbrains.anko.doAsync +import org.jetbrains.anko.toast +import org.jetbrains.anko.uiThread +import java.io.File + +object Restore { + val jsonPath: ParseContext by lazy { + JsonPath.using( + Configuration.builder() + .options(Option.SUPPRESS_EXCEPTIONS) + .build() + ) + } + + fun restore(path: String = defaultPath) { + doAsync { + try { + val file = FileHelp.getFile(path + File.separator + "bookshelf.json") + val json = file.readText() + GSON.fromJsonArray(json)?.let { + App.db.bookDao().insert(*it.toTypedArray()) + } + } catch (e: Exception) { + e.printStackTrace() + } + try { + val file = FileHelp.getFile(path + File.separator + "bookSource.json") + val json = file.readText() + GSON.fromJsonArray(json)?.let { + App.db.bookSourceDao().insert(*it.toTypedArray()) + } + } catch (e: Exception) { + e.printStackTrace() + } + try { + val file = FileHelp.getFile(path + File.separator + "rssSource.json") + val json = file.readText() + GSON.fromJsonArray(json)?.let { + App.db.rssSourceDao().insert(*it.toTypedArray()) + } + } catch (e: Exception) { + e.printStackTrace() + } + try { + val file = FileHelp.getFile(path + File.separator + "replaceRule.json") + val json = file.readText() + GSON.fromJsonArray(json)?.let { + App.db.replaceRuleDao().insert(*it.toTypedArray()) + } + } catch (e: Exception) { + e.printStackTrace() + } + try { + val file = + FileHelp.getFile(path + File.separator + ReadBookConfig.readConfigFileName) + val configFile = + File(App.INSTANCE.filesDir.absolutePath + File.separator + ReadBookConfig.readConfigFileName) + if (file.exists()) { + file.copyTo(configFile, true) + ReadBookConfig.upConfig() + } + } catch (e: Exception) { + e.printStackTrace() + } + Preferences.getSharedPreferences(App.INSTANCE, path, "config")?.all?.map { + val edit = App.INSTANCE.defaultSharedPreferences.edit() + when (val value = it.value) { + is Int -> edit.putInt(it.key, value) + is Boolean -> edit.putBoolean(it.key, value) + is Long -> edit.putLong(it.key, value) + is Float -> edit.putFloat(it.key, value) + is String -> edit.putString(it.key, value) + else -> Unit + } + edit.commit() + } + uiThread { App.INSTANCE.toast("恢复完成") } + } + } + + fun importYueDuData(context: Context) { + val file = File(FileUtils.getSdCardPath(), "YueDu") + + // 导入书架 + val shelfFile = File(file, "myBookShelf.json") + val books = mutableListOf() + if (shelfFile.exists()) try { + doAsync { + val items: List> = jsonPath.parse(shelfFile.readText()).read("$") + val existingBooks = App.db.bookDao().allBookUrls.toSet() + for (item in items) { + val jsonItem = jsonPath.parse(item) + val book = Book() + book.bookUrl = jsonItem.readString("$.noteUrl") ?: "" + if (book.bookUrl.isBlank()) continue + book.name = jsonItem.readString("$.bookInfoBean.name") ?: "" + if (book.bookUrl in existingBooks) { + Log.d(AppConst.APP_TAG, "Found existing book: ${book.name}") + continue + } + book.origin = jsonItem.readString("$.tag") ?: "" + book.originName = jsonItem.readString("$.bookInfoBean.origin") ?: "" + book.author = jsonItem.readString("$.bookInfoBean.author") ?: "" + book.type = + if (jsonItem.readString("$.bookInfoBean.bookSourceType") == "AUDIO") 1 else 0 + book.tocUrl = jsonItem.readString("$.bookInfoBean.chapterUrl") ?: book.bookUrl + book.coverUrl = jsonItem.readString("$.bookInfoBean.coverUrl") + book.customCoverUrl = jsonItem.readString("$.customCoverPath") + book.lastCheckTime = jsonItem.readLong("$.bookInfoBean.finalRefreshData") ?: 0 + book.canUpdate = jsonItem.readBool("$.allowUpdate") == true + book.totalChapterNum = jsonItem.readInt("$.chapterListSize") ?: 0 + book.durChapterIndex = jsonItem.readInt("$.durChapter") ?: 0 + book.durChapterTitle = jsonItem.readString("$.durChapterName") + book.durChapterPos = jsonItem.readInt("$.durChapterPage") ?: 0 + book.durChapterTime = jsonItem.readLong("$.finalDate") ?: 0 + book.group = jsonItem.readInt("$.group") ?: 0 + book.intro = jsonItem.readString("$.bookInfoBean.introduce") + book.latestChapterTitle = jsonItem.readString("$.lastChapterName") + book.lastCheckCount = jsonItem.readInt("$.newChapters") ?: 0 + book.order = jsonItem.readInt("$.serialNumber") ?: 0 + book.useReplaceRule = jsonItem.readBool("$.useReplaceRule") == true + book.variable = jsonItem.readString("$.variable") + books.add(book) + Log.d(AppConst.APP_TAG, "Added ${book.name}") + } + App.db.bookDao().insert(*books.toTypedArray()) + val count = books.size + + uiThread { + context.toast(if (count > 0) "成功地导入 $count 本新书和音频" else "没有发现新书或音频") + } + } + } catch (e: Exception) { + Log.e(AppConst.APP_TAG, "Failed to import book shelf.", e) + context.toast("Unable to import books:\n${e.localizedMessage}") + } + + // Book source + val sourceFile = File(file, "myBookSource.json") + val bookSources = mutableListOf() + if (sourceFile.exists()) try { + doAsync { + val items: List> = jsonPath.parse(sourceFile.readText()).read("$") + for (item in items) { + val jsonItem = jsonPath.parse(item) + OldRule.jsonToBookSource(jsonItem.jsonString())?.let { + bookSources.add(it) + } + } + App.db.bookSourceDao().insert(*bookSources.toTypedArray()) + } + } catch (e: Exception) { + e.printStackTrace() + } + + // Replace rules + val ruleFile = File(file, "myBookReplaceRule.json") + val replaceRules = mutableListOf() + if (ruleFile.exists()) try { + doAsync { + val items: List> = jsonPath.parse(ruleFile.readText()).read("$") + val existingRules = App.db.replaceRuleDao().all.map { it.pattern }.toSet() + for ((index: Int, item: Map) in items.withIndex()) { + val jsonItem = jsonPath.parse(item) + val rule = ReplaceRule() + rule.id = jsonItem.readLong("$.id") ?: System.currentTimeMillis().plus(index) + rule.pattern = jsonItem.readString("$.regex") ?: "" + if (rule.pattern.isEmpty() || rule.pattern in existingRules) continue + rule.name = jsonItem.readString("$.replaceSummary") ?: "" + rule.replacement = jsonItem.readString("$.replacement") ?: "" + rule.isRegex = jsonItem.readBool("$.isRegex") == true + rule.scope = jsonItem.readString("$.useTo") + rule.isEnabled = jsonItem.readBool("$.enable") == true + rule.order = jsonItem.readInt("$.serialNumber") ?: index + replaceRules.add(rule) + } + App.db.replaceRuleDao().insert(*replaceRules.toTypedArray()) + val count = replaceRules.size + uiThread { + context.toast(if (count > 0) "成功地导入 $count 条净化替换规则" else "没有发现新的净化替换规则") + } + } + } catch (e: Exception) { + Log.e(AppConst.APP_TAG, e.localizedMessage) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/storage/WebDavHelp.kt b/app/src/main/java/io/legado/app/help/storage/WebDavHelp.kt new file mode 100644 index 000000000..898c2d8d3 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/storage/WebDavHelp.kt @@ -0,0 +1,102 @@ +package io.legado.app.help.storage + +import android.content.Context +import io.legado.app.App +import io.legado.app.help.FileHelp +import io.legado.app.help.ReadBookConfig +import io.legado.app.lib.webdav.WebDav +import io.legado.app.lib.webdav.http.HttpAuth +import io.legado.app.utils.ZipUtils +import io.legado.app.utils.getPrefString +import org.jetbrains.anko.doAsync +import org.jetbrains.anko.selector +import org.jetbrains.anko.uiThread +import java.io.File +import java.text.SimpleDateFormat +import java.util.* +import kotlin.math.min + +object WebDavHelp { + private val zipFilePath = FileHelp.getCachePath() + "/backup" + ".zip" + + private fun getWebDavUrl(): String? { + var url = App.INSTANCE.getPrefString("web_dav_url") + if (url.isNullOrBlank()) return null + if (!url.endsWith("/")) url += "/" + return url + } + + private fun initWebDav(): Boolean { + val account = App.INSTANCE.getPrefString("web_dav_account") + val password = App.INSTANCE.getPrefString("web_dav_password") + if (!account.isNullOrBlank() && !password.isNullOrBlank()) { + HttpAuth.auth = HttpAuth.Auth(account, password) + return true + } + return false + } + + private fun getWebDavFileNames(): ArrayList { + val url = getWebDavUrl() + val names = arrayListOf() + if (!url.isNullOrBlank() && initWebDav()) { + var files = WebDav(url + "legado/").listFiles() + files = files.reversed() + for (index: Int in 0 until min(10, files.size)) { + files[index].displayName?.let { + names.add(it) + } + } + } + return names + } + + fun showRestoreDialog(context: Context) { + doAsync { + val names = getWebDavFileNames() + if (names.isNotEmpty()) { + uiThread { + context.selector(title = "选择恢复文件", items = names) { _, index -> + if (index in 0 until names.size) { + restoreWebDav(names[index]) + } + } + } + } else { + Restore.restore() + } + } + } + + private fun restoreWebDav(name: String) { + doAsync { + getWebDavUrl()?.let { + val file = WebDav(it + "legado/" + name) + file.downloadTo(zipFilePath, true) + ZipUtils.unzipFile(zipFilePath, Backup.defaultPath) + Restore.restore() + } + } + } + + fun backUpWebDav(path: String) { + if (initWebDav()) { + val paths = arrayListOf( + path + File.separator + "bookshelf.json", + path + File.separator + "bookSource.json", + path + File.separator + "rssSource.json", + path + File.separator + "replaceRule.json", + path + File.separator + "config.xml", + path + File.separator + ReadBookConfig.readConfigFileName + ) + FileHelp.deleteFile(zipFilePath) + if (ZipUtils.zipFiles(paths, zipFilePath)) { + WebDav(getWebDavUrl() + "legado").makeAsDir() + val putUrl = getWebDavUrl() + "legado/backup" + + SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + .format(Date(System.currentTimeMillis())) + ".zip" + WebDav(putUrl).upload(zipFilePath) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/lib/dialogs/AlertBuilder.kt b/app/src/main/java/io/legado/app/lib/dialogs/AlertBuilder.kt new file mode 100644 index 000000000..60404b266 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/dialogs/AlertBuilder.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2016 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("NOTHING_TO_INLINE", "unused") + +package io.legado.app.lib.dialogs + +import android.annotation.SuppressLint +import android.content.Context +import android.content.DialogInterface +import android.graphics.drawable.Drawable +import android.view.KeyEvent +import android.view.View +import android.view.ViewManager +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import org.jetbrains.anko.UI +import org.jetbrains.anko.internals.AnkoInternals.NO_GETTER +import kotlin.DeprecationLevel.ERROR + +@SuppressLint("SupportAnnotationUsage") +interface AlertBuilder { + val ctx: Context + + var title: CharSequence + @Deprecated(NO_GETTER, level = ERROR) get + + var titleResource: Int + @Deprecated(NO_GETTER, level = ERROR) get + + var message: CharSequence + @Deprecated(NO_GETTER, level = ERROR) get + + var messageResource: Int + @Deprecated(NO_GETTER, level = ERROR) get + + var icon: Drawable + @Deprecated(NO_GETTER, level = ERROR) get + + @setparam:DrawableRes + var iconResource: Int + @Deprecated(NO_GETTER, level = ERROR) get + + var customTitle: View + @Deprecated(NO_GETTER, level = ERROR) get + + var customView: View + @Deprecated(NO_GETTER, level = ERROR) get + + var isCancelable: Boolean + @Deprecated(NO_GETTER, level = ERROR) get + + fun positiveButton(buttonText: String, onClicked: ((dialog: DialogInterface) -> Unit)? = null) + fun positiveButton(@StringRes buttonTextResource: Int, onClicked: ((dialog: DialogInterface) -> Unit)? = null) + + fun negativeButton(buttonText: String, onClicked: ((dialog: DialogInterface) -> Unit)? = null) + fun negativeButton(@StringRes buttonTextResource: Int, onClicked: ((dialog: DialogInterface) -> Unit)? = null) + + fun neutralButton(buttonText: String, onClicked: ((dialog: DialogInterface) -> Unit)? = null) + fun neutralButton(@StringRes buttonTextResource: Int, onClicked: ((dialog: DialogInterface) -> Unit)? = null) + + fun onCancelled(handler: (dialog: DialogInterface) -> Unit) + + fun onKeyPressed(handler: (dialog: DialogInterface, keyCode: Int, e: KeyEvent) -> Boolean) + + fun items(items: List, onItemSelected: (dialog: DialogInterface, index: Int) -> Unit) + fun items(items: List, onItemSelected: (dialog: DialogInterface, item: T, index: Int) -> Unit) + + fun build(): D + fun show(): D +} + +fun AlertBuilder<*>.customTitle(view: () -> View) { + customTitle = view() +} + +fun AlertBuilder<*>.customView(view: () -> View) { + customView = view() +} + +inline fun AlertBuilder<*>.okButton(noinline handler: ((dialog: DialogInterface) -> Unit)? = null) = + positiveButton(android.R.string.ok, handler) + +inline fun AlertBuilder<*>.cancelButton(noinline handler: ((dialog: DialogInterface) -> Unit)? = null) = + negativeButton(android.R.string.cancel, handler) + +inline fun AlertBuilder<*>.yesButton(noinline handler: ((dialog: DialogInterface) -> Unit)? = null) = + positiveButton(android.R.string.yes, handler) + +inline fun AlertBuilder<*>.noButton(noinline handler: ((dialog: DialogInterface) -> Unit)? = null) = + negativeButton(android.R.string.no, handler) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/lib/dialogs/AndroidAlertBuilder.kt b/app/src/main/java/io/legado/app/lib/dialogs/AndroidAlertBuilder.kt new file mode 100644 index 000000000..52711a17a --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/dialogs/AndroidAlertBuilder.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2016 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.legado.app.lib.dialogs + +import android.content.Context +import android.content.DialogInterface +import android.graphics.drawable.Drawable +import android.view.KeyEvent +import android.view.View +import androidx.appcompat.app.AlertDialog +import org.jetbrains.anko.internals.AnkoInternals +import org.jetbrains.anko.internals.AnkoInternals.NO_GETTER +import kotlin.DeprecationLevel.ERROR + +val Android: AlertBuilderFactory = ::AndroidAlertBuilder + +internal class AndroidAlertBuilder(override val ctx: Context) : AlertBuilder { + private val builder = AlertDialog.Builder(ctx) + + override var title: CharSequence + @Deprecated(NO_GETTER, level = ERROR) get() = AnkoInternals.noGetter() + set(value) { builder.setTitle(value) } + + override var titleResource: Int + @Deprecated(NO_GETTER, level = ERROR) get() = AnkoInternals.noGetter() + set(value) { builder.setTitle(value) } + + override var message: CharSequence + @Deprecated(NO_GETTER, level = ERROR) get() = AnkoInternals.noGetter() + set(value) { builder.setMessage(value) } + + override var messageResource: Int + @Deprecated(NO_GETTER, level = ERROR) get() = AnkoInternals.noGetter() + set(value) { builder.setMessage(value) } + + override var icon: Drawable + @Deprecated(NO_GETTER, level = ERROR) get() = AnkoInternals.noGetter() + set(value) { builder.setIcon(value) } + + override var iconResource: Int + @Deprecated(NO_GETTER, level = ERROR) get() = AnkoInternals.noGetter() + set(value) { builder.setIcon(value) } + + override var customTitle: View + @Deprecated(NO_GETTER, level = ERROR) get() = AnkoInternals.noGetter() + set(value) { builder.setCustomTitle(value) } + + override var customView: View + @Deprecated(NO_GETTER, level = ERROR) get() = AnkoInternals.noGetter() + set(value) { builder.setView(value) } + + override var isCancelable: Boolean + @Deprecated(NO_GETTER, level = ERROR) get() = AnkoInternals.noGetter() + set(value) { builder.setCancelable(value) } + + override fun onCancelled(handler: (DialogInterface) -> Unit) { + builder.setOnCancelListener(handler) + } + + override fun onKeyPressed(handler: (dialog: DialogInterface, keyCode: Int, e: KeyEvent) -> Boolean) { + builder.setOnKeyListener(handler) + } + + override fun positiveButton(buttonText: String, onClicked: ((dialog: DialogInterface) -> Unit)?) { + builder.setPositiveButton(buttonText) { dialog, _ -> onClicked?.invoke(dialog) } + } + + override fun positiveButton(buttonTextResource: Int, onClicked: ((dialog: DialogInterface) -> Unit)?) { + builder.setPositiveButton(buttonTextResource) { dialog, _ -> onClicked?.invoke(dialog) } + } + + override fun negativeButton(buttonText: String, onClicked: ((dialog: DialogInterface) -> Unit)?) { + builder.setNegativeButton(buttonText) { dialog, _ -> onClicked?.invoke(dialog) } + } + + override fun negativeButton(buttonTextResource: Int, onClicked: ((dialog: DialogInterface) -> Unit)?) { + builder.setNegativeButton(buttonTextResource) { dialog, _ -> onClicked?.invoke(dialog) } + } + + override fun neutralButton(buttonText: String, onClicked: ((dialog: DialogInterface) -> Unit)?) { + builder.setNeutralButton(buttonText) { dialog, _ -> onClicked?.invoke(dialog) } + } + + override fun neutralButton(buttonTextResource: Int, onClicked: ((dialog: DialogInterface) -> Unit)?) { + builder.setNeutralButton(buttonTextResource) { dialog, _ -> onClicked?.invoke(dialog) } + } + + override fun items(items: List, onItemSelected: (dialog: DialogInterface, index: Int) -> Unit) { + builder.setItems(Array(items.size) { i -> items[i].toString() }) { dialog, which -> + onItemSelected(dialog, which) + } + } + + override fun items(items: List, onItemSelected: (dialog: DialogInterface, item: T, index: Int) -> Unit) { + builder.setItems(Array(items.size) { i -> items[i].toString() }) { dialog, which -> + onItemSelected(dialog, items[which], which) + } + } + + override fun build(): AlertDialog = builder.create() + + override fun show(): AlertDialog = builder.show() +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/lib/dialogs/AndroidDialogs.kt b/app/src/main/java/io/legado/app/lib/dialogs/AndroidDialogs.kt new file mode 100644 index 000000000..0d40174f4 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/dialogs/AndroidDialogs.kt @@ -0,0 +1,142 @@ +/* + * Copyright 2016 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("NOTHING_TO_INLINE", "unused", "DEPRECATION") + +package io.legado.app.lib.dialogs + +import android.app.ProgressDialog +import android.content.Context +import android.content.DialogInterface +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.Fragment +import org.jetbrains.anko.AnkoContext + +inline fun Fragment.alert( + title: CharSequence? = null, + message: CharSequence? = null, + noinline init: (AlertBuilder.() -> Unit)? = null +) = requireActivity().alert(title, message, init) + +fun Context.alert( + title: CharSequence? = null, + message: CharSequence? = null, + init: (AlertBuilder.() -> Unit)? = null +): AlertBuilder { + return AndroidAlertBuilder(this).apply { + if (title != null) { + this.title = title + } + if (message != null) { + this.message = message + } + if (init != null) init() + } +} + +inline fun Fragment.alert( + title: Int? = null, + message: Int? = null, + noinline init: (AlertBuilder.() -> Unit)? = null +) = requireActivity().alert(title, message, init) + +fun Context.alert( + titleResource: Int? = null, + messageResource: Int? = null, + init: (AlertBuilder.() -> Unit)? = null +): AlertBuilder { + return AndroidAlertBuilder(this).apply { + if (titleResource != null) { + this.titleResource = titleResource + } + if (messageResource != null) { + this.messageResource = messageResource + } + if (init != null) init() + } +} + + +inline fun AnkoContext<*>.alert(noinline init: AlertBuilder.() -> Unit) = ctx.alert(init) +inline fun Fragment.alert(noinline init: AlertBuilder.() -> Unit) = requireContext().alert(init) + +fun Context.alert(init: AlertBuilder.() -> Unit): AlertBuilder = + AndroidAlertBuilder(this).apply { init() } + +inline fun Fragment.progressDialog( + title: Int? = null, + message: Int? = null, + noinline init: (ProgressDialog.() -> Unit)? = null +) = requireActivity().progressDialog(title, message, init) + +fun Context.progressDialog( + title: Int? = null, + message: Int? = null, + init: (ProgressDialog.() -> Unit)? = null +) = progressDialog(title?.let { getString(it) }, message?.let { getString(it) }, false, init) + + +inline fun Fragment.indeterminateProgressDialog( + title: Int? = null, + message: Int? = null, + noinline init: (ProgressDialog.() -> Unit)? = null +) = requireActivity().indeterminateProgressDialog(title, message, init) + +fun Context.indeterminateProgressDialog( + title: Int? = null, + message: Int? = null, + init: (ProgressDialog.() -> Unit)? = null +) = progressDialog(title?.let { getString(it) }, message?.let { getString(it) }, true, init) + +inline fun Fragment.progressDialog( + title: CharSequence? = null, + message: CharSequence? = null, + noinline init: (ProgressDialog.() -> Unit)? = null +) = requireActivity().progressDialog(title, message, init) + +fun Context.progressDialog( + title: CharSequence? = null, + message: CharSequence? = null, + init: (ProgressDialog.() -> Unit)? = null +) = progressDialog(title, message, false, init) + + +inline fun Fragment.indeterminateProgressDialog( + title: CharSequence? = null, + message: CharSequence? = null, + noinline init: (ProgressDialog.() -> Unit)? = null +) = requireActivity().indeterminateProgressDialog(title, message, init) + +fun Context.indeterminateProgressDialog( + title: CharSequence? = null, + message: CharSequence? = null, + init: (ProgressDialog.() -> Unit)? = null +) = progressDialog(title, message, true, init) + + +private fun Context.progressDialog( + title: CharSequence? = null, + message: CharSequence? = null, + indeterminate: Boolean, + init: (ProgressDialog.() -> Unit)? = null +) = ProgressDialog(this).apply { + isIndeterminate = indeterminate + if (!indeterminate) setProgressStyle(ProgressDialog.STYLE_HORIZONTAL) + if (message != null) setMessage(message) + if (title != null) setTitle(title) + if (init != null) init() + show() +} diff --git a/app/src/main/java/io/legado/app/lib/dialogs/AndroidSelectors.kt b/app/src/main/java/io/legado/app/lib/dialogs/AndroidSelectors.kt new file mode 100644 index 000000000..17ed0dea8 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/dialogs/AndroidSelectors.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2016 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("NOTHING_TO_INLINE", "unused") + +package io.legado.app.lib.dialogs + +import android.content.Context +import android.content.DialogInterface +import androidx.fragment.app.Fragment + +inline fun Fragment.selector( + title: CharSequence? = null, + items: List, + noinline onClick: (DialogInterface, Int) -> Unit +) = activity?.selector(title, items, onClick) + +fun Context.selector( + title: CharSequence? = null, + items: List, + onClick: (DialogInterface, Int) -> Unit +) { + with(AndroidAlertBuilder(this)) { + if (title != null) { + this.title = title + } + items(items, onClick) + show() + } +} diff --git a/app/src/main/java/io/legado/app/lib/dialogs/Dialogs.kt b/app/src/main/java/io/legado/app/lib/dialogs/Dialogs.kt new file mode 100644 index 000000000..6a1e28428 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/dialogs/Dialogs.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2016 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("NOTHING_TO_INLINE", "unused") + +package io.legado.app.lib.dialogs + +import android.content.Context +import android.content.DialogInterface +import androidx.fragment.app.Fragment + +typealias AlertBuilderFactory = (Context) -> AlertBuilder + +inline fun Fragment.alert( + noinline factory: AlertBuilderFactory, + title: String? = null, + message: String? = null, + noinline init: (AlertBuilder.() -> Unit)? = null +) = activity?.alert(factory, title, message, init) + +fun Context.alert( + factory: AlertBuilderFactory, + title: String? = null, + message: String? = null, + init: (AlertBuilder.() -> Unit)? = null +): AlertBuilder { + return factory(this).apply { + if (title != null) { + this.title = title + } + if (message != null) { + this.message = message + } + if (init != null) init() + } +} + +inline fun Fragment.alert( + noinline factory: AlertBuilderFactory, + titleResource: Int? = null, + messageResource: Int? = null, + noinline init: (AlertBuilder.() -> Unit)? = null +) = requireActivity().alert(factory, titleResource, messageResource, init) + +fun Context.alert( + factory: AlertBuilderFactory, + titleResource: Int? = null, + messageResource: Int? = null, + init: (AlertBuilder.() -> Unit)? = null +): AlertBuilder { + return factory(this).apply { + if (titleResource != null) { + this.titleResource = titleResource + } + if (messageResource != null) { + this.messageResource = messageResource + } + if (init != null) init() + } +} + +inline fun Fragment.alert( + noinline factory: AlertBuilderFactory, + noinline init: AlertBuilder.() -> Unit +) = requireActivity().alert(factory, init) + +fun Context.alert( + factory: AlertBuilderFactory, + init: AlertBuilder.() -> Unit +): AlertBuilder = factory(this).apply { init() } diff --git a/app/src/main/java/io/legado/app/lib/dialogs/Selectors.kt b/app/src/main/java/io/legado/app/lib/dialogs/Selectors.kt new file mode 100644 index 000000000..b8fe1e1b4 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/dialogs/Selectors.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2016 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("NOTHING_TO_INLINE", "unused") + +package io.legado.app.lib.dialogs + +import android.content.Context +import android.content.DialogInterface +import androidx.fragment.app.Fragment + +inline fun Fragment.selector( + noinline factory: AlertBuilderFactory, + title: CharSequence? = null, + items: List, + noinline onClick: (DialogInterface, CharSequence, Int) -> Unit +) = requireActivity().selector(factory, title, items, onClick) + +fun Context.selector( + factory: AlertBuilderFactory, + title: CharSequence? = null, + items: List, + onClick: (DialogInterface, CharSequence, Int) -> Unit +) { + with(factory(this)) { + if (title != null) { + this.title = title + } + items(items, onClick) + show() + } +} diff --git a/app/src/main/java/io/legado/app/lib/theme/ATH.kt b/app/src/main/java/io/legado/app/lib/theme/ATH.kt new file mode 100644 index 000000000..19542ffc4 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/ATH.kt @@ -0,0 +1,243 @@ +package io.legado.app.lib.theme + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.ActivityManager +import android.content.Context +import android.graphics.Color +import android.os.Build +import android.view.View +import android.view.View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR +import android.widget.EdgeEffect +import android.widget.ScrollView +import androidx.annotation.ColorInt +import androidx.appcompat.app.AlertDialog +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager.widget.ViewPager +import com.google.android.material.bottomnavigation.BottomNavigationView +import io.legado.app.R +import io.legado.app.utils.getCompatColor +import io.legado.app.utils.isNightTheme +import io.legado.app.utils.isTransparentStatusBar +import kotlinx.android.synthetic.main.activity_main.view.* +import org.jetbrains.anko.backgroundColor + + +/** + * @author Karim Abou Zeid (kabouzeid) + */ +object ATH { + + @SuppressLint("CommitPrefEdits") + fun didThemeValuesChange(context: Context, since: Long): Boolean { + return ThemeStore.isConfigured(context) && ThemeStore.prefs(context).getLong( + ThemeStorePrefKeys.VALUES_CHANGED, + -1 + ) > since + } + + fun setStatusBarColorAuto(activity: Activity, fullScreen: Boolean) { + val isTransparentStatusBar = activity.isTransparentStatusBar + setStatusBarColor( + activity, + ThemeStore.statusBarColor(activity, isTransparentStatusBar), + isTransparentStatusBar, fullScreen + ) + } + + fun setStatusBarColor( + activity: Activity, + color: Int, + isTransparentStatusBar: Boolean, + fullScreen: Boolean + ) { + if (fullScreen) { + if (isTransparentStatusBar) { + activity.window.statusBarColor = Color.TRANSPARENT + } else { + activity.window.statusBarColor = activity.getCompatColor(R.color.status_bar_bag) + } + } else { + activity.window.statusBarColor = color + } + setLightStatusBarAuto(activity, color) + } + + fun setLightStatusBarAuto(activity: Activity, bgColor: Int) { + setLightStatusBar(activity, ColorUtils.isColorLight(bgColor)) + } + + fun setLightStatusBar(activity: Activity, enabled: Boolean) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val decorView = activity.window.decorView + val systemUiVisibility = decorView.systemUiVisibility + if (enabled) { + decorView.systemUiVisibility = + systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR + } else { + decorView.systemUiVisibility = + systemUiVisibility and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv() + } + } + } + + fun setLightNavigationBar(activity: Activity, enabled: Boolean) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val decorView = activity.window.decorView + var systemUiVisibility = decorView.systemUiVisibility + systemUiVisibility = if (enabled) { + systemUiVisibility or SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR + } else { + systemUiVisibility and SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR.inv() + } + decorView.systemUiVisibility = systemUiVisibility + } + } + + fun setLightNavigationBarAuto(activity: Activity, bgColor: Int) { + setLightNavigationBar(activity, ColorUtils.isColorLight(bgColor)) + } + + fun setNavigationBarColorAuto(activity: Activity) { + setNavigationBarColor(activity, ThemeStore.navigationBarColor(activity)) + } + + fun setNavigationBarColor(activity: Activity, color: Int) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + activity.window.navigationBarColor = color + setLightNavigationBarAuto(activity, color) + } + } + + fun setTaskDescriptionColorAuto(activity: Activity) { + setTaskDescriptionColor(activity, ThemeStore.primaryColor(activity)) + } + + fun setTaskDescriptionColor(activity: Activity, @ColorInt color: Int) { + val color1: Int + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + color1 = ColorUtils.stripAlpha(color) + @Suppress("DEPRECATION") + activity.setTaskDescription( + ActivityManager.TaskDescription( + activity.title as String, + null, + color1 + ) + ) + } + } + + fun setTint( + view: View, + @ColorInt color: Int, + isDark: Boolean = view.context.isNightTheme + ) { + TintHelper.setTintAuto(view, color, false, isDark) + } + + fun setBackgroundTint( + view: View, @ColorInt color: Int, + isDark: Boolean = view.context.isNightTheme + ) { + TintHelper.setTintAuto(view, color, true, isDark) + } + + fun setAlertDialogTint(dialog: AlertDialog): AlertDialog { + val colorStateList = Selector.colorBuild() + .setDefaultColor(ThemeStore.accentColor(dialog.context)) + .setPressedColor(ColorUtils.darkenColor(ThemeStore.accentColor(dialog.context))) + .create() + if (dialog.getButton(AlertDialog.BUTTON_NEGATIVE) != null) { + dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setTextColor(colorStateList) + } + if (dialog.getButton(AlertDialog.BUTTON_POSITIVE) != null) { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setTextColor(colorStateList) + } + if (dialog.getButton(AlertDialog.BUTTON_NEUTRAL) != null) { + dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setTextColor(colorStateList) + } + return dialog + } + + fun setEdgeEffectColor(view: RecyclerView?, @ColorInt color: Int) { + view?.edgeEffectFactory = object : RecyclerView.EdgeEffectFactory() { + override fun createEdgeEffect(view: RecyclerView, direction: Int): EdgeEffect { + val edgeEffect = super.createEdgeEffect(view, direction) + edgeEffect.color = color + return edgeEffect + } + } + } + + fun setEdgeEffectColor(viewPager: ViewPager?, @ColorInt color: Int) { + try { + val clazz = ViewPager::class.java + for (name in arrayOf("mLeftEdge", "mRightEdge")) { + val field = clazz.getDeclaredField(name) + field.isAccessible = true + val edge = field.get(viewPager) + (edge as EdgeEffect).color = color + } + } catch (ignored: Exception) { + } + } + + fun setEdgeEffectColor(scrollView: ScrollView?, @ColorInt color: Int) { + try { + val clazz = ScrollView::class.java + for (name in arrayOf("mEdgeGlowTop", "mEdgeGlowBottom")) { + val field = clazz.getDeclaredField(name) + field.isAccessible = true + val edge = field.get(scrollView) + (edge as EdgeEffect).color = color + } + } catch (ignored: Exception) { + } + } + + //**************************************************************Directly*************************************************************// + + fun applyBottomNavigationColor(bottomBar: BottomNavigationView?) { + bottomBar?.apply { + setBackgroundColor(ThemeStore.backgroundColor(context)) + val colorStateList = Selector.colorBuild() + .setDefaultColor(context.getCompatColor(R.color.btn_bg_press_tp)) + .setSelectedColor(ThemeStore.primaryColor(bottom_navigation_view.context)).create() + itemIconTintList = colorStateList + itemTextColor = colorStateList + } + } + + fun applyAccentTint(view: View?) { + view?.apply { + setTint(this, context.accentColor) + } + } + + fun applyBackgroundTint(view: View?) { + view?.apply { + if (background == null) { + backgroundColor = context.backgroundColor + } else { + setBackgroundTint(this, context.backgroundColor) + } + } + } + + fun applyEdgeEffectColor(view: View?) { + when (view) { + is RecyclerView -> view.edgeEffectFactory = DEFAULT_EFFECT_FACTORY + is ViewPager -> setEdgeEffectColor(view, ThemeStore.primaryColor(view.context)) + is ScrollView -> setEdgeEffectColor(view, ThemeStore.primaryColor(view.context)) + } + } + + private val DEFAULT_EFFECT_FACTORY = object : RecyclerView.EdgeEffectFactory() { + override fun createEdgeEffect(view: RecyclerView, direction: Int): EdgeEffect { + val edgeEffect = super.createEdgeEffect(view, direction) + edgeEffect.color = ThemeStore.primaryColor(view.context) + return edgeEffect + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/lib/theme/ATHUtils.kt b/app/src/main/java/io/legado/app/lib/theme/ATHUtils.kt new file mode 100644 index 000000000..24d0a923a --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/ATHUtils.kt @@ -0,0 +1,26 @@ +package io.legado.app.lib.theme + +import android.content.Context +import androidx.annotation.AttrRes + +/** + * @author Aidan Follestad (afollestad) + */ +object ATHUtils { + + fun isWindowBackgroundDark(context: Context): Boolean { + return !ColorUtils.isColorLight(resolveColor(context, android.R.attr.windowBackground)) + } + + @JvmOverloads + fun resolveColor(context: Context, @AttrRes attr: Int, fallback: Int = 0): Int { + val a = context.theme.obtainStyledAttributes(intArrayOf(attr)) + return try { + a.getColor(0, fallback) + } catch (e: Exception) { + fallback + } finally { + a.recycle() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/lib/theme/ColorUtils.kt b/app/src/main/java/io/legado/app/lib/theme/ColorUtils.kt new file mode 100644 index 000000000..b4a17748e --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/ColorUtils.kt @@ -0,0 +1,156 @@ +package io.legado.app.lib.theme + +import android.graphics.Color +import androidx.annotation.ColorInt +import androidx.annotation.FloatRange +import java.util.* + +object ColorUtils { + + fun intToString(intColor: Int): String { + return String.format("#%06X", 0xFFFFFF and intColor) + } + + + fun stripAlpha(@ColorInt color: Int): Int { + return -0x1000000 or color + } + + @ColorInt + fun shiftColor(@ColorInt color: Int, @FloatRange(from = 0.0, to = 2.0) by: Float): Int { + if (by == 1f) return color + val alpha = Color.alpha(color) + val hsv = FloatArray(3) + Color.colorToHSV(color, hsv) + hsv[2] *= by // value component + return (alpha shl 24) + (0x00ffffff and Color.HSVToColor(hsv)) + } + + @ColorInt + fun darkenColor(@ColorInt color: Int): Int { + return shiftColor(color, 0.9f) + } + + @ColorInt + fun lightenColor(@ColorInt color: Int): Int { + return shiftColor(color, 1.1f) + } + + fun isColorLight(@ColorInt color: Int): Boolean { + val darkness = 1 - (0.299 * Color.red(color) + 0.587 * Color.green(color) + 0.114 * Color.blue(color)) / 255 + return darkness < 0.4 + } + + @ColorInt + fun invertColor(@ColorInt color: Int): Int { + val r = 255 - Color.red(color) + val g = 255 - Color.green(color) + val b = 255 - Color.blue(color) + return Color.argb(Color.alpha(color), r, g, b) + } + + @ColorInt + fun adjustAlpha(@ColorInt color: Int, @FloatRange(from = 0.0, to = 1.0) factor: Float): Int { + val alpha = Math.round(Color.alpha(color) * factor) + val red = Color.red(color) + val green = Color.green(color) + val blue = Color.blue(color) + return Color.argb(alpha, red, green, blue) + } + + @ColorInt + fun withAlpha(@ColorInt baseColor: Int, @FloatRange(from = 0.0, to = 1.0) alpha: Float): Int { + val a = Math.min(255, Math.max(0, (alpha * 255).toInt())) shl 24 + val rgb = 0x00ffffff and baseColor + return a + rgb + } + + /** + * Taken from CollapsingToolbarLayout's CollapsingTextHelper class. + */ + fun blendColors(color1: Int, color2: Int, @FloatRange(from = 0.0, to = 1.0) ratio: Float): Int { + val inverseRatio = 1f - ratio + val a = Color.alpha(color1) * inverseRatio + Color.alpha(color2) * ratio + val r = Color.red(color1) * inverseRatio + Color.red(color2) * ratio + val g = Color.green(color1) * inverseRatio + Color.green(color2) * ratio + val b = Color.blue(color1) * inverseRatio + Color.blue(color2) * ratio + return Color.argb(a.toInt(), r.toInt(), g.toInt(), b.toInt()) + } + + /** + * 按条件的到随机颜色 + * + * @param alpha 透明 + * @param lower 下边界 + * @param upper 上边界 + * @return 颜色值 + */ + fun getRandomColor(alpha: Int, lower: Int, upper: Int): Int { + return RandomColor(alpha, lower, upper).color + } + + /** + * @return 获取随机色 + */ + fun getRandomColor(): Int { + return RandomColor(255, 80, 200).color + } + + + /** + * 随机颜色 + */ + class RandomColor(alpha: Int, lower: Int, upper: Int) { + private var alpha: Int = 0 + private var lower: Int = 0 + private var upper: Int = 0 + + //随机数是前闭 后开 + val color: Int + get() { + val red = getLower() + Random().nextInt(getUpper() - getLower() + 1) + val green = getLower() + Random().nextInt(getUpper() - getLower() + 1) + val blue = getLower() + Random().nextInt(getUpper() - getLower() + 1) + + return Color.argb(getAlpha(), red, green, blue) + } + + init { + require(upper > lower) { "must be lower < upper" } + setAlpha(alpha) + setLower(lower) + setUpper(upper) + } + + private fun getAlpha(): Int { + return alpha + } + + private fun setAlpha(alpha: Int) { + var alpha1 = alpha + if (alpha1 > 255) alpha1 = 255 + if (alpha1 < 0) alpha1 = 0 + this.alpha = alpha1 + } + + private fun getLower(): Int { + return lower + } + + private fun setLower(lower: Int) { + var lower1 = lower + if (lower1 < 0) lower1 = 0 + this.lower = lower1 + } + + private fun getUpper(): Int { + return upper + } + + private fun setUpper(upper: Int) { + var upper1 = upper + if (upper1 > 255) upper1 = 255 + this.upper = upper1 + } + } +} diff --git a/app/src/main/java/io/legado/app/lib/theme/DrawableUtils.kt b/app/src/main/java/io/legado/app/lib/theme/DrawableUtils.kt new file mode 100644 index 000000000..660448088 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/DrawableUtils.kt @@ -0,0 +1,47 @@ +package io.legado.app.lib.theme + +import android.content.res.ColorStateList +import android.graphics.PorterDuff +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.TransitionDrawable +import androidx.annotation.ColorInt +import androidx.core.graphics.drawable.DrawableCompat + +/** + * @author Karim Abou Zeid (kabouzeid) + */ +object DrawableUtils { + + fun createTransitionDrawable(@ColorInt startColor: Int, @ColorInt endColor: Int): TransitionDrawable { + return createTransitionDrawable(ColorDrawable(startColor), ColorDrawable(endColor)) + } + + fun createTransitionDrawable(start: Drawable, end: Drawable): TransitionDrawable { + val drawables = arrayOfNulls(2) + + drawables[0] = start + drawables[1] = end + + return TransitionDrawable(drawables) + } + + fun setTintList(drawable: Drawable?, tint: ColorStateList, tintMode: PorterDuff.Mode = PorterDuff.Mode.SRC_ATOP) { + drawable?.let { + val wrappedDrawable = DrawableCompat.wrap(it) + wrappedDrawable.mutate() + DrawableCompat.setTintMode(wrappedDrawable, tintMode) + DrawableCompat.setTintList(wrappedDrawable, tint) + } + } + + + fun setTint(drawable: Drawable?, @ColorInt tint: Int, tintMode: PorterDuff.Mode = PorterDuff.Mode.SRC_ATOP) { + drawable?.let { + val wrappedDrawable = DrawableCompat.wrap(it) + wrappedDrawable.mutate() + DrawableCompat.setTintMode(wrappedDrawable, tintMode) + DrawableCompat.setTint(wrappedDrawable, tint) + } + } +} diff --git a/app/src/main/java/io/legado/app/lib/theme/MaterialValueHelper.kt b/app/src/main/java/io/legado/app/lib/theme/MaterialValueHelper.kt new file mode 100644 index 000000000..1618884b5 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/MaterialValueHelper.kt @@ -0,0 +1,104 @@ +package io.legado.app.lib.theme + +import android.annotation.SuppressLint +import android.content.Context +import androidx.annotation.ColorInt +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import io.legado.app.R + +/** + * @author Karim Abou Zeid (kabouzeid) + */ +@SuppressLint("PrivateResource") +@ColorInt +fun Context.getPrimaryTextColor(dark: Boolean): Int { + return if (dark) { + ContextCompat.getColor(this, R.color.primary_text_default_material_light) + } else ContextCompat.getColor(this, R.color.primary_text_default_material_dark) +} + +@SuppressLint("PrivateResource") +@ColorInt +fun Context.getSecondaryTextColor(dark: Boolean): Int { + return if (dark) { + ContextCompat.getColor(this, R.color.secondary_text_default_material_light) + } else ContextCompat.getColor(this, R.color.secondary_text_default_material_dark) +} + +@SuppressLint("PrivateResource") +@ColorInt +fun Context.getPrimaryDisabledTextColor(dark: Boolean): Int { + return if (dark) { + ContextCompat.getColor(this, R.color.primary_text_disabled_material_light) + } else ContextCompat.getColor(this, R.color.primary_text_disabled_material_dark) +} + +@SuppressLint("PrivateResource") +@ColorInt +fun Context.getSecondaryDisabledTextColor(dark: Boolean): Int { + return if (dark) { + ContextCompat.getColor(this, R.color.secondary_text_disabled_material_light) + } else ContextCompat.getColor(this, R.color.secondary_text_disabled_material_dark) +} + +val Context.primaryColor: Int + get() = ThemeStore.primaryColor(this) + +val Context.primaryColorDark: Int + get() = ThemeStore.primaryColorDark(this) + +val Context.accentColor: Int + get() = ThemeStore.accentColor(this) + +val Context.backgroundColor: Int + get() = ThemeStore.backgroundColor(this) + +val Context.primaryTextColor: Int + get() = getPrimaryTextColor(isDarkTheme) + +val Context.secondaryTextColor: Int + get() = getSecondaryTextColor(isDarkTheme) + +val Context.primaryDisabledTextColor: Int + get() = getPrimaryDisabledTextColor(isDarkTheme) + +val Context.secondaryDisabledTextColor: Int + get() = getSecondaryDisabledTextColor(isDarkTheme) + +val Fragment.primaryColor: Int + get() = ThemeStore.primaryColor(requireContext()) + +val Fragment.primaryColorDark: Int + get() = ThemeStore.primaryColorDark(requireContext()) + +val Fragment.accentColor: Int + get() = ThemeStore.accentColor(requireContext()) + +val Fragment.backgroundColor: Int + get() = ThemeStore.backgroundColor(requireContext()) + +val Fragment.primaryTextColor: Int + get() = requireContext().getPrimaryTextColor(isDarkTheme) + +val Fragment.secondaryTextColor: Int + get() = requireContext().getSecondaryTextColor(isDarkTheme) + +val Fragment.primaryDisabledTextColor: Int + get() = requireContext().getPrimaryDisabledTextColor(isDarkTheme) + +val Fragment.secondaryDisabledTextColor: Int + get() = requireContext().getSecondaryDisabledTextColor(isDarkTheme) + +val Context.buttonDisabledColor: Int + get() = if (isDarkTheme) { + ContextCompat.getColor(this, R.color.ate_button_disabled_dark) + } else { + ContextCompat.getColor(this, R.color.ate_button_disabled_light) + } + +val Context.isDarkTheme: Boolean + get() = ColorUtils.isColorLight(ThemeStore.primaryColor(this)) + +val Fragment.isDarkTheme: Boolean + get() = requireContext().isDarkTheme \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/lib/theme/NavigationViewUtils.kt b/app/src/main/java/io/legado/app/lib/theme/NavigationViewUtils.kt new file mode 100644 index 000000000..c199ba855 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/NavigationViewUtils.kt @@ -0,0 +1,38 @@ +package io.legado.app.lib.theme + +import android.content.res.ColorStateList +import androidx.annotation.ColorInt +import com.google.android.material.internal.NavigationMenuView +import com.google.android.material.navigation.NavigationView + +/** + * @author Karim Abou Zeid (kabouzeid) + */ +object NavigationViewUtils { + + fun setItemIconColors(navigationView: NavigationView, @ColorInt normalColor: Int, @ColorInt selectedColor: Int) { + val iconSl = ColorStateList( + arrayOf(intArrayOf(-android.R.attr.state_checked), intArrayOf(android.R.attr.state_checked)), + intArrayOf(normalColor, selectedColor) + ) + navigationView.itemIconTintList = iconSl + } + + fun setItemTextColors(navigationView: NavigationView, @ColorInt normalColor: Int, @ColorInt selectedColor: Int) { + val textSl = ColorStateList( + arrayOf(intArrayOf(-android.R.attr.state_checked), intArrayOf(android.R.attr.state_checked)), + intArrayOf(normalColor, selectedColor) + ) + navigationView.itemTextColor = textSl + } + + /** + * 去掉navigationView的滚动条 + * @param navigationView NavigationView + */ + fun disableScrollbar(navigationView: NavigationView?) { + navigationView ?: return + val navigationMenuView = navigationView.getChildAt(0) as? NavigationMenuView + navigationMenuView?.isVerticalScrollBarEnabled = false + } +} diff --git a/app/src/main/java/io/legado/app/lib/theme/README.md b/app/src/main/java/io/legado/app/lib/theme/README.md new file mode 100644 index 000000000..cfba14d6d --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/README.md @@ -0,0 +1 @@ +## 主题引擎 \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/lib/theme/Selector.kt b/app/src/main/java/io/legado/app/lib/theme/Selector.kt new file mode 100644 index 000000000..89a71198b --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/Selector.kt @@ -0,0 +1,443 @@ +package io.legado.app.lib.theme + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.StateListDrawable +import androidx.annotation.ColorInt +import androidx.annotation.Dimension +import androidx.annotation.DrawableRes +import androidx.annotation.IntDef +import androidx.core.content.ContextCompat + +object Selector { + fun shapeBuild(): ShapeSelector { + return ShapeSelector() + } + + fun colorBuild(): ColorSelector { + return ColorSelector() + } + + fun drawableBuild(): DrawableSelector { + return DrawableSelector() + } + + /** + * 形状ShapeSelector + * + * @author hjy + * created at 2017/12/11 22:26 + */ + class ShapeSelector { + + private var mShape: Int = 0 //the shape of background + private var mDefaultBgColor: Int = 0 //default background color + private var mDisabledBgColor: Int = 0 //state_enabled = false + private var mPressedBgColor: Int = 0 //state_pressed = true + private var mSelectedBgColor: Int = 0 //state_selected = true + private var mFocusedBgColor: Int = 0 //state_focused = true + private var mCheckedBgColor: Int = 0 //state_checked = true + private var mStrokeWidth: Int = 0 //stroke width in pixel + private var mDefaultStrokeColor: Int = 0 //default stroke color + private var mDisabledStrokeColor: Int = 0 //state_enabled = false + private var mPressedStrokeColor: Int = 0 //state_pressed = true + private var mSelectedStrokeColor: Int = 0 //state_selected = true + private var mFocusedStrokeColor: Int = 0 //state_focused = true + private var mCheckedStrokeColor: Int = 0 //state_checked = true + private var mCornerRadius: Int = 0 //corner radius + + private var hasSetDisabledBgColor = false + private var hasSetPressedBgColor = false + private var hasSetSelectedBgColor = false + private val hasSetFocusedBgColor = false + private var hasSetCheckedBgColor = false + + private var hasSetDisabledStrokeColor = false + private var hasSetPressedStrokeColor = false + private var hasSetSelectedStrokeColor = false + private var hasSetFocusedStrokeColor = false + private var hasSetCheckedStrokeColor = false + + @IntDef(GradientDrawable.RECTANGLE, GradientDrawable.OVAL, GradientDrawable.LINE, GradientDrawable.RING) + private annotation class Shape + + init { + //initialize default values + mShape = GradientDrawable.RECTANGLE + mDefaultBgColor = Color.TRANSPARENT + mDisabledBgColor = Color.TRANSPARENT + mPressedBgColor = Color.TRANSPARENT + mSelectedBgColor = Color.TRANSPARENT + mFocusedBgColor = Color.TRANSPARENT + mStrokeWidth = 0 + mDefaultStrokeColor = Color.TRANSPARENT + mDisabledStrokeColor = Color.TRANSPARENT + mPressedStrokeColor = Color.TRANSPARENT + mSelectedStrokeColor = Color.TRANSPARENT + mFocusedStrokeColor = Color.TRANSPARENT + mCornerRadius = 0 + } + + fun setShape(@Shape shape: Int): ShapeSelector { + mShape = shape + return this + } + + fun setDefaultBgColor(@ColorInt color: Int): ShapeSelector { + mDefaultBgColor = color + if (!hasSetDisabledBgColor) + mDisabledBgColor = color + if (!hasSetPressedBgColor) + mPressedBgColor = color + if (!hasSetSelectedBgColor) + mSelectedBgColor = color + if (!hasSetFocusedBgColor) + mFocusedBgColor = color + return this + } + + fun setDisabledBgColor(@ColorInt color: Int): ShapeSelector { + mDisabledBgColor = color + hasSetDisabledBgColor = true + return this + } + + fun setPressedBgColor(@ColorInt color: Int): ShapeSelector { + mPressedBgColor = color + hasSetPressedBgColor = true + return this + } + + fun setSelectedBgColor(@ColorInt color: Int): ShapeSelector { + mSelectedBgColor = color + hasSetSelectedBgColor = true + return this + } + + fun setFocusedBgColor(@ColorInt color: Int): ShapeSelector { + mFocusedBgColor = color + hasSetPressedBgColor = true + return this + } + + fun setCheckedBgColor(@ColorInt color: Int): ShapeSelector { + mCheckedBgColor = color + hasSetCheckedBgColor = true + return this + } + + fun setStrokeWidth(@Dimension width: Int): ShapeSelector { + mStrokeWidth = width + return this + } + + fun setDefaultStrokeColor(@ColorInt color: Int): ShapeSelector { + mDefaultStrokeColor = color + if (!hasSetDisabledStrokeColor) + mDisabledStrokeColor = color + if (!hasSetPressedStrokeColor) + mPressedStrokeColor = color + if (!hasSetSelectedStrokeColor) + mSelectedStrokeColor = color + if (!hasSetFocusedStrokeColor) + mFocusedStrokeColor = color + return this + } + + fun setDisabledStrokeColor(@ColorInt color: Int): ShapeSelector { + mDisabledStrokeColor = color + hasSetDisabledStrokeColor = true + return this + } + + fun setPressedStrokeColor(@ColorInt color: Int): ShapeSelector { + mPressedStrokeColor = color + hasSetPressedStrokeColor = true + return this + } + + fun setSelectedStrokeColor(@ColorInt color: Int): ShapeSelector { + mSelectedStrokeColor = color + hasSetSelectedStrokeColor = true + return this + } + + fun setCheckedStrokeColor(@ColorInt color: Int): ShapeSelector { + mCheckedStrokeColor = color + hasSetCheckedStrokeColor = true + return this + } + + fun setFocusedStrokeColor(@ColorInt color: Int): ShapeSelector { + mFocusedStrokeColor = color + hasSetFocusedStrokeColor = true + return this + } + + fun setCornerRadius(@Dimension radius: Int): ShapeSelector { + mCornerRadius = radius + return this + } + + fun create(): StateListDrawable { + val selector = StateListDrawable() + + //enabled = false + if (hasSetDisabledBgColor || hasSetDisabledStrokeColor) { + val disabledShape = getItemShape( + mShape, mCornerRadius, + mDisabledBgColor, mStrokeWidth, mDisabledStrokeColor + ) + selector.addState(intArrayOf(-android.R.attr.state_enabled), disabledShape) + } + + //pressed = true + if (hasSetPressedBgColor || hasSetPressedStrokeColor) { + val pressedShape = getItemShape( + mShape, mCornerRadius, + mPressedBgColor, mStrokeWidth, mPressedStrokeColor + ) + selector.addState(intArrayOf(android.R.attr.state_pressed), pressedShape) + } + + //selected = true + if (hasSetSelectedBgColor || hasSetSelectedStrokeColor) { + val selectedShape = getItemShape( + mShape, mCornerRadius, + mSelectedBgColor, mStrokeWidth, mSelectedStrokeColor + ) + selector.addState(intArrayOf(android.R.attr.state_selected), selectedShape) + } + + //focused = true + if (hasSetFocusedBgColor || hasSetFocusedStrokeColor) { + val focusedShape = getItemShape( + mShape, mCornerRadius, + mFocusedBgColor, mStrokeWidth, mFocusedStrokeColor + ) + selector.addState(intArrayOf(android.R.attr.state_focused), focusedShape) + } + + //checked = true + if (hasSetCheckedBgColor || hasSetCheckedStrokeColor) { + val checkedShape = getItemShape( + mShape, mCornerRadius, + mCheckedBgColor, mStrokeWidth, mCheckedStrokeColor + ) + selector.addState(intArrayOf(android.R.attr.state_checked), checkedShape) + } + + //default + val defaultShape = getItemShape( + mShape, mCornerRadius, + mDefaultBgColor, mStrokeWidth, mDefaultStrokeColor + ) + selector.addState(intArrayOf(), defaultShape) + + return selector + } + + private fun getItemShape( + shape: Int, cornerRadius: Int, + solidColor: Int, strokeWidth: Int, strokeColor: Int + ): GradientDrawable { + val drawable = GradientDrawable() + drawable.shape = shape + drawable.setStroke(strokeWidth, strokeColor) + drawable.cornerRadius = cornerRadius.toFloat() + drawable.setColor(solidColor) + return drawable + } + } + + /** + * 资源DrawableSelector + * + * @author hjy + * created at 2017/12/11 22:34 + */ + class DrawableSelector constructor() { + + private var mDefaultDrawable: Drawable? = null + private var mDisabledDrawable: Drawable? = null + private var mPressedDrawable: Drawable? = null + private var mSelectedDrawable: Drawable? = null + private var mFocusedDrawable: Drawable? = null + + private var hasSetDisabledDrawable = false + private var hasSetPressedDrawable = false + private var hasSetSelectedDrawable = false + private var hasSetFocusedDrawable = false + + init { + mDefaultDrawable = ColorDrawable(Color.TRANSPARENT) + } + + fun setDefaultDrawable(drawable: Drawable?): DrawableSelector { + mDefaultDrawable = drawable + if (!hasSetDisabledDrawable) + mDisabledDrawable = drawable + if (!hasSetPressedDrawable) + mPressedDrawable = drawable + if (!hasSetSelectedDrawable) + mSelectedDrawable = drawable + if (!hasSetFocusedDrawable) + mFocusedDrawable = drawable + return this + } + + fun setDisabledDrawable(drawable: Drawable?): DrawableSelector { + mDisabledDrawable = drawable + hasSetDisabledDrawable = true + return this + } + + fun setPressedDrawable(drawable: Drawable?): DrawableSelector { + mPressedDrawable = drawable + hasSetPressedDrawable = true + return this + } + + fun setSelectedDrawable(drawable: Drawable?): DrawableSelector { + mSelectedDrawable = drawable + hasSetSelectedDrawable = true + return this + } + + fun setFocusedDrawable(drawable: Drawable?): DrawableSelector { + mFocusedDrawable = drawable + hasSetFocusedDrawable = true + return this + } + + fun create(): StateListDrawable { + val selector = StateListDrawable() + if (hasSetDisabledDrawable) + selector.addState(intArrayOf(-android.R.attr.state_enabled), mDisabledDrawable) + if (hasSetPressedDrawable) + selector.addState(intArrayOf(android.R.attr.state_pressed), mPressedDrawable) + if (hasSetSelectedDrawable) + selector.addState(intArrayOf(android.R.attr.state_selected), mSelectedDrawable) + if (hasSetFocusedDrawable) + selector.addState(intArrayOf(android.R.attr.state_focused), mFocusedDrawable) + selector.addState(intArrayOf(), mDefaultDrawable) + return selector + } + + fun setDefaultDrawable(context: Context, @DrawableRes drawableRes: Int): DrawableSelector { + return setDefaultDrawable(ContextCompat.getDrawable(context, drawableRes)) + } + + fun setDisabledDrawable(context: Context, @DrawableRes drawableRes: Int): DrawableSelector { + return setDisabledDrawable(ContextCompat.getDrawable(context, drawableRes)) + } + + fun setPressedDrawable(context: Context, @DrawableRes drawableRes: Int): DrawableSelector { + return setPressedDrawable(ContextCompat.getDrawable(context, drawableRes)) + } + + fun setSelectedDrawable(context: Context, @DrawableRes drawableRes: Int): DrawableSelector { + return setSelectedDrawable(ContextCompat.getDrawable(context, drawableRes)) + } + + fun setFocusedDrawable(context: Context, @DrawableRes drawableRes: Int): DrawableSelector { + return setFocusedDrawable(ContextCompat.getDrawable(context, drawableRes)) + } + } + + /** + * 颜色ColorSelector + * + * @author hjy + * created at 2017/12/11 22:26 + */ + class ColorSelector constructor() { + + private var mDefaultColor: Int = 0 + private var mDisabledColor: Int = 0 + private var mPressedColor: Int = 0 + private var mSelectedColor: Int = 0 + private var mFocusedColor: Int = 0 + private var mCheckedColor: Int = 0 + + private var hasSetDisabledColor = false + private var hasSetPressedColor = false + private var hasSetSelectedColor = false + private var hasSetFocusedColor = false + private var hasSetCheckedColor = false + + init { + mDefaultColor = Color.BLACK + mDisabledColor = Color.GRAY + mPressedColor = Color.BLACK + mSelectedColor = Color.BLACK + mFocusedColor = Color.BLACK + } + + fun setDefaultColor(@ColorInt color: Int): ColorSelector { + mDefaultColor = color + if (!hasSetDisabledColor) + mDisabledColor = color + if (!hasSetPressedColor) + mPressedColor = color + if (!hasSetSelectedColor) + mSelectedColor = color + if (!hasSetFocusedColor) + mFocusedColor = color + return this + } + + fun setDisabledColor(@ColorInt color: Int): ColorSelector { + mDisabledColor = color + hasSetDisabledColor = true + return this + } + + fun setPressedColor(@ColorInt color: Int): ColorSelector { + mPressedColor = color + hasSetPressedColor = true + return this + } + + fun setSelectedColor(@ColorInt color: Int): ColorSelector { + mSelectedColor = color + hasSetSelectedColor = true + return this + } + + fun setFocusedColor(@ColorInt color: Int): ColorSelector { + mFocusedColor = color + hasSetFocusedColor = true + return this + } + + fun setCheckedColor(@ColorInt color: Int): ColorSelector { + mCheckedColor = color + hasSetCheckedColor = true + return this + } + + fun create(): ColorStateList { + val colors = intArrayOf( + if (hasSetDisabledColor) mDisabledColor else mDefaultColor, + if (hasSetPressedColor) mPressedColor else mDefaultColor, + if (hasSetSelectedColor) mSelectedColor else mDefaultColor, + if (hasSetFocusedColor) mFocusedColor else mDefaultColor, + if (hasSetCheckedColor) mCheckedColor else mDefaultColor, + mDefaultColor + ) + val states = arrayOfNulls(6) + states[0] = intArrayOf(-android.R.attr.state_enabled) + states[1] = intArrayOf(android.R.attr.state_pressed) + states[2] = intArrayOf(android.R.attr.state_selected) + states[3] = intArrayOf(android.R.attr.state_focused) + states[4] = intArrayOf(android.R.attr.state_checked) + states[5] = intArrayOf() + return ColorStateList(states, colors) + } + } +} diff --git a/app/src/main/java/io/legado/app/lib/theme/ThemeStore.kt b/app/src/main/java/io/legado/app/lib/theme/ThemeStore.kt new file mode 100644 index 000000000..b3605e3e9 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/ThemeStore.kt @@ -0,0 +1,313 @@ +package io.legado.app.lib.theme + +import android.annotation.SuppressLint +import android.content.Context +import android.content.SharedPreferences +import android.graphics.Color +import androidx.annotation.AttrRes +import androidx.annotation.CheckResult +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.core.content.ContextCompat +import io.legado.app.R + +/** + * @author Aidan Follestad (afollestad), Karim Abou Zeid (kabouzeid) + */ +class ThemeStore @SuppressLint("CommitPrefEdits") +private constructor(private val mContext: Context) : ThemeStorePrefKeys, ThemeStoreInterface { + private val mEditor: SharedPreferences.Editor + + init { + mEditor = prefs(mContext).edit() + } + + + override fun primaryColor(@ColorInt color: Int): ThemeStore { + mEditor.putInt(ThemeStorePrefKeys.KEY_PRIMARY_COLOR, color) + if (autoGeneratePrimaryDark(mContext)) + primaryColorDark(ColorUtils.darkenColor(color)) + return this + } + + override fun primaryColorRes(@ColorRes colorRes: Int): ThemeStore { + return primaryColor(ContextCompat.getColor(mContext, colorRes)) + } + + override fun primaryColorAttr(@AttrRes colorAttr: Int): ThemeStore { + return primaryColor(ATHUtils.resolveColor(mContext, colorAttr)) + } + + override fun primaryColorDark(@ColorInt color: Int): ThemeStore { + mEditor.putInt(ThemeStorePrefKeys.KEY_PRIMARY_COLOR_DARK, color) + return this + } + + override fun primaryColorDarkRes(@ColorRes colorRes: Int): ThemeStore { + return primaryColorDark(ContextCompat.getColor(mContext, colorRes)) + } + + override fun primaryColorDarkAttr(@AttrRes colorAttr: Int): ThemeStore { + return primaryColorDark(ATHUtils.resolveColor(mContext, colorAttr)) + } + + override fun accentColor(@ColorInt color: Int): ThemeStore { + mEditor.putInt(ThemeStorePrefKeys.KEY_ACCENT_COLOR, color) + return this + } + + override fun accentColorRes(@ColorRes colorRes: Int): ThemeStore { + return accentColor(ContextCompat.getColor(mContext, colorRes)) + } + + override fun accentColorAttr(@AttrRes colorAttr: Int): ThemeStore { + return accentColor(ATHUtils.resolveColor(mContext, colorAttr)) + } + + override fun statusBarColor(@ColorInt color: Int): ThemeStore { + mEditor.putInt(ThemeStorePrefKeys.KEY_STATUS_BAR_COLOR, color) + return this + } + + override fun statusBarColorRes(@ColorRes colorRes: Int): ThemeStore { + return statusBarColor(ContextCompat.getColor(mContext, colorRes)) + } + + override fun statusBarColorAttr(@AttrRes colorAttr: Int): ThemeStore { + return statusBarColor(ATHUtils.resolveColor(mContext, colorAttr)) + } + + override fun navigationBarColor(@ColorInt color: Int): ThemeStore { + mEditor.putInt(ThemeStorePrefKeys.KEY_NAVIGATION_BAR_COLOR, color) + return this + } + + override fun navigationBarColorRes(@ColorRes colorRes: Int): ThemeStore { + return navigationBarColor(ContextCompat.getColor(mContext, colorRes)) + } + + override fun navigationBarColorAttr(@AttrRes colorAttr: Int): ThemeStore { + return navigationBarColor(ATHUtils.resolveColor(mContext, colorAttr)) + } + + override fun textColorPrimary(@ColorInt color: Int): ThemeStore { + mEditor.putInt(ThemeStorePrefKeys.KEY_TEXT_COLOR_PRIMARY, color) + return this + } + + override fun textColorPrimaryRes(@ColorRes colorRes: Int): ThemeStore { + return textColorPrimary(ContextCompat.getColor(mContext, colorRes)) + } + + override fun textColorPrimaryAttr(@AttrRes colorAttr: Int): ThemeStore { + return textColorPrimary(ATHUtils.resolveColor(mContext, colorAttr)) + } + + override fun textColorPrimaryInverse(@ColorInt color: Int): ThemeStore { + mEditor.putInt(ThemeStorePrefKeys.KEY_TEXT_COLOR_PRIMARY_INVERSE, color) + return this + } + + override fun textColorPrimaryInverseRes(@ColorRes colorRes: Int): ThemeStore { + return textColorPrimaryInverse(ContextCompat.getColor(mContext, colorRes)) + } + + override fun textColorPrimaryInverseAttr(@AttrRes colorAttr: Int): ThemeStore { + return textColorPrimaryInverse(ATHUtils.resolveColor(mContext, colorAttr)) + } + + override fun textColorSecondary(@ColorInt color: Int): ThemeStore { + mEditor.putInt(ThemeStorePrefKeys.KEY_TEXT_COLOR_SECONDARY, color) + return this + } + + override fun textColorSecondaryRes(@ColorRes colorRes: Int): ThemeStore { + return textColorSecondary(ContextCompat.getColor(mContext, colorRes)) + } + + override fun textColorSecondaryAttr(@AttrRes colorAttr: Int): ThemeStore { + return textColorSecondary(ATHUtils.resolveColor(mContext, colorAttr)) + } + + override fun textColorSecondaryInverse(@ColorInt color: Int): ThemeStore { + mEditor.putInt(ThemeStorePrefKeys.KEY_TEXT_COLOR_SECONDARY_INVERSE, color) + return this + } + + override fun textColorSecondaryInverseRes(@ColorRes colorRes: Int): ThemeStore { + return textColorSecondaryInverse(ContextCompat.getColor(mContext, colorRes)) + } + + override fun textColorSecondaryInverseAttr(@AttrRes colorAttr: Int): ThemeStore { + return textColorSecondaryInverse(ATHUtils.resolveColor(mContext, colorAttr)) + } + + override fun backgroundColor(color: Int): ThemeStore { + mEditor.putInt(ThemeStorePrefKeys.KEY_BACKGROUND_COLOR, color) + return this + } + + override fun coloredStatusBar(colored: Boolean): ThemeStore { + mEditor.putBoolean(ThemeStorePrefKeys.KEY_APPLY_PRIMARYDARK_STATUSBAR, colored) + return this + } + + override fun coloredNavigationBar(applyToNavBar: Boolean): ThemeStore { + mEditor.putBoolean(ThemeStorePrefKeys.KEY_APPLY_PRIMARY_NAVBAR, applyToNavBar) + return this + } + + override fun autoGeneratePrimaryDark(autoGenerate: Boolean): ThemeStore { + mEditor.putBoolean(ThemeStorePrefKeys.KEY_AUTO_GENERATE_PRIMARYDARK, autoGenerate) + return this + } + + // Commit method + + override fun apply() { + mEditor.putLong(ThemeStorePrefKeys.VALUES_CHANGED, System.currentTimeMillis()) + .putBoolean(ThemeStorePrefKeys.IS_CONFIGURED_KEY, true) + .apply() + } + + companion object { + + fun editTheme(context: Context): ThemeStore { + return ThemeStore(context) + } + + // Static getters + + @CheckResult + internal fun prefs(context: Context): SharedPreferences { + return context.getSharedPreferences(ThemeStorePrefKeys.CONFIG_PREFS_KEY_DEFAULT, Context.MODE_PRIVATE) + } + + fun markChanged(context: Context) { + ThemeStore(context).apply() + } + + @CheckResult + @ColorInt + fun primaryColor(context: Context): Int { + return prefs(context).getInt( + ThemeStorePrefKeys.KEY_PRIMARY_COLOR, + ATHUtils.resolveColor(context, R.attr.colorPrimary, Color.parseColor("#455A64")) + ) + } + + @CheckResult + @ColorInt + fun primaryColorDark(context: Context): Int { + return prefs(context).getInt( + ThemeStorePrefKeys.KEY_PRIMARY_COLOR_DARK, + ATHUtils.resolveColor(context, R.attr.colorPrimaryDark, Color.parseColor("#37474F")) + ) + } + + @CheckResult + @ColorInt + fun accentColor(context: Context): Int { + return prefs(context).getInt( + ThemeStorePrefKeys.KEY_ACCENT_COLOR, + ATHUtils.resolveColor(context, R.attr.colorAccent, Color.parseColor("#263238")) + ) + } + + @CheckResult + @ColorInt + fun statusBarColor(context: Context, transparent: Boolean): Int { + return if (!coloredStatusBar(context)) { + Color.BLACK + } else if (transparent) { + prefs(context).getInt(ThemeStorePrefKeys.KEY_STATUS_BAR_COLOR, primaryColor(context)) + } else { + prefs(context).getInt(ThemeStorePrefKeys.KEY_STATUS_BAR_COLOR, primaryColorDark(context)) + } + } + + @CheckResult + @ColorInt + fun navigationBarColor(context: Context): Int { + return if (!coloredNavigationBar(context)) { + Color.BLACK + } else prefs(context).getInt(ThemeStorePrefKeys.KEY_NAVIGATION_BAR_COLOR, primaryColor(context)) + } + + @CheckResult + @ColorInt + fun textColorPrimary(context: Context): Int { + return prefs(context).getInt( + ThemeStorePrefKeys.KEY_TEXT_COLOR_PRIMARY, + ATHUtils.resolveColor(context, android.R.attr.textColorPrimary) + ) + } + + @CheckResult + @ColorInt + fun textColorPrimaryInverse(context: Context): Int { + return prefs(context).getInt( + ThemeStorePrefKeys.KEY_TEXT_COLOR_PRIMARY_INVERSE, + ATHUtils.resolveColor(context, android.R.attr.textColorPrimaryInverse) + ) + } + + @CheckResult + @ColorInt + fun textColorSecondary(context: Context): Int { + return prefs(context).getInt( + ThemeStorePrefKeys.KEY_TEXT_COLOR_SECONDARY, + ATHUtils.resolveColor(context, android.R.attr.textColorSecondary) + ) + } + + @CheckResult + @ColorInt + fun textColorSecondaryInverse(context: Context): Int { + return prefs(context).getInt( + ThemeStorePrefKeys.KEY_TEXT_COLOR_SECONDARY_INVERSE, + ATHUtils.resolveColor(context, android.R.attr.textColorSecondaryInverse) + ) + } + + @CheckResult + @ColorInt + fun backgroundColor(context: Context): Int { + return prefs(context).getInt( + ThemeStorePrefKeys.KEY_BACKGROUND_COLOR, + ATHUtils.resolveColor(context, android.R.attr.colorBackground) + ) + } + + @CheckResult + fun coloredStatusBar(context: Context): Boolean { + return prefs(context).getBoolean(ThemeStorePrefKeys.KEY_APPLY_PRIMARYDARK_STATUSBAR, true) + } + + @CheckResult + fun coloredNavigationBar(context: Context): Boolean { + return prefs(context).getBoolean(ThemeStorePrefKeys.KEY_APPLY_PRIMARY_NAVBAR, false) + } + + @CheckResult + fun autoGeneratePrimaryDark(context: Context): Boolean { + return prefs(context).getBoolean(ThemeStorePrefKeys.KEY_AUTO_GENERATE_PRIMARYDARK, true) + } + + @CheckResult + fun isConfigured(context: Context): Boolean { + return prefs(context).getBoolean(ThemeStorePrefKeys.IS_CONFIGURED_KEY, false) + } + + @SuppressLint("CommitPrefEdits") + fun isConfigured(context: Context, version: Int): Boolean { + val prefs = prefs(context) + val lastVersion = prefs.getInt(ThemeStorePrefKeys.IS_CONFIGURED_VERSION_KEY, -1) + if (version > lastVersion) { + prefs.edit().putInt(ThemeStorePrefKeys.IS_CONFIGURED_VERSION_KEY, version).apply() + return false + } + return true + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/lib/theme/ThemeStoreInterface.kt b/app/src/main/java/io/legado/app/lib/theme/ThemeStoreInterface.kt new file mode 100644 index 000000000..55584b633 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/ThemeStoreInterface.kt @@ -0,0 +1,92 @@ +package io.legado.app.lib.theme + + +import androidx.annotation.AttrRes +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes + +/** + * @author Aidan Follestad (afollestad), Karim Abou Zeid (kabouzeid) + */ +internal interface ThemeStoreInterface { + + // Primary colors + + fun primaryColor(@ColorInt color: Int): ThemeStore + + fun primaryColorRes(@ColorRes colorRes: Int): ThemeStore + + fun primaryColorAttr(@AttrRes colorAttr: Int): ThemeStore + + fun autoGeneratePrimaryDark(autoGenerate: Boolean): ThemeStore + + fun primaryColorDark(@ColorInt color: Int): ThemeStore + + fun primaryColorDarkRes(@ColorRes colorRes: Int): ThemeStore + + fun primaryColorDarkAttr(@AttrRes colorAttr: Int): ThemeStore + + // Accent colors + + fun accentColor(@ColorInt color: Int): ThemeStore + + fun accentColorRes(@ColorRes colorRes: Int): ThemeStore + + fun accentColorAttr(@AttrRes colorAttr: Int): ThemeStore + + // Status bar color + + fun statusBarColor(@ColorInt color: Int): ThemeStore + + fun statusBarColorRes(@ColorRes colorRes: Int): ThemeStore + + fun statusBarColorAttr(@AttrRes colorAttr: Int): ThemeStore + + // Navigation bar color + + fun navigationBarColor(@ColorInt color: Int): ThemeStore + + fun navigationBarColorRes(@ColorRes colorRes: Int): ThemeStore + + fun navigationBarColorAttr(@AttrRes colorAttr: Int): ThemeStore + + // Primary text color + + fun textColorPrimary(@ColorInt color: Int): ThemeStore + + fun textColorPrimaryRes(@ColorRes colorRes: Int): ThemeStore + + fun textColorPrimaryAttr(@AttrRes colorAttr: Int): ThemeStore + + fun textColorPrimaryInverse(@ColorInt color: Int): ThemeStore + + fun textColorPrimaryInverseRes(@ColorRes colorRes: Int): ThemeStore + + fun textColorPrimaryInverseAttr(@AttrRes colorAttr: Int): ThemeStore + + // Secondary text color + + fun textColorSecondary(@ColorInt color: Int): ThemeStore + + fun textColorSecondaryRes(@ColorRes colorRes: Int): ThemeStore + + fun textColorSecondaryAttr(@AttrRes colorAttr: Int): ThemeStore + + fun textColorSecondaryInverse(@ColorInt color: Int): ThemeStore + + fun textColorSecondaryInverseRes(@ColorRes colorRes: Int): ThemeStore + + fun textColorSecondaryInverseAttr(@AttrRes colorAttr: Int): ThemeStore + + fun backgroundColor(@ColorInt color: Int): ThemeStore + + // Toggle configurations + + fun coloredStatusBar(colored: Boolean): ThemeStore + + fun coloredNavigationBar(applyToNavBar: Boolean): ThemeStore + + // Commit/apply + + fun apply() +} diff --git a/app/src/main/java/io/legado/app/lib/theme/ThemeStorePrefKeys.kt b/app/src/main/java/io/legado/app/lib/theme/ThemeStorePrefKeys.kt new file mode 100644 index 000000000..4fd4540fc --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/ThemeStorePrefKeys.kt @@ -0,0 +1,31 @@ +package io.legado.app.lib.theme + +/** + * @author Aidan Follestad (afollestad), Karim Abou Zeid (kabouzeid) + */ +internal interface ThemeStorePrefKeys { + companion object { + + const val CONFIG_PREFS_KEY_DEFAULT = "app_themes" + const val IS_CONFIGURED_KEY = "is_configured" + const val IS_CONFIGURED_VERSION_KEY = "is_configured_version" + const val VALUES_CHANGED = "values_changed" + + const val KEY_PRIMARY_COLOR = "primary_color" + const val KEY_PRIMARY_COLOR_DARK = "primary_color_dark" + const val KEY_ACCENT_COLOR = "accent_color" + const val KEY_STATUS_BAR_COLOR = "status_bar_color" + const val KEY_NAVIGATION_BAR_COLOR = "navigation_bar_color" + + const val KEY_TEXT_COLOR_PRIMARY = "text_color_primary" + const val KEY_TEXT_COLOR_PRIMARY_INVERSE = "text_color_primary_inverse" + const val KEY_TEXT_COLOR_SECONDARY = "text_color_secondary" + const val KEY_TEXT_COLOR_SECONDARY_INVERSE = "text_color_secondary_inverse" + + const val KEY_BACKGROUND_COLOR = "backgroundColor" + + const val KEY_APPLY_PRIMARYDARK_STATUSBAR = "apply_primarydark_statusbar" + const val KEY_APPLY_PRIMARY_NAVBAR = "apply_primary_navbar" + const val KEY_AUTO_GENERATE_PRIMARYDARK = "auto_generate_primarydark" + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/lib/theme/TintHelper.kt b/app/src/main/java/io/legado/app/lib/theme/TintHelper.kt new file mode 100644 index 000000000..7b3653eb2 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/TintHelper.kt @@ -0,0 +1,473 @@ +package io.legado.app.lib.theme + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.PorterDuff +import android.graphics.drawable.Drawable +import android.graphics.drawable.RippleDrawable +import android.os.Build +import android.view.View +import android.widget.* +import androidx.annotation.CheckResult +import androidx.annotation.ColorInt +import androidx.appcompat.widget.AppCompatEditText +import androidx.appcompat.widget.SearchView +import androidx.appcompat.widget.SwitchCompat +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat +import com.google.android.material.floatingactionbutton.FloatingActionButton +import io.legado.app.R + +/** + * @author afollestad, plusCubed + */ +object TintHelper { + + @SuppressLint("PrivateResource") + @ColorInt + private fun getDefaultRippleColor(context: Context, useDarkRipple: Boolean): Int { + // Light ripple is actually translucent black, and vice versa + return ContextCompat.getColor( + context, if (useDarkRipple) + R.color.ripple_material_light + else + R.color.ripple_material_dark + ) + } + + private fun getDisabledColorStateList(@ColorInt normal: Int, @ColorInt disabled: Int): ColorStateList { + return ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_enabled), + intArrayOf(android.R.attr.state_enabled) + ), intArrayOf(disabled, normal) + ) + } + + fun setTintSelector(view: View, @ColorInt color: Int, darker: Boolean, useDarkTheme: Boolean) { + val isColorLight = ColorUtils.isColorLight(color) + val disabled = ContextCompat.getColor( + view.context, + if (useDarkTheme) R.color.ate_button_disabled_dark else R.color.ate_button_disabled_light + ) + val pressed = ColorUtils.shiftColor(color, if (darker) 0.9f else 1.1f) + val activated = ColorUtils.shiftColor(color, if (darker) 1.1f else 0.9f) + val rippleColor = getDefaultRippleColor(view.context, isColorLight) + val textColor = ContextCompat.getColor( + view.context, + if (isColorLight) R.color.ate_primary_text_light else R.color.ate_primary_text_dark + ) + + val sl: ColorStateList + if (view is Button) { + sl = getDisabledColorStateList(color, disabled) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && view.getBackground() is RippleDrawable) { + val rd = view.getBackground() as RippleDrawable + rd.setColor(ColorStateList.valueOf(rippleColor)) + } + + // Disabled text color state for buttons, may get overridden later by ATE tags + view.setTextColor( + getDisabledColorStateList( + textColor, + ContextCompat.getColor( + view.getContext(), + if (useDarkTheme) R.color.ate_button_text_disabled_dark else R.color.ate_button_text_disabled_light + ) + ) + ) + } else if (view is FloatingActionButton) { + // FloatingActionButton doesn't support disabled state? + sl = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_pressed), + intArrayOf(android.R.attr.state_pressed) + ), intArrayOf(color, pressed) + ) + + view.rippleColor = rippleColor + view.backgroundTintList = sl + if (view.drawable != null) + view.setImageDrawable(createTintedDrawable(view.drawable, textColor)) + return + } else { + sl = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_enabled), + intArrayOf(android.R.attr.state_enabled), + intArrayOf(android.R.attr.state_enabled, android.R.attr.state_pressed), + intArrayOf(android.R.attr.state_enabled, android.R.attr.state_activated), + intArrayOf(android.R.attr.state_enabled, android.R.attr.state_checked) + ), + intArrayOf(disabled, color, pressed, activated, activated) + ) + } + + var drawable: Drawable? = view.background + if (drawable != null) { + drawable = createTintedDrawable(drawable, sl) + ViewUtils.setBackgroundCompat(view, drawable) + } + + if (view is TextView && view !is Button) { + view.setTextColor( + getDisabledColorStateList( + textColor, + ContextCompat.getColor( + view.getContext(), + if (isColorLight) R.color.ate_text_disabled_light else R.color.ate_text_disabled_dark + ) + ) + ) + } + } + + fun setTintAuto( + view: View, @ColorInt color: Int, + isBackground: Boolean, isDark: Boolean + ) { + var isBg = isBackground + if (!isBg) { + when (view) { + is RadioButton -> setTint(view, color, isDark) + is SeekBar -> setTint(view, color, isDark) + is ProgressBar -> setTint(view, color) + is AppCompatEditText -> setTint(view, color, isDark) + is CheckBox -> setTint(view, color, isDark) + is ImageView -> setTint(view, color) + is Switch -> setTint(view, color, isDark) + is SwitchCompat -> setTint(view, color, isDark) + is SearchView -> { + val iconIdS = + intArrayOf( + androidx.appcompat.R.id.search_button, + androidx.appcompat.R.id.search_close_btn, + androidx.appcompat.R.id.search_go_btn, + androidx.appcompat.R.id.search_voice_btn, + androidx.appcompat.R.id.search_mag_icon + ) + for (iconId in iconIdS) { + val icon = view.findViewById(iconId) + if (icon != null) { + setTint(icon, color) + } + } + } + else -> isBg = true + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && + !isBg && view.background is RippleDrawable + ) { + // Ripples for the above views (e.g. when you tap and hold a switch or checkbox) + val rd = view.background as RippleDrawable + @SuppressLint("PrivateResource") val unchecked = ContextCompat.getColor( + view.context, + if (isDark) R.color.ripple_material_dark else R.color.ripple_material_light + ) + val checked = ColorUtils.adjustAlpha(color, 0.4f) + val sl = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_activated, -android.R.attr.state_checked), + intArrayOf(android.R.attr.state_activated), + intArrayOf(android.R.attr.state_checked) + ), + intArrayOf(unchecked, checked, checked) + ) + rd.setColor(sl) + } + } + if (isBg) { + // Need to tint the isBackground of a view + if (view is FloatingActionButton || view is Button) { + setTintSelector(view, color, false, isDark) + } else if (view.background != null) { + var drawable: Drawable? = view.background + if (drawable != null) { + drawable = createTintedDrawable(drawable, color) + ViewUtils.setBackgroundCompat(view, drawable) + } + } + } + } + + fun setTint(radioButton: RadioButton, @ColorInt color: Int, useDarker: Boolean) { + val sl = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_enabled), + intArrayOf(android.R.attr.state_enabled, -android.R.attr.state_checked), + intArrayOf(android.R.attr.state_enabled, android.R.attr.state_checked) + ), intArrayOf( + // Radio button includes own alpha for disabled state + ColorUtils.stripAlpha( + ContextCompat.getColor( + radioButton.context, + if (useDarker) R.color.ate_control_disabled_dark else R.color.ate_control_disabled_light + ) + ), + ContextCompat.getColor( + radioButton.context, + if (useDarker) R.color.ate_control_normal_dark else R.color.ate_control_normal_light + ), + color + ) + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + radioButton.buttonTintList = sl + } else { + radioButton.buttonDrawable = createTintedDrawable( + ContextCompat.getDrawable(radioButton.context, R.drawable.abc_btn_radio_material), + sl + ) + } + } + + fun setTint(seekBar: SeekBar, @ColorInt color: Int, useDarker: Boolean) { + val s1 = getDisabledColorStateList( + color, + ContextCompat.getColor( + seekBar.context, + if (useDarker) R.color.ate_control_disabled_dark else R.color.ate_control_disabled_light + ) + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + seekBar.thumbTintList = s1 + seekBar.progressTintList = s1 + } else { + val progressDrawable = createTintedDrawable(seekBar.progressDrawable, s1) + seekBar.progressDrawable = progressDrawable + val thumbDrawable = createTintedDrawable(seekBar.thumb, s1) + seekBar.thumb = thumbDrawable + } + } + + @JvmOverloads + fun setTint( + progressBar: ProgressBar, @ColorInt color: Int, + skipIndeterminate: Boolean = false + ) { + val sl = ColorStateList.valueOf(color) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + progressBar.progressTintList = sl + progressBar.secondaryProgressTintList = sl + if (!skipIndeterminate) + progressBar.indeterminateTintList = sl + } else { + val mode = PorterDuff.Mode.SRC_IN + if (!skipIndeterminate && progressBar.indeterminateDrawable != null) + progressBar.indeterminateDrawable.setColorFilter(color, mode) + if (progressBar.progressDrawable != null) + progressBar.progressDrawable.setColorFilter(color, mode) + } + } + + + @SuppressLint("RestrictedApi") + fun setTint(editText: AppCompatEditText, @ColorInt color: Int, useDarker: Boolean) { + val editTextColorStateList = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_enabled), + intArrayOf( + android.R.attr.state_enabled, + -android.R.attr.state_pressed, + -android.R.attr.state_focused + ), + intArrayOf() + ), + intArrayOf( + ContextCompat.getColor( + editText.context, + if (useDarker) R.color.ate_text_disabled_dark else R.color.ate_text_disabled_light + ), + ContextCompat.getColor( + editText.context, + if (useDarker) R.color.ate_control_normal_dark else R.color.ate_control_normal_light + ), + color + ) + ) + editText.supportBackgroundTintList = editTextColorStateList + setCursorTint(editText, color) + } + + fun setTint(box: CheckBox, @ColorInt color: Int, useDarker: Boolean) { + val sl = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_enabled), + intArrayOf(android.R.attr.state_enabled, -android.R.attr.state_checked), + intArrayOf(android.R.attr.state_enabled, android.R.attr.state_checked) + ), + intArrayOf( + ContextCompat.getColor( + box.context, + if (useDarker) R.color.ate_control_disabled_dark else R.color.ate_control_disabled_light + ), + ContextCompat.getColor( + box.context, + if (useDarker) R.color.ate_control_normal_dark else R.color.ate_control_normal_light + ), + color + ) + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + box.buttonTintList = sl + } else { + val drawable = + createTintedDrawable( + ContextCompat.getDrawable( + box.context, + R.drawable.abc_btn_check_material + ), sl + ) + box.buttonDrawable = drawable + } + } + + fun setTint(image: ImageView, @ColorInt color: Int) { + image.setColorFilter(color, PorterDuff.Mode.SRC_ATOP) + } + + private fun modifySwitchDrawable( + context: Context, + from: Drawable, + @ColorInt tint: Int, + thumb: Boolean, + compatSwitch: Boolean, + useDarker: Boolean + ): Drawable? { + var tint1 = tint + if (useDarker) { + tint1 = ColorUtils.shiftColor(tint1, 1.1f) + } + tint1 = ColorUtils.adjustAlpha(tint1, if (compatSwitch && !thumb) 0.5f else 1.0f) + val disabled: Int + var normal: Int + if (thumb) { + disabled = ContextCompat.getColor( + context, + if (useDarker) R.color.ate_switch_thumb_disabled_dark else R.color.ate_switch_thumb_disabled_light + ) + normal = ContextCompat.getColor( + context, + if (useDarker) R.color.ate_switch_thumb_normal_dark else R.color.ate_switch_thumb_normal_light + ) + } else { + disabled = ContextCompat.getColor( + context, + if (useDarker) R.color.ate_switch_track_disabled_dark else R.color.ate_switch_track_disabled_light + ) + normal = ContextCompat.getColor( + context, + if (useDarker) R.color.ate_switch_track_normal_dark else R.color.ate_switch_track_normal_light + ) + } + + // Stock switch includes its own alpha + if (!compatSwitch) { + normal = ColorUtils.stripAlpha(normal) + } + + val sl = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_enabled), + intArrayOf( + android.R.attr.state_enabled, + -android.R.attr.state_activated, + -android.R.attr.state_checked + ), + intArrayOf(android.R.attr.state_enabled, android.R.attr.state_activated), + intArrayOf(android.R.attr.state_enabled, android.R.attr.state_checked) + ), + intArrayOf(disabled, normal, tint1, tint1) + ) + return createTintedDrawable(from, sl) + } + + fun setTint(switchView: Switch, @ColorInt color: Int, useDarker: Boolean) { + if (switchView.trackDrawable != null) { + switchView.trackDrawable = modifySwitchDrawable( + switchView.context, + switchView.trackDrawable, + color, + thumb = false, + compatSwitch = false, + useDarker = useDarker + ) + } + if (switchView.thumbDrawable != null) { + switchView.thumbDrawable = modifySwitchDrawable( + switchView.context, + switchView.thumbDrawable, + color, + thumb = true, + compatSwitch = false, + useDarker = useDarker + ) + } + } + + fun setTint(switchView: SwitchCompat, @ColorInt color: Int, useDarker: Boolean) { + if (switchView.trackDrawable != null) { + switchView.trackDrawable = modifySwitchDrawable( + switchView.context, + switchView.trackDrawable, + color, + thumb = false, + compatSwitch = true, + useDarker = useDarker + ) + } + if (switchView.thumbDrawable != null) { + switchView.thumbDrawable = modifySwitchDrawable( + switchView.context, + switchView.thumbDrawable, + color, + thumb = true, + compatSwitch = true, + useDarker = useDarker + ) + } + } + + // This returns a NEW Drawable because of the mutate() call. The mutate() call is necessary because Drawables with the same resource have shared states otherwise. + @CheckResult + fun createTintedDrawable(drawable: Drawable?, @ColorInt color: Int): Drawable? { + var drawable1: Drawable? = drawable ?: return null + drawable1 = DrawableCompat.wrap(drawable1!!.mutate()) + DrawableCompat.setTintMode(drawable1!!, PorterDuff.Mode.SRC_IN) + DrawableCompat.setTint(drawable1, color) + return drawable1 + } + + // This returns a NEW Drawable because of the mutate() call. The mutate() call is necessary because Drawables with the same resource have shared states otherwise. + @CheckResult + fun createTintedDrawable(drawable: Drawable?, sl: ColorStateList): Drawable? { + var drawable1: Drawable? = drawable ?: return null + drawable1 = DrawableCompat.wrap(drawable1!!.mutate()) + DrawableCompat.setTintList(drawable1!!, sl) + return drawable1 + } + + fun setCursorTint(editText: EditText, @ColorInt color: Int) { + try { + val fCursorDrawableRes = TextView::class.java.getDeclaredField("mCursorDrawableRes") + fCursorDrawableRes.isAccessible = true + val mCursorDrawableRes = fCursorDrawableRes.getInt(editText) + val fEditor = TextView::class.java.getDeclaredField("mEditor") + fEditor.isAccessible = true + val editor = fEditor.get(editText) + val clazz = editor.javaClass + val fCursorDrawable = clazz.getDeclaredField("mCursorDrawable") + fCursorDrawable.isAccessible = true + val drawables = arrayOfNulls(2) + drawables[0] = ContextCompat.getDrawable(editText.context, mCursorDrawableRes) + drawables[0] = createTintedDrawable(drawables[0], color) + drawables[1] = ContextCompat.getDrawable(editText.context, mCursorDrawableRes) + drawables[1] = createTintedDrawable(drawables[1], color) + fCursorDrawable.set(editor, drawables) + } catch (ignored: Exception) { + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/lib/theme/ViewUtils.kt b/app/src/main/java/io/legado/app/lib/theme/ViewUtils.kt new file mode 100644 index 000000000..f3afa243c --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/ViewUtils.kt @@ -0,0 +1,41 @@ +package io.legado.app.lib.theme + +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.TransitionDrawable +import android.view.View +import android.view.ViewTreeObserver +import androidx.annotation.ColorInt + +/** + * @author Karim Abou Zeid (kabouzeid) + */ +object ViewUtils { + + fun removeOnGlobalLayoutListener(v: View, listener: ViewTreeObserver.OnGlobalLayoutListener) { + v.viewTreeObserver.removeOnGlobalLayoutListener(listener) + } + + fun setBackgroundCompat(view: View, drawable: Drawable?) { + view.background = drawable + } + + fun setBackgroundTransition(view: View, newDrawable: Drawable): TransitionDrawable { + val transition = DrawableUtils.createTransitionDrawable(view.background, newDrawable) + setBackgroundCompat(view, transition) + return transition + } + + fun setBackgroundColorTransition(view: View, @ColorInt newColor: Int): TransitionDrawable { + val oldColor = view.background + + val start = oldColor ?: ColorDrawable(view.solidColor) + val end = ColorDrawable(newColor) + + val transition = DrawableUtils.createTransitionDrawable(start, end) + + setBackgroundCompat(view, transition) + + return transition + } +} diff --git a/app/src/main/java/io/legado/app/lib/theme/prefs/ATEColorPreference.kt b/app/src/main/java/io/legado/app/lib/theme/prefs/ATEColorPreference.kt new file mode 100644 index 000000000..517ba8d68 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/prefs/ATEColorPreference.kt @@ -0,0 +1,432 @@ +package io.legado.app.lib.theme.prefs + +import android.content.Context +import android.content.ContextWrapper +import android.content.res.TypedArray +import android.graphics.Color +import android.os.Bundle +import android.util.AttributeSet +import androidx.annotation.ColorInt +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.FragmentActivity +import androidx.preference.Preference +import androidx.preference.PreferenceViewHolder +import com.jaredrummler.android.colorpicker.* +import io.legado.app.lib.theme.ATH + +class ATEColorPreference(context: Context, attrs: AttributeSet) : Preference(context, attrs), + ColorPickerDialogListener { + + private val SIZE_NORMAL = 0 + private val SIZE_LARGE = 1 + + private var onShowDialogListener: OnShowDialogListener? = null + private var color = Color.BLACK + private var showDialog: Boolean = false + @ColorPickerDialog.DialogType + private var dialogType: Int = 0 + private var colorShape: Int = 0 + private var allowPresets: Boolean = false + private var allowCustom: Boolean = false + private var showAlphaSlider: Boolean = false + private var showColorShades: Boolean = false + private var previewSize: Int = 0 + private var presets: IntArray? = null + private var dialogTitle: Int = 0 + + init { + isPersistent = true + val a = context.obtainStyledAttributes(attrs, R.styleable.ColorPreference) + showDialog = a.getBoolean(R.styleable.ColorPreference_cpv_showDialog, true) + + dialogType = a.getInt(R.styleable.ColorPreference_cpv_dialogType, ColorPickerDialog.TYPE_PRESETS) + colorShape = a.getInt(R.styleable.ColorPreference_cpv_colorShape, ColorShape.CIRCLE) + allowPresets = a.getBoolean(R.styleable.ColorPreference_cpv_allowPresets, true) + allowCustom = a.getBoolean(R.styleable.ColorPreference_cpv_allowCustom, true) + showAlphaSlider = a.getBoolean(R.styleable.ColorPreference_cpv_showAlphaSlider, false) + showColorShades = a.getBoolean(R.styleable.ColorPreference_cpv_showColorShades, true) + previewSize = a.getInt(R.styleable.ColorPreference_cpv_previewSize, SIZE_NORMAL) + val presetsResId = a.getResourceId(R.styleable.ColorPreference_cpv_colorPresets, 0) + dialogTitle = a.getResourceId(R.styleable.ColorPreference_cpv_dialogTitle, R.string.cpv_default_title) + if (presetsResId != 0) { + presets = context.resources.getIntArray(presetsResId) + } else { + presets = ColorPickerDialog.MATERIAL_COLORS + } + if (colorShape == ColorShape.CIRCLE) { + widgetLayoutResource = + if (previewSize == SIZE_LARGE) R.layout.cpv_preference_circle_large else R.layout.cpv_preference_circle + } else { + widgetLayoutResource = + if (previewSize == SIZE_LARGE) R.layout.cpv_preference_square_large else R.layout.cpv_preference_square + } + a.recycle() + } + + override fun onClick() { + super.onClick() + if (onShowDialogListener != null) { + onShowDialogListener!!.onShowColorPickerDialog(title as String, color) + } else if (showDialog) { + val dialog = ColorPickerDialogCompat.newBuilder() + .setDialogType(dialogType) + .setDialogTitle(dialogTitle) + .setColorShape(colorShape) + .setPresets(presets!!) + .setAllowPresets(allowPresets) + .setAllowCustom(allowCustom) + .setShowAlphaSlider(showAlphaSlider) + .setShowColorShades(showColorShades) + .setColor(color) + .create() + dialog.setColorPickerDialogListener(this) + getActivity().supportFragmentManager + .beginTransaction() + .add(dialog, getFragmentTag()) + .commitAllowingStateLoss() + } + } + + fun getActivity(): FragmentActivity { + val context = context + if (context is FragmentActivity) { + return context + } else if (context is ContextWrapper) { + val baseContext = context.baseContext + if (baseContext is FragmentActivity) { + return baseContext + } + } + throw IllegalStateException("Error getting activity from context") + } + + override fun onAttached() { + super.onAttached() + if (showDialog) { + val fragment = + getActivity().supportFragmentManager.findFragmentByTag(getFragmentTag()) as ColorPickerDialog? + fragment?.setColorPickerDialogListener(this) + } + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + val preview = holder.itemView.findViewById(R.id.cpv_preference_preview_color_panel) as ColorPanelView + preview.color = color + } + + override fun onSetInitialValue(defaultValue: Any?) { + super.onSetInitialValue(defaultValue) + if (defaultValue is Int) { + color = (defaultValue as Int?)!! + persistInt(color) + } else { + color = getPersistedInt(-0x1000000) + } + } + + override fun onGetDefaultValue(a: TypedArray?, index: Int): Any { + return a!!.getInteger(index, Color.BLACK) + } + + override fun onColorSelected(dialogId: Int, @ColorInt color: Int) { + saveValue(color) + } + + override fun onDialogDismissed(dialogId: Int) { + // no-op + } + + /** + * Set the new color + * + * @param color The newly selected color + */ + fun saveValue(@ColorInt color: Int) { + this.color = color + persistInt(this.color) + notifyChanged() + callChangeListener(color) + } + + /** + * Get the colors that will be shown in the [ColorPickerDialog]. + * + * @return An array of color ints + */ + fun getPresets(): IntArray? { + return presets + } + + /** + * Set the colors shown in the [ColorPickerDialog]. + * + * @param presets An array of color ints + */ + fun setPresets(presets: IntArray) { + this.presets = presets + } + + /** + * The listener used for showing the [ColorPickerDialog]. + * Call [.saveValue] after the user chooses a color. + * If this is set then it is up to you to show the dialog. + * + * @param listener The listener to show the dialog + */ + fun setOnShowDialogListener(listener: OnShowDialogListener) { + onShowDialogListener = listener + } + + /** + * The tag used for the [ColorPickerDialog]. + * + * @return The tag + */ + fun getFragmentTag(): String { + return "color_$key" + } + + interface OnShowDialogListener { + + fun onShowColorPickerDialog(title: String, currentColor: Int) + } + + + internal class ColorPickerDialogCompat : ColorPickerDialog() { + + override fun onStart() { + super.onStart() + val alertDialog = dialog as? AlertDialog + alertDialog?.let { + ATH.setAlertDialogTint(it) + } + } + + + companion object { + fun newBuilder(): Builder { + return Builder() + } + + private const val ARG_ID = "id" + private const val ARG_TYPE = "dialogType" + private const val ARG_COLOR = "color" + private const val ARG_ALPHA = "alpha" + private const val ARG_PRESETS = "presets" + private const val ARG_ALLOW_PRESETS = "allowPresets" + private const val ARG_ALLOW_CUSTOM = "allowCustom" + private const val ARG_DIALOG_TITLE = "dialogTitle" + private const val ARG_SHOW_COLOR_SHADES = "showColorShades" + private const val ARG_COLOR_SHAPE = "colorShape" + private const val ARG_PRESETS_BUTTON_TEXT = "presetsButtonText" + private const val ARG_CUSTOM_BUTTON_TEXT = "customButtonText" + private const val ARG_SELECTED_BUTTON_TEXT = "selectedButtonText" + } + + class Builder internal constructor() { + + internal var colorPickerDialogListener: ColorPickerDialogListener? = null + @StringRes + internal var dialogTitle = R.string.cpv_default_title + @StringRes + internal var presetsButtonText = R.string.cpv_presets + @StringRes + internal var customButtonText = R.string.cpv_custom + @StringRes + internal var selectedButtonText = R.string.cpv_select + @DialogType + internal var dialogType = TYPE_PRESETS + internal var presets = MATERIAL_COLORS + @ColorInt + internal var color = Color.BLACK + internal var dialogId = 0 + internal var showAlphaSlider = false + internal var allowPresets = true + internal var allowCustom = true + internal var showColorShades = true + @ColorShape + internal var colorShape = ColorShape.CIRCLE + + /** + * Set the dialog title string resource id + * + * @param dialogTitle The string resource used for the dialog title + * @return This builder object for chaining method calls + */ + fun setDialogTitle(@StringRes dialogTitle: Int): Builder { + this.dialogTitle = dialogTitle + return this + } + + /** + * Set the selected button text string resource id + * + * @param selectedButtonText The string resource used for the selected button text + * @return This builder object for chaining method calls + */ + fun setSelectedButtonText(@StringRes selectedButtonText: Int): Builder { + this.selectedButtonText = selectedButtonText + return this + } + + /** + * Set the presets button text string resource id + * + * @param presetsButtonText The string resource used for the presets button text + * @return This builder object for chaining method calls + */ + fun setPresetsButtonText(@StringRes presetsButtonText: Int): Builder { + this.presetsButtonText = presetsButtonText + return this + } + + /** + * Set the custom button text string resource id + * + * @param customButtonText The string resource used for the custom button text + * @return This builder object for chaining method calls + */ + fun setCustomButtonText(@StringRes customButtonText: Int): Builder { + this.customButtonText = customButtonText + return this + } + + /** + * Set which dialog view to show. + * + * @param dialogType Either [ColorPickerDialog.TYPE_CUSTOM] or [ColorPickerDialog.TYPE_PRESETS]. + * @return This builder object for chaining method calls + */ + fun setDialogType(@DialogType dialogType: Int): Builder { + this.dialogType = dialogType + return this + } + + /** + * Set the colors used for the presets + * + * @param presets An array of color ints. + * @return This builder object for chaining method calls + */ + fun setPresets(presets: IntArray): Builder { + this.presets = presets + return this + } + + /** + * Set the original color + * + * @param color The default color for the color picker + * @return This builder object for chaining method calls + */ + fun setColor(color: Int): Builder { + this.color = color + return this + } + + /** + * Set the dialog id used for callbacks + * + * @param dialogId The id that is sent back to the [ColorPickerDialogListener]. + * @return This builder object for chaining method calls + */ + fun setDialogId(dialogId: Int): Builder { + this.dialogId = dialogId + return this + } + + /** + * Show the alpha slider + * + * @param showAlphaSlider `true` to show the alpha slider. Currently only supported with the [ ]. + * @return This builder object for chaining method calls + */ + fun setShowAlphaSlider(showAlphaSlider: Boolean): Builder { + this.showAlphaSlider = showAlphaSlider + return this + } + + /** + * Show/Hide a neutral button to select preset colors. + * + * @param allowPresets `false` to disable showing the presets button. + * @return This builder object for chaining method calls + */ + fun setAllowPresets(allowPresets: Boolean): Builder { + this.allowPresets = allowPresets + return this + } + + /** + * Show/Hide the neutral button to select a custom color. + * + * @param allowCustom `false` to disable showing the custom button. + * @return This builder object for chaining method calls + */ + fun setAllowCustom(allowCustom: Boolean): Builder { + this.allowCustom = allowCustom + return this + } + + /** + * Show/Hide the color shades in the presets picker + * + * @param showColorShades `false` to hide the color shades. + * @return This builder object for chaining method calls + */ + fun setShowColorShades(showColorShades: Boolean): Builder { + this.showColorShades = showColorShades + return this + } + + /** + * Set the shape of the color panel view. + * + * @param colorShape Either [ColorShape.CIRCLE] or [ColorShape.SQUARE]. + * @return This builder object for chaining method calls + */ + fun setColorShape(colorShape: Int): Builder { + this.colorShape = colorShape + return this + } + + /** + * Create the [ColorPickerDialog] instance. + * + * @return A new [ColorPickerDialog]. + * @see .show + */ + fun create(): ColorPickerDialog { + val dialog = ColorPickerDialogCompat() + val args = Bundle() + args.putInt(ARG_ID, dialogId) + args.putInt(ARG_TYPE, dialogType) + args.putInt(ARG_COLOR, color) + args.putIntArray(ARG_PRESETS, presets) + args.putBoolean(ARG_ALPHA, showAlphaSlider) + args.putBoolean(ARG_ALLOW_CUSTOM, allowCustom) + args.putBoolean(ARG_ALLOW_PRESETS, allowPresets) + args.putInt(ARG_DIALOG_TITLE, dialogTitle) + args.putBoolean(ARG_SHOW_COLOR_SHADES, showColorShades) + args.putInt(ARG_COLOR_SHAPE, colorShape) + args.putInt(ARG_PRESETS_BUTTON_TEXT, presetsButtonText) + args.putInt(ARG_CUSTOM_BUTTON_TEXT, customButtonText) + args.putInt(ARG_SELECTED_BUTTON_TEXT, selectedButtonText) + dialog.arguments = args + return dialog + } + + /** + * Create and show the [ColorPickerDialog] created with this builder. + * + * @param activity The current activity. + */ + fun show(activity: FragmentActivity) { + create().show(activity.supportFragmentManager, "color-picker-dialog") + } + } + + } +} diff --git a/app/src/main/java/io/legado/app/lib/theme/prefs/ATEPreferenceCategory.kt b/app/src/main/java/io/legado/app/lib/theme/prefs/ATEPreferenceCategory.kt new file mode 100644 index 000000000..16f9e168f --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/prefs/ATEPreferenceCategory.kt @@ -0,0 +1,24 @@ +package io.legado.app.lib.theme.prefs + +import android.content.Context +import android.util.AttributeSet +import android.widget.TextView +import androidx.preference.PreferenceCategory +import androidx.preference.PreferenceViewHolder +import io.legado.app.lib.theme.ThemeStore + + +class ATEPreferenceCategory(context: Context, attrs: AttributeSet) : + PreferenceCategory(context, attrs) { + + override fun onBindViewHolder(holder: PreferenceViewHolder?) { + super.onBindViewHolder(holder) + holder?.let { + val view = it.findViewById(android.R.id.title) + if (view is TextView) { + view.setTextColor(ThemeStore.accentColor(view.getContext()))//设置title文本的颜色 + } + } + } + +} diff --git a/app/src/main/java/io/legado/app/lib/theme/prefs/ATESwitchPreference.kt b/app/src/main/java/io/legado/app/lib/theme/prefs/ATESwitchPreference.kt new file mode 100644 index 000000000..998865a7f --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/prefs/ATESwitchPreference.kt @@ -0,0 +1,25 @@ +package io.legado.app.lib.theme.prefs + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.SwitchCompat +import androidx.preference.PreferenceViewHolder +import androidx.preference.SwitchPreferenceCompat +import io.legado.app.R +import io.legado.app.lib.theme.ATH +import io.legado.app.lib.theme.ThemeStore + +class ATESwitchPreference(context: Context, attrs: AttributeSet) : + SwitchPreferenceCompat(context, attrs) { + + override fun onBindViewHolder(holder: PreferenceViewHolder?) { + super.onBindViewHolder(holder) + holder?.let { + val view = it.findViewById(R.id.switchWidget) + if (view is SwitchCompat) { + ATH.setTint(view, ThemeStore.accentColor(view.getContext())) + } + } + } + +} diff --git a/app/src/main/java/io/legado/app/lib/theme/view/ATEAccentBgTextView.kt b/app/src/main/java/io/legado/app/lib/theme/view/ATEAccentBgTextView.kt new file mode 100644 index 000000000..fe1f0b0ea --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/view/ATEAccentBgTextView.kt @@ -0,0 +1,27 @@ +package io.legado.app.lib.theme.view + +import android.content.Context +import android.graphics.Color +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatTextView +import io.legado.app.R +import io.legado.app.lib.theme.ColorUtils +import io.legado.app.lib.theme.Selector +import io.legado.app.lib.theme.ThemeStore + +class ATEAccentBgTextView(context: Context, attrs: AttributeSet) : + AppCompatTextView(context, attrs) { + + init { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.ATEAccentBgTextView) + val radios = + typedArray.getDimensionPixelOffset(R.styleable.ATEAccentBgTextView_abt_radius, 0) + typedArray.recycle() + background = Selector.shapeBuild() + .setCornerRadius(radios) + .setDefaultBgColor(ThemeStore.accentColor(context)) + .setPressedBgColor(ColorUtils.darkenColor(ThemeStore.accentColor(context))) + .create() + setTextColor(Color.WHITE) + } +} diff --git a/app/src/main/java/io/legado/app/lib/theme/view/ATEAccentStrokeTextView.kt b/app/src/main/java/io/legado/app/lib/theme/view/ATEAccentStrokeTextView.kt new file mode 100644 index 000000000..355ed65a6 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/view/ATEAccentStrokeTextView.kt @@ -0,0 +1,30 @@ +package io.legado.app.lib.theme.view + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatTextView +import io.legado.app.R +import io.legado.app.lib.theme.Selector +import io.legado.app.lib.theme.ThemeStore +import io.legado.app.utils.dp +import io.legado.app.utils.getCompatColor + +class ATEAccentStrokeTextView(context: Context, attrs: AttributeSet) : + AppCompatTextView(context, attrs) { + + init { + background = Selector.shapeBuild() + .setCornerRadius(3.dp) + .setStrokeWidth(1.dp) + .setDisabledStrokeColor(context.getCompatColor(R.color.md_grey_500)) + .setDefaultStrokeColor(ThemeStore.accentColor(context)) + .setPressedBgColor(context.getCompatColor(R.color.transparent30)) + .create() + setTextColor( + Selector.colorBuild() + .setDefaultColor(ThemeStore.accentColor(context)) + .setDisabledColor(context.getCompatColor(R.color.md_grey_500)) + .create() + ) + } +} diff --git a/app/src/main/java/io/legado/app/lib/theme/view/ATEAutoCompleteTextView.kt b/app/src/main/java/io/legado/app/lib/theme/view/ATEAutoCompleteTextView.kt new file mode 100644 index 000000000..ae7dec6e0 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/view/ATEAutoCompleteTextView.kt @@ -0,0 +1,76 @@ +package io.legado.app.lib.theme.view + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import androidx.appcompat.widget.AppCompatAutoCompleteTextView +import io.legado.app.R +import io.legado.app.lib.theme.ATH +import io.legado.app.utils.gone +import io.legado.app.utils.visible +import kotlinx.android.synthetic.main.item_1line_text_and_del.view.* +import org.jetbrains.anko.sdk27.listeners.onClick + + +class ATEAutoCompleteTextView : AppCompatAutoCompleteTextView { + + constructor(context: Context) : super(context) + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + + var delCallBack: ((value: String) -> Unit)? = null + + init { + ATH.applyAccentTint(this) + } + + override fun enoughToFilter(): Boolean { + return true + } + + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent?): Boolean { + if (event?.action == MotionEvent.ACTION_DOWN) { + showDropDown() + } + return super.onTouchEvent(event) + } + + fun setFilterValues(values: List?, delCallBack: ((value: String) -> Unit)? = null) { + this.delCallBack = delCallBack + values?.let { + setAdapter(MyAdapter(context, values)) + } + } + + fun setFilterValues(vararg value: String, delCallBack: ((value: String) -> Unit)? = null) { + this.delCallBack = delCallBack + setAdapter(MyAdapter(context, value.toMutableList())) + } + + inner class MyAdapter(context: Context, values: List) : + ArrayAdapter(context, android.R.layout.simple_dropdown_item_1line, values) { + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = convertView ?: LayoutInflater.from(context) + .inflate(R.layout.item_1line_text_and_del, parent, false) + view.text_view.text = getItem(position) + if (delCallBack != null) view.iv_delete.visible() else view.iv_delete.gone() + view.iv_delete.onClick { + getItem(position)?.let { + remove(it) + delCallBack?.invoke(it) + showDropDown() + } + } + return view + } + } + +} diff --git a/app/src/main/java/io/legado/app/lib/theme/view/ATECheckBox.kt b/app/src/main/java/io/legado/app/lib/theme/view/ATECheckBox.kt new file mode 100644 index 000000000..fa009b01b --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/view/ATECheckBox.kt @@ -0,0 +1,17 @@ +package io.legado.app.lib.theme.view + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatCheckBox +import io.legado.app.lib.theme.ATH +import io.legado.app.lib.theme.accentColor + +/** + * @author Aidan Follestad (afollestad) + */ +class ATECheckBox(context: Context, attrs: AttributeSet) : AppCompatCheckBox(context, attrs) { + + init { + ATH.setTint(this, context.accentColor) + } +} diff --git a/app/src/main/java/io/legado/app/lib/theme/view/ATEEditText.kt b/app/src/main/java/io/legado/app/lib/theme/view/ATEEditText.kt new file mode 100644 index 000000000..c6c692634 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/view/ATEEditText.kt @@ -0,0 +1,17 @@ +package io.legado.app.lib.theme.view + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatEditText +import io.legado.app.lib.theme.ATH +import io.legado.app.lib.theme.ThemeStore + +/** + * @author Aidan Follestad (afollestad) + */ +class ATEEditText(context: Context, attrs: AttributeSet) : AppCompatEditText(context, attrs) { + + init { + ATH.setTint(this, ThemeStore.accentColor(context)) + } +} diff --git a/app/src/main/java/io/legado/app/lib/theme/view/ATEPrimaryTextView.kt b/app/src/main/java/io/legado/app/lib/theme/view/ATEPrimaryTextView.kt new file mode 100644 index 000000000..b28a97305 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/view/ATEPrimaryTextView.kt @@ -0,0 +1,17 @@ +package io.legado.app.lib.theme.view + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatTextView +import io.legado.app.lib.theme.ThemeStore + +/** + * @author Aidan Follestad (afollestad) + */ +class ATEPrimaryTextView(context: Context, attrs: AttributeSet) : + AppCompatTextView(context, attrs) { + + init { + setTextColor(ThemeStore.textColorPrimary(context)) + } +} diff --git a/app/src/main/java/io/legado/app/lib/theme/view/ATEProgressBar.kt b/app/src/main/java/io/legado/app/lib/theme/view/ATEProgressBar.kt new file mode 100644 index 000000000..20f13e03c --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/view/ATEProgressBar.kt @@ -0,0 +1,17 @@ +package io.legado.app.lib.theme.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.ProgressBar +import io.legado.app.lib.theme.ATH +import io.legado.app.lib.theme.ThemeStore + +/** + * @author Aidan Follestad (afollestad) + */ +class ATEProgressBar(context: Context, attrs: AttributeSet) : ProgressBar(context, attrs) { + + init { + ATH.setTint(this, ThemeStore.accentColor(context)) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/lib/theme/view/ATERadioButton.kt b/app/src/main/java/io/legado/app/lib/theme/view/ATERadioButton.kt new file mode 100644 index 000000000..d416d6f8f --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/view/ATERadioButton.kt @@ -0,0 +1,17 @@ +package io.legado.app.lib.theme.view + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatRadioButton +import io.legado.app.lib.theme.ATH +import io.legado.app.lib.theme.accentColor + +/** + * @author Aidan Follestad (afollestad) + */ +class ATERadioButton(context: Context, attrs: AttributeSet) : AppCompatRadioButton(context, attrs) { + + init { + ATH.setTint(this@ATERadioButton, context.accentColor) + } +} diff --git a/app/src/main/java/io/legado/app/lib/theme/view/ATERadioNoButton.kt b/app/src/main/java/io/legado/app/lib/theme/view/ATERadioNoButton.kt new file mode 100644 index 000000000..12e93ec4f --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/view/ATERadioNoButton.kt @@ -0,0 +1,27 @@ +package io.legado.app.lib.theme.view + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatRadioButton +import io.legado.app.R +import io.legado.app.lib.theme.Selector +import io.legado.app.lib.theme.ThemeStore +import io.legado.app.utils.dp +import io.legado.app.utils.getCompatColor + +/** + * @author Aidan Follestad (afollestad) + */ +class ATERadioNoButton(context: Context, attrs: AttributeSet) : + AppCompatRadioButton(context, attrs) { + + init { + background = Selector.shapeBuild() + .setCornerRadius(2.dp) + .setStrokeWidth(2.dp) + .setCheckedBgColor(ThemeStore.accentColor(context)) + .setCheckedStrokeColor(ThemeStore.accentColor(context)) + .setDefaultStrokeColor(context.getCompatColor(R.color.tv_text_default)) + .create() + } +} diff --git a/app/src/main/java/io/legado/app/lib/theme/view/ATESecondaryTextView.kt b/app/src/main/java/io/legado/app/lib/theme/view/ATESecondaryTextView.kt new file mode 100644 index 000000000..8031961d3 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/view/ATESecondaryTextView.kt @@ -0,0 +1,17 @@ +package io.legado.app.lib.theme.view + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatTextView +import io.legado.app.lib.theme.secondaryTextColor + +/** + * @author Aidan Follestad (afollestad) + */ +class ATESecondaryTextView(context: Context, attrs: AttributeSet) : + AppCompatTextView(context, attrs) { + + init { + setTextColor(context.secondaryTextColor) + } +} diff --git a/app/src/main/java/io/legado/app/lib/theme/view/ATESeekBar.kt b/app/src/main/java/io/legado/app/lib/theme/view/ATESeekBar.kt new file mode 100644 index 000000000..2d793ba82 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/view/ATESeekBar.kt @@ -0,0 +1,17 @@ +package io.legado.app.lib.theme.view + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatSeekBar +import io.legado.app.lib.theme.ATH +import io.legado.app.lib.theme.accentColor + +/** + * @author Aidan Follestad (afollestad) + */ +class ATESeekBar(context: Context, attrs: AttributeSet) : AppCompatSeekBar(context, attrs) { + + init { + ATH.setTint(this, context.accentColor) + } +} diff --git a/app/src/main/java/io/legado/app/lib/theme/view/ATEStrokeTextView.kt b/app/src/main/java/io/legado/app/lib/theme/view/ATEStrokeTextView.kt new file mode 100644 index 000000000..d113321d9 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/view/ATEStrokeTextView.kt @@ -0,0 +1,31 @@ +package io.legado.app.lib.theme.view + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatTextView +import io.legado.app.R +import io.legado.app.lib.theme.Selector +import io.legado.app.lib.theme.ThemeStore +import io.legado.app.utils.dp +import io.legado.app.utils.getCompatColor + +class ATEStrokeTextView(context: Context, attrs: AttributeSet) : AppCompatTextView(context, attrs) { + + init { + background = Selector.shapeBuild() + .setCornerRadius(1.dp) + .setStrokeWidth(1.dp) + .setDisabledStrokeColor(context.getCompatColor(R.color.md_grey_500)) + .setDefaultStrokeColor(ThemeStore.textColorSecondary(context)) + .setSelectedStrokeColor(ThemeStore.accentColor(context)) + .setPressedBgColor(context.getCompatColor(R.color.transparent30)) + .create() + setTextColor( + Selector.colorBuild() + .setDefaultColor(ThemeStore.textColorSecondary(context)) + .setSelectedColor(ThemeStore.accentColor(context)) + .setDisabledColor(context.getCompatColor(R.color.md_grey_500)) + .create() + ) + } +} diff --git a/app/src/main/java/io/legado/app/lib/theme/view/ATESwitch.kt b/app/src/main/java/io/legado/app/lib/theme/view/ATESwitch.kt new file mode 100644 index 000000000..ceaec7f21 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/view/ATESwitch.kt @@ -0,0 +1,18 @@ +package io.legado.app.lib.theme.view + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.SwitchCompat +import io.legado.app.lib.theme.ATH +import io.legado.app.lib.theme.accentColor + +/** + * @author Aidan Follestad (afollestad) + */ +class ATESwitch(context: Context, attrs: AttributeSet) : SwitchCompat(context, attrs) { + + init { + ATH.setTint(this, context.accentColor) + } + +} diff --git a/app/src/main/java/io/legado/app/lib/theme/view/ATETextInputLayout.kt b/app/src/main/java/io/legado/app/lib/theme/view/ATETextInputLayout.kt new file mode 100644 index 000000000..5444a309c --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/theme/view/ATETextInputLayout.kt @@ -0,0 +1,15 @@ +package io.legado.app.lib.theme.view + +import android.content.Context +import android.util.AttributeSet +import com.google.android.material.textfield.TextInputLayout +import io.legado.app.lib.theme.Selector +import io.legado.app.lib.theme.ThemeStore + +class ATETextInputLayout(context: Context, attrs: AttributeSet?) : TextInputLayout(context, attrs) { + + init { + defaultHintTextColor = Selector.colorBuild().setDefaultColor(ThemeStore.accentColor(context)).create() + } + +} diff --git a/app/src/main/java/io/legado/app/lib/webdav/README.md b/app/src/main/java/io/legado/app/lib/webdav/README.md new file mode 100644 index 000000000..f6ac57a9d --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/webdav/README.md @@ -0,0 +1 @@ +## 用于网络备份的WebDav \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/lib/webdav/WebDav.kt b/app/src/main/java/io/legado/app/lib/webdav/WebDav.kt new file mode 100644 index 000000000..8cf806786 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/webdav/WebDav.kt @@ -0,0 +1,248 @@ +package io.legado.app.lib.webdav + +import io.legado.app.help.http.HttpHelper +import io.legado.app.lib.webdav.http.Handler +import io.legado.app.lib.webdav.http.HttpAuth +import okhttp3.Credentials +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.Request +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.jsoup.Jsoup +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.UnsupportedEncodingException +import java.net.MalformedURLException +import java.net.URL +import java.net.URLEncoder +import java.util.* + +class WebDav @Throws(MalformedURLException::class) +constructor(urlStr: String) { + companion object { + // 指定返回哪些属性 + private const val DIR = "\n" + + "\n" + + "\n" + + "\n\n\n\n\n%s" + + "\n" + + "" + } + private val url: URL = URL(null, urlStr, Handler) + private val httpUrl: String? by lazy { + val raw = url.toString().replace("davs://", "https://").replace("dav://", "http://") + try { + return@lazy URLEncoder.encode(raw, "UTF-8") + .replace("\\+".toRegex(), "%20") + .replace("%3A".toRegex(), ":") + .replace("%2F".toRegex(), "/") + } catch (e: UnsupportedEncodingException) { + e.printStackTrace() + return@lazy null + } + } + + var displayName: String? = null + var size: Long = 0 + var exists = false + var parent = "" + var urlName = "" + get() { + if (field.isEmpty()) { + this.urlName = ( + if (parent.isEmpty()) url.file + else url.toString().replace(parent, "") + ).replace("/", "") + } + return field + } + + fun getPath() = url.toString() + + fun getHost() = url.host + + /** + * 填充文件信息。实例化WebDAVFile对象时,并没有将远程文件的信息填充到实例中。需要手动填充! + * + * @return 远程文件是否存在 + */ + @Throws(IOException::class) + fun indexFileInfo(): Boolean { + propFindResponse(ArrayList())?.let { response -> + if (!response.isSuccessful) { + this.exists = false + return false + } + response.body?.let { + if (it.string().isNotEmpty()) { + return true + } + } + } + return false + } + + /** + * 列出当前路径下的文件 + * + * @param propsList 指定列出文件的哪些属性 + * @return 文件列表 + */ + @Throws(IOException::class) + @JvmOverloads + fun listFiles(propsList: ArrayList = ArrayList()): List { + propFindResponse(propsList)?.let { response -> + if (response.isSuccessful) { + response.body?.let { body -> + return parseDir(body.string()) + } + } + } + return ArrayList() + } + + @Throws(IOException::class) + private fun propFindResponse(propsList: ArrayList, depth: Int = 1): Response? { + val requestProps = StringBuilder() + for (p in propsList) { + requestProps.append("\n") + } + val requestPropsStr: String + requestPropsStr = if (requestProps.toString().isEmpty()) { + DIR.replace("%s", "") + } else { + String.format(DIR, requestProps.toString() + "\n") + } + httpUrl?.let { url -> + val request = Request.Builder() + .url(url) + // 添加RequestBody对象,可以只返回的属性。如果设为null,则会返回全部属性 + // 注意:尽量手动指定需要返回的属性。若返回全部属性,可能后由于Prop.java里没有该属性名,而崩溃。 + .method("PROPFIND", requestPropsStr.toRequestBody("text/plain".toMediaTypeOrNull())) + + HttpAuth.auth?.let { + request.header( + "Authorization", + Credentials.basic(it.user, it.pass) + ) + } + request.header("Depth", if (depth < 0) "infinity" else depth.toString()) + return HttpHelper.client.newCall(request.build()).execute() + } + return null + } + + private fun parseDir(s: String): List { + val list = ArrayList() + val document = Jsoup.parse(s) + val elements = document.getElementsByTag("d:response") + httpUrl?.let { url -> + val baseUrl = if (url.endsWith("/")) url else "$url/" + for (element in elements) { + val href = element.getElementsByTag("d:href")[0].text() + if (!href.endsWith("/")) { + val fileName = href.substring(href.lastIndexOf("/") + 1) + val webDavFile: WebDav + try { + webDavFile = WebDav(baseUrl + fileName) + webDavFile.displayName = fileName + webDavFile.urlName = href + list.add(webDavFile) + } catch (e: MalformedURLException) { + e.printStackTrace() + } + } + } + } + return list + } + + /** + * 根据自己的URL,在远程处创建对应的文件夹 + * + * @return 是否创建成功 + */ + @Throws(IOException::class) + fun makeAsDir(): Boolean { + httpUrl?.let { url -> + val request = Request.Builder() + .url(url) + .method("MKCOL", null) + return execRequest(request) + } + return false + } + + /** + * 下载到本地 + * + * @param savedPath 本地的完整路径,包括最后的文件名 + * @param replaceExisting 是否替换本地的同名文件 + * @return 下载是否成功 + */ + fun downloadTo(savedPath: String, replaceExisting: Boolean): Boolean { + if (File(savedPath).exists()) { + if (!replaceExisting) return false + } + val inputS = getInputStream() ?: return false + File(savedPath).writeBytes(inputS.readBytes()) + return true + } + + /** + * 上传文件 + */ + @Throws(IOException::class) + @JvmOverloads + fun upload(localPath: String, contentType: String? = null): Boolean { + val file = File(localPath) + if (!file.exists()) return false + val mediaType = contentType?.toMediaTypeOrNull() + // 务必注意RequestBody不要嵌套,不然上传时内容可能会被追加多余的文件信息 + val fileBody = file.asRequestBody(mediaType) + httpUrl?.let { + val request = Request.Builder() + .url(it) + .put(fileBody) + return execRequest(request) + } + return false + } + + /** + * 执行请求,获取响应结果 + * @param requestBuilder 因为还需要追加验证信息,所以此处传递Request.Builder的对象,而不是Request的对象 + * @return 请求执行的结果 + */ + @Throws(IOException::class) + private fun execRequest(requestBuilder: Request.Builder): Boolean { + HttpAuth.auth?.let { + requestBuilder.header( + "Authorization", + Credentials.basic(it.user, it.pass) + ) + } + val response = HttpHelper.client.newCall(requestBuilder.build()).execute() + return response.isSuccessful + } + + private fun getInputStream(): InputStream? { + httpUrl?.let { url -> + val request = Request.Builder().url(url) + HttpAuth.auth?.let { + request.header("Authorization", Credentials.basic(it.user, it.pass)) + } + try { + return HttpHelper.client.newCall(request.build()).execute().body?.byteStream() + } catch (e: IOException) { + e.printStackTrace() + } catch (e: IllegalArgumentException) { + e.printStackTrace() + } + } + return null + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/lib/webdav/http/Handler.kt b/app/src/main/java/io/legado/app/lib/webdav/http/Handler.kt new file mode 100644 index 000000000..c3deec2e2 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/webdav/http/Handler.kt @@ -0,0 +1,16 @@ +package io.legado.app.lib.webdav.http + +import java.net.URL +import java.net.URLConnection +import java.net.URLStreamHandler + +object Handler : URLStreamHandler() { + + override fun getDefaultPort(): Int { + return 80 + } + + public override fun openConnection(u: URL): URLConnection? { + return null + } +} diff --git a/app/src/main/java/io/legado/app/lib/webdav/http/HttpAuth.kt b/app/src/main/java/io/legado/app/lib/webdav/http/HttpAuth.kt new file mode 100644 index 000000000..07cab5855 --- /dev/null +++ b/app/src/main/java/io/legado/app/lib/webdav/http/HttpAuth.kt @@ -0,0 +1,9 @@ +package io.legado.app.lib.webdav.http + +object HttpAuth { + + var auth: Auth? = null + + class Auth internal constructor(val user: String, val pass: String) + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/Rss.kt b/app/src/main/java/io/legado/app/model/Rss.kt new file mode 100644 index 000000000..cd76e24fe --- /dev/null +++ b/app/src/main/java/io/legado/app/model/Rss.kt @@ -0,0 +1,24 @@ +package io.legado.app.model + +import io.legado.app.data.entities.RssArticle +import io.legado.app.data.entities.RssSource +import io.legado.app.help.coroutine.Coroutine +import io.legado.app.model.analyzeRule.AnalyzeUrl +import io.legado.app.model.rss.RssParserByRule +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlin.coroutines.CoroutineContext + +object Rss { + + fun getArticles( + rssSource: RssSource, + scope: CoroutineScope = Coroutine.DEFAULT, + context: CoroutineContext = Dispatchers.IO + ): Coroutine> { + return Coroutine.async(scope, context) { + val response = AnalyzeUrl(rssSource.sourceUrl).getResponseAsync().await() + RssParserByRule.parseXML(response, rssSource) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/WebBook.kt b/app/src/main/java/io/legado/app/model/WebBook.kt new file mode 100644 index 000000000..e290e555c --- /dev/null +++ b/app/src/main/java/io/legado/app/model/WebBook.kt @@ -0,0 +1,146 @@ +package io.legado.app.model + +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.SearchBook +import io.legado.app.help.coroutine.Coroutine +import io.legado.app.model.analyzeRule.AnalyzeUrl +import io.legado.app.model.webbook.BookChapterList +import io.legado.app.model.webbook.BookContent +import io.legado.app.model.webbook.BookInfo +import io.legado.app.model.webbook.BookList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlin.coroutines.CoroutineContext + +class WebBook(val bookSource: BookSource) { + + val sourceUrl: String + get() = bookSource.bookSourceUrl + + /** + * 搜索 + */ + fun searchBook( + key: String, + page: Int? = 1, + scope: CoroutineScope = Coroutine.DEFAULT, + context: CoroutineContext = Dispatchers.IO + ): Coroutine> { + return Coroutine.async(scope, context) { + bookSource.searchUrl?.let { searchUrl -> + val analyzeUrl = AnalyzeUrl( + ruleUrl = searchUrl, + key = key, + page = page, + baseUrl = sourceUrl, + headerMapF = bookSource.getHeaderMap() + ) + val response = analyzeUrl.getResponseAsync().await() + BookList.analyzeBookList(response, bookSource, analyzeUrl, true) + } ?: arrayListOf() + } + } + + /** + * 发现 + */ + fun exploreBook( + url: String, + page: Int? = 1, + scope: CoroutineScope = Coroutine.DEFAULT, + context: CoroutineContext = Dispatchers.IO + ): Coroutine> { + return Coroutine.async(scope, context) { + val analyzeUrl = AnalyzeUrl( + ruleUrl = url, + page = page, + baseUrl = sourceUrl, + headerMapF = bookSource.getHeaderMap() + ) + val response = analyzeUrl.getResponseAsync().await() + BookList.analyzeBookList(response, bookSource, analyzeUrl, false) + } + } + + /** + * 书籍信息 + */ + fun getBookInfo( + book: Book, + scope: CoroutineScope = Coroutine.DEFAULT, + context: CoroutineContext = Dispatchers.IO + ): Coroutine { + return Coroutine.async(scope, context) { + val body = if (book.infoHtml.isNullOrEmpty()) { + val analyzeUrl = AnalyzeUrl( + book = book, + ruleUrl = book.bookUrl, + baseUrl = sourceUrl, + headerMapF = bookSource.getHeaderMap() + ) + analyzeUrl.getResponseAsync().await().body() + } else { + book.infoHtml + } + BookInfo.analyzeBookInfo(book, body, bookSource, book.bookUrl) + book + } + } + + /** + * 目录 + */ + fun getChapterList( + book: Book, + scope: CoroutineScope = Coroutine.DEFAULT, + context: CoroutineContext = Dispatchers.IO + ): Coroutine> { + return Coroutine.async(scope, context) { + val body = if (book.bookUrl == book.tocUrl && !book.tocHtml.isNullOrEmpty()) { + book.tocHtml + } else { + val analyzeUrl = AnalyzeUrl( + book = book, + ruleUrl = book.tocUrl, + baseUrl = book.bookUrl, + headerMapF = bookSource.getHeaderMap() + ) + analyzeUrl.getResponseAsync().await().body() + } + BookChapterList.analyzeChapterList(this, book, body, bookSource, book.tocUrl) + } + } + + /** + * 章节内容 + */ + fun getContent( + book: Book, + bookChapter: BookChapter, + nextChapterUrl: String? = null, + scope: CoroutineScope = Coroutine.DEFAULT, + context: CoroutineContext = Dispatchers.IO + ): Coroutine { + return Coroutine.async(scope, context) { + val analyzeUrl = + AnalyzeUrl( + book = book, + ruleUrl = bookChapter.url, + baseUrl = book.tocUrl, + headerMapF = bookSource.getHeaderMap() + ) + val response = analyzeUrl.getResponseAsync().await() + BookContent.analyzeContent( + this, + response, + book, + bookChapter, + bookSource, + analyzeUrl, + nextChapterUrl + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByJSonPath.kt b/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByJSonPath.kt new file mode 100644 index 000000000..84b5045e7 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByJSonPath.kt @@ -0,0 +1,217 @@ +package io.legado.app.model.analyzeRule + +import android.text.TextUtils +import com.jayway.jsonpath.JsonPath +import com.jayway.jsonpath.ReadContext +import io.legado.app.utils.splitNotBlank +import java.util.* +import java.util.regex.Pattern + +class AnalyzeByJSonPath { + private var ctx: ReadContext? = null + + fun parse(json: Any): AnalyzeByJSonPath { + ctx = if (json is String) { + JsonPath.parse(json) + } else { + JsonPath.parse(json) + } + return this + } + + fun getString(rule: String): String? { + if (TextUtils.isEmpty(rule)) return null + var result = "" + val rules: Array + val elementsType: String + if (rule.contains("&&")) { + rules = rule.splitNotBlank("&&") + elementsType = "&" + } else { + rules = rule.splitNotBlank("||") + elementsType = "|" + } + if (rules.size == 1) { + if (!rule.contains("{$.")) { + ctx?.let { + try { + val ob = it.read(rule) + result = if (ob is List<*>) { + val builder = StringBuilder() + for (o in ob) { + builder.append(o).append("\n") + } + builder.toString().replace("\\n$".toRegex(), "") + } else { + ob.toString() + } + } catch (ignored: Exception) { + } + + } + return result + } else { + result = rule + val matcher = jsonRulePattern.matcher(rule) + while (matcher.find()) { + result = result.replace( + String.format("{%s}", matcher.group()), + getString(matcher.group())!! + ) + } + return result + } + } else { + val textList = arrayListOf() + for (rl in rules) { + val temp = getString(rl) + if (!temp.isNullOrEmpty()) { + textList.add(temp) + if (elementsType == "|") { + break + } + } + } + return TextUtils.join(",", textList) + } + } + + internal fun getStringList(rule: String): List { + val result = ArrayList() + if (TextUtils.isEmpty(rule)) return result + val rules: Array + val elementsType: String + when { + rule.contains("&&") -> { + rules = rule.splitNotBlank("&&") + elementsType = "&" + } + rule.contains("%%") -> { + rules = rule.splitNotBlank("%%") + elementsType = "%" + } + else -> { + rules = rule.splitNotBlank("||") + elementsType = "|" + } + } + if (rules.size == 1) { + if (!rule.contains("{$.")) { + try { + val obj = ctx!!.read(rule) ?: return result + if (obj is List<*>) { + for (o in obj) + result.add(o.toString()) + } else { + result.add(obj.toString()) + } + } catch (ignored: Exception) { + } + + return result + } else { + val matcher = jsonRulePattern.matcher(rule) + while (matcher.find()) { + val stringList = getStringList(matcher.group()) + for (s in stringList) { + result.add(rule.replace(String.format("{%s}", matcher.group()), s)) + } + } + return result + } + } else { + val results = ArrayList>() + for (rl in rules) { + val temp = getStringList(rl) + if (temp.isNotEmpty()) { + results.add(temp) + if (temp.isNotEmpty() && elementsType == "|") { + break + } + } + } + if (results.size > 0) { + if ("%" == elementsType) { + for (i in results[0].indices) { + for (temp in results) { + if (i < temp.size) { + result.add(temp[i]) + } + } + } + } else { + for (temp in results) { + result.addAll(temp) + } + } + } + return result + } + } + + internal fun getObject(rule: String): Any { + return ctx!!.read(rule) + } + + internal fun getList(rule: String): ArrayList? { + val result = ArrayList() + if (TextUtils.isEmpty(rule)) return result + val elementsType: String + val rules: Array + when { + rule.contains("&&") -> { + rules = rule.splitNotBlank("&&") + elementsType = "&" + } + rule.contains("%%") -> { + rules = rule.splitNotBlank("%%") + elementsType = "%" + } + else -> { + rules = rule.splitNotBlank("||") + elementsType = "|" + } + } + if (rules.size == 1) { + ctx?.let { + try { + return it.read>(rules[0]) + } catch (e: Exception) { + e.printStackTrace() + } + } + return null + } else { + val results = ArrayList>() + for (rl in rules) { + val temp = getList(rl) + if (temp != null && temp.isNotEmpty()) { + results.add(temp) + if (temp.isNotEmpty() && elementsType == "|") { + break + } + } + } + if (results.size > 0) { + if ("%" == elementsType) { + for (i in 0 until results[0].size) { + for (temp in results) { + if (i < temp.size) { + temp[i]?.let { result.add(it) } + } + } + } + } else { + for (temp in results) { + result.addAll(temp) + } + } + } + } + return result + } + + companion object { + private val jsonRulePattern = Pattern.compile("(?<=\\{)\\$\\..+?(?=\\})") + } +} diff --git a/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByJSoup.kt b/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByJSoup.kt new file mode 100644 index 000000000..33658de3c --- /dev/null +++ b/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByJSoup.kt @@ -0,0 +1,406 @@ +package io.legado.app.model.analyzeRule + +import android.text.TextUtils.isEmpty +import android.text.TextUtils.join +import io.legado.app.utils.splitNotBlank +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import org.jsoup.select.Collector +import org.jsoup.select.Elements +import org.jsoup.select.Evaluator +import org.seimicrawler.xpath.JXNode +import java.util.* + +/** + * Created by GKF on 2018/1/25. + * 书源规则解析 + */ + +class AnalyzeByJSoup { + private var element: Element? = null + + fun parse(doc: Any): AnalyzeByJSoup { + element = if (doc is Element) { + doc + } else if (doc is JXNode) { + if (doc.isElement) { + doc.asElement() + } else { + Jsoup.parse(doc.value().toString()) + } + } else { + Jsoup.parse(doc.toString()) + } + return this + } + + /** + * 获取列表 + */ + internal fun getElements(rule: String): Elements { + return getElements(element, rule) + } + + /** + * 合并内容列表,得到内容 + */ + internal fun getString(ruleStr: String): String? { + if (isEmpty(ruleStr)) { + return null + } + val textS = getStringList(ruleStr) + return if (textS.isEmpty()) { + null + } else join(",", textS).trim { it <= ' ' } + } + + /** + * 获取一个字符串 + */ + internal fun getString0(ruleStr: String): String { + val urlList = getStringList(ruleStr) + return if (urlList.isNotEmpty()) { + urlList[0] + } else "" + } + + /** + * 获取所有内容列表 + */ + internal fun getStringList(ruleStr: String): List { + val textS = ArrayList() + if (isEmpty(ruleStr)) { + return textS + } + //拆分规则 + val sourceRule = SourceRule(ruleStr) + if (isEmpty(sourceRule.elementsRule)) { + textS.add(element?.data() ?: "") + } else { + val elementsType: String + val ruleStrS: Array + when { + sourceRule.elementsRule.contains("&&") -> { + elementsType = "&" + ruleStrS = sourceRule.elementsRule.splitNotBlank("&&") + } + sourceRule.elementsRule.contains("%%") -> { + elementsType = "%" + ruleStrS = sourceRule.elementsRule.splitNotBlank("%%") + } + else -> { + elementsType = "|" + ruleStrS = sourceRule.elementsRule.splitNotBlank("||") + } + } + val results = ArrayList>() + for (ruleStrX in ruleStrS) { + val temp: List? + temp = if (sourceRule.isCss) { + val lastIndex = ruleStrX.lastIndexOf('@') + getResultLast( + element!!.select(ruleStrX.substring(0, lastIndex)), + ruleStrX.substring(lastIndex + 1) + ) + } else { + getResultList(ruleStrX) + } + if (!temp.isNullOrEmpty()) { + results.add(temp) + if (results.isNotEmpty() && elementsType == "|") { + break + } + } + } + if (results.size > 0) { + if ("%" == elementsType) { + for (i in results[0].indices) { + for (temp in results) { + if (i < temp.size) { + textS.add(temp[i]) + } + } + } + } else { + for (temp in results) { + textS.addAll(temp) + } + } + } + } + return textS + } + + /** + * 获取Elements + */ + private fun getElements(temp: Element?, rule: String): Elements { + val elements = Elements() + if (temp == null || isEmpty(rule)) { + return elements + } + val sourceRule = SourceRule(rule) + val elementsType: String + val ruleStrS: Array + when { + sourceRule.elementsRule.contains("&&") -> { + elementsType = "&" + ruleStrS = sourceRule.elementsRule.splitNotBlank("&&") + } + sourceRule.elementsRule.contains("%%") -> { + elementsType = "%" + ruleStrS = sourceRule.elementsRule.splitNotBlank("%%") + } + else -> { + elementsType = "|" + ruleStrS = sourceRule.elementsRule.splitNotBlank("||") + } + } + val elementsList = ArrayList() + if (sourceRule.isCss) { + for (ruleStr in ruleStrS) { + val tempS = temp.select(ruleStr) + elementsList.add(tempS) + if (tempS.size > 0 && elementsType == "|") { + break + } + } + } else { + for (ruleStr in ruleStrS) { + val tempS = getElementsSingle(temp, ruleStr) + elementsList.add(tempS) + if (tempS.size > 0 && elementsType == "|") { + break + } + } + } + if (elementsList.size > 0) { + if ("%" == elementsType) { + for (i in 0 until elementsList[0].size) { + for (es in elementsList) { + if (i < es.size) { + elements.add(es[i]) + } + } + } + } else { + for (es in elementsList) { + elements.addAll(es) + } + } + } + return elements + } + + private fun filterElements(elements: Elements, rules: Array?): Elements { + if (rules == null || rules.size < 2) return elements + val selectedEls = Elements() + for (ele in elements) { + var isOk = false + when (rules[0]) { + "class" -> isOk = ele.getElementsByClass(rules[1]).size > 0 + "id" -> isOk = ele.getElementById(rules[1]) != null + "tag" -> isOk = ele.getElementsByTag(rules[1]).size > 0 + "text" -> isOk = ele.getElementsContainingOwnText(rules[1]).size > 0 + } + if (isOk) { + selectedEls.add(ele) + } + } + return selectedEls + } + + /** + * 获取Elements按照一个规则 + */ + private fun getElementsSingle(temp: Element, rule: String): Elements { + val elements = Elements() + try { + val rs = rule.trim { it <= ' ' }.splitNotBlank("@") + if (rs.size > 1) { + elements.add(temp) + for (rl in rs) { + val es = Elements() + for (et in elements) { + es.addAll(getElements(et, rl)) + } + elements.clear() + elements.addAll(es) + } + } else { + val rulePcx = rule.splitNotBlank("!") + val rulePc = + rulePcx[0].trim { it <= ' ' }.splitNotBlank(">") + val rules = + rulePc[0].trim { it <= ' ' }.splitNotBlank(".") + var filterRules: Array? = null + var needFilterElements = rulePc.size > 1 && !isEmpty(rulePc[1].trim { it <= ' ' }) + if (needFilterElements) { + filterRules = rulePc[1].trim { it <= ' ' }.splitNotBlank(".") + filterRules[0] = filterRules[0].trim { it <= ' ' } + val validKeys = listOf("class", "id", "tag", "text") + if (filterRules.size < 2 || !validKeys.contains(filterRules[0]) || isEmpty(filterRules[1].trim { it <= ' ' })) { + needFilterElements = false + } + filterRules[1] = filterRules[1].trim { it <= ' ' } + } + when (rules[0]) { + "children" -> { + var children = temp.children() + if (needFilterElements) + children = filterElements(children, filterRules) + elements.addAll(children) + } + "class" -> { + var elementsByClass = temp.getElementsByClass(rules[1]) + if (rules.size == 3) { + val index = Integer.parseInt(rules[2]) + if (index < 0) { + elements.add(elementsByClass[elementsByClass.size + index]) + } else { + elements.add(elementsByClass[index]) + } + } else { + if (needFilterElements) + elementsByClass = filterElements(elementsByClass, filterRules) + elements.addAll(elementsByClass) + } + } + "tag" -> { + var elementsByTag = temp.getElementsByTag(rules[1]) + if (rules.size == 3) { + val index = Integer.parseInt(rules[2]) + if (index < 0) { + elements.add(elementsByTag[elementsByTag.size + index]) + } else { + elements.add(elementsByTag[index]) + } + } else { + if (needFilterElements) + elementsByTag = filterElements(elementsByTag, filterRules) + elements.addAll(elementsByTag) + } + } + "id" -> { + var elementsById = Collector.collect(Evaluator.Id(rules[1]), temp) + if (rules.size == 3) { + val index = Integer.parseInt(rules[2]) + if (index < 0) { + elements.add(elementsById[elementsById.size + index]) + } else { + elements.add(elementsById[index]) + } + } else { + if (needFilterElements) + elementsById = filterElements(elementsById, filterRules) + elements.addAll(elementsById) + } + } + "text" -> { + var elementsByText = temp.getElementsContainingOwnText(rules[1]) + if (needFilterElements) + elementsByText = filterElements(elementsByText, filterRules) + elements.addAll(elementsByText) + } + else -> elements.addAll(temp.select(rulePcx[0])) + } + if (rulePcx.size > 1) { + val rulePcs = rulePcx[1].splitNotBlank(":") + for (pc in rulePcs) { + val pcInt = Integer.parseInt(pc) + if (pcInt < 0 && elements.size + pcInt >= 0) { + elements[elements.size + pcInt] = null + } else if (Integer.parseInt(pc) < elements.size) { + elements[Integer.parseInt(pc)] = null + } + } + val es = Elements() + es.add(null) + elements.removeAll(es) + } + } + } catch (ignore: Exception) { + } + + return elements + } + + /** + * 获取内容列表 + */ + private fun getResultList(ruleStr: String): List? { + if (isEmpty(ruleStr)) { + return null + } + var elements = Elements() + elements.add(element) + val rules = ruleStr.splitNotBlank("@") + for (i in 0 until rules.size - 1) { + val es = Elements() + for (elt in elements) { + es.addAll(getElementsSingle(elt, rules[i])) + } + elements.clear() + elements = es + } + return if (elements.isEmpty()) { + null + } else getResultLast(elements, rules[rules.size - 1]) + } + + /** + * 根据最后一个规则获取内容 + */ + private fun getResultLast(elements: Elements, lastRule: String): List { + val textS = ArrayList() + try { + when (lastRule) { + "text" -> for (element in elements) { + val text = element.text() + textS.add(text) + } + "textNodes" -> for (element in elements) { + val tn = arrayListOf() + val contentEs = element.textNodes() + for (item in contentEs) { + val temp = item.text().trim { it <= ' ' } + if (!isEmpty(temp)) { + tn.add(temp) + } + } + textS.add(join("\n", tn)) + } + "ownText", "html" -> { + elements.select("script").remove() + val html = elements.html() + textS.add(html) + } + else -> for (element in elements) { + val url = element.attr(lastRule) + if (!isEmpty(url) && !textS.contains(url)) { + textS.add(url) + } + } + } + } catch (e: Exception) { + e.printStackTrace() + } + + return textS + } + + internal inner class SourceRule(ruleStr: String) { + var isCss = false + var elementsRule: String + + init { + if (ruleStr.startsWith("@CSS:", true)) { + isCss = true + elementsRule = ruleStr.substring(5).trim { it <= ' ' } + } else { + elementsRule = ruleStr + } + } + } + +} diff --git a/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByRegex.kt b/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByRegex.kt new file mode 100644 index 000000000..28e022fb5 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByRegex.kt @@ -0,0 +1,59 @@ +package io.legado.app.model.analyzeRule + +import java.util.* +import java.util.regex.Pattern + +object AnalyzeByRegex { + + fun getElement(res: String, regs: Array, index: Int = 0): List? { + var vIndex = index + val resM = Pattern.compile(regs[vIndex]).matcher(res) + if (!resM.find()) { + return null + } + // 判断索引的规则是最后一个规则 + return if (vIndex + 1 == regs.size) { + // 新建容器 + val info = arrayListOf() + for (groupIndex in 0..resM.groupCount()) { + info.add(resM.group(groupIndex)) + } + info + } else { + val result = StringBuilder() + do { + result.append(resM.group()) + } while (resM.find()) + getElement(result.toString(), regs, ++vIndex) + } + } + + fun getElements(res: String, regs: Array, index: Int = 0): List> { + var vIndex = index + val resM = Pattern.compile(regs[vIndex]).matcher(res) + if (!resM.find()) { + return arrayListOf() + } + // 判断索引的规则是最后一个规则 + if (vIndex + 1 == regs.size) { + // 创建书息缓存数组 + val books = ArrayList>() + // 提取列表 + do { + // 新建容器 + val info = arrayListOf() + for (groupIndex in 0..resM.groupCount()) { + info.add(resM.group(groupIndex)) + } + books.add(info) + } while (resM.find()) + return books + } else { + val result = StringBuilder() + do { + result.append(resM.group()) + } while (resM.find()) + return getElements(result.toString(), regs, ++vIndex) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByXPath.kt b/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByXPath.kt new file mode 100644 index 000000000..f2745ee69 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByXPath.kt @@ -0,0 +1,187 @@ +package io.legado.app.model.analyzeRule + +import android.text.TextUtils +import io.legado.app.utils.splitNotBlank +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import org.jsoup.select.Elements +import org.seimicrawler.xpath.JXDocument +import org.seimicrawler.xpath.JXNode +import java.util.* + +class AnalyzeByXPath { + private var jxDocument: JXDocument? = null + private var jxNode: JXNode? = null + + fun parse(doc: Any): AnalyzeByXPath { + if (doc is JXNode) { + jxNode = doc + if (jxNode?.isElement == false) { + jxDocument = strToJXDocument(doc.toString()) + jxNode = null + } + } else if (doc is Document) { + jxDocument = JXDocument.create(doc) + jxNode = null + } else if (doc is Element) { + jxDocument = JXDocument.create(Elements(doc)) + jxNode = null + } else if (doc is Elements) { + jxDocument = JXDocument.create(doc) + jxNode = null + } else { + jxDocument = strToJXDocument(doc.toString()) + jxNode = null + } + return this + } + + private fun strToJXDocument(html: String): JXDocument { + var html1 = html + if (html1.endsWith("")) { + html1 = String.format("%s", html1) + } + if (html1.endsWith("") || html1.endsWith("")) { + html1 = String.format("%s
", html1) + } + return JXDocument.create(html1) + } + + internal fun getElements(xPath: String): List? { + if (TextUtils.isEmpty(xPath)) { + return null + } + val jxNodes = ArrayList() + val elementsType: String + val rules: Array + when { + xPath.contains("&&") -> { + rules = xPath.splitNotBlank("&&") + elementsType = "&" + } + xPath.contains("%%") -> { + rules = xPath.splitNotBlank("%%") + elementsType = "%" + } + else -> { + rules = xPath.splitNotBlank("||") + elementsType = "|" + } + } + if (rules.size == 1) { + return jxNode?.sel(rules[0]) ?: jxDocument?.selN(rules[0]) + } else { + val results = ArrayList>() + for (rl in rules) { + val temp = getElements(rl) + if (temp != null && temp.isNotEmpty()) { + results.add(temp) + if (temp.isNotEmpty() && elementsType == "|") { + break + } + } + } + if (results.size > 0) { + if ("%" == elementsType) { + for (i in results[0].indices) { + for (temp in results) { + if (i < temp.size) { + jxNodes.add(temp[i]) + } + } + } + } else { + for (temp in results) { + jxNodes.addAll(temp) + } + } + } + } + return jxNodes + } + + internal fun getStringList(xPath: String): List { + val result = ArrayList() + val elementsType: String + val rules: Array + when { + xPath.contains("&&") -> { + rules = xPath.splitNotBlank("&&") + elementsType = "&" + } + xPath.contains("%%") -> { + rules = xPath.splitNotBlank("%%") + elementsType = "%" + } + else -> { + rules = xPath.splitNotBlank("||") + elementsType = "|" + } + } + if (rules.size == 1) { + val jxNodes = jxNode?.sel(xPath) ?: jxDocument?.selN(xPath) + jxNodes?.map { + result.add(it.asString()) + } + return result + } else { + val results = ArrayList>() + for (rl in rules) { + val temp = getStringList(rl) + if (temp.isNotEmpty()) { + results.add(temp) + if (temp.isNotEmpty() && elementsType == "|") { + break + } + } + } + if (results.size > 0) { + if ("%" == elementsType) { + for (i in results[0].indices) { + for (temp in results) { + if (i < temp.size) { + result.add(temp[i]) + } + } + } + } else { + for (temp in results) { + result.addAll(temp) + } + } + } + } + return result + } + + fun getString(rule: String): String? { + val rules: Array + val elementsType: String + if (rule.contains("&&")) { + rules = rule.splitNotBlank("&&") + elementsType = "&" + } else { + rules = rule.splitNotBlank("||") + elementsType = "|" + } + if (rules.size == 1) { + val jxNodes = jxNode?.sel(rule) ?: jxDocument?.selN(rule) + jxNodes?.let { + return TextUtils.join(",", jxNodes) + } + return null + } else { + val textList = arrayListOf() + for (rl in rules) { + val temp = getString(rl) + if (!temp.isNullOrEmpty()) { + textList.add(temp) + if (elementsType == "|") { + break + } + } + } + return TextUtils.join(",", textList) + } + } +} diff --git a/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt b/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt new file mode 100644 index 000000000..b4bb0276e --- /dev/null +++ b/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt @@ -0,0 +1,616 @@ +package io.legado.app.model.analyzeRule + +import android.text.TextUtils +import androidx.annotation.Keep +import io.legado.app.constant.AppConst.SCRIPT_ENGINE +import io.legado.app.constant.Pattern.JS_PATTERN +import io.legado.app.data.entities.BaseBook +import io.legado.app.utils.* +import org.mozilla.javascript.NativeObject +import java.util.* +import java.util.regex.Pattern +import javax.script.SimpleBindings +import kotlin.collections.HashMap + + +/** + * Created by REFGD. + * 统一解析接口 + */ +@Keep +@Suppress("unused") +class AnalyzeRule(private var book: BaseBook? = null) { + private var content: Any? = null + private var baseUrl: String? = null + private var isJSON: Boolean = false + private var isRegex: Boolean = false + + private var analyzeByXPath: AnalyzeByXPath? = null + private var analyzeByJSoup: AnalyzeByJSoup? = null + private var analyzeByJSonPath: AnalyzeByJSonPath? = null + + private var objectChangedXP = false + private var objectChangedJS = false + private var objectChangedJP = false + + fun setBook(book: BaseBook) { + this.book = book + } + + @Throws(Exception::class) + @JvmOverloads + fun setContent(content: Any?, baseUrl: String? = this.baseUrl): AnalyzeRule { + if (content == null) throw AssertionError("Content cannot be null") + isJSON = content.toString().isJson() + this.content = content + this.baseUrl = baseUrl + objectChangedXP = true + objectChangedJS = true + objectChangedJP = true + return this + } + + /** + * 获取XPath解析类 + */ + private fun getAnalyzeByXPath(o: Any): AnalyzeByXPath { + return if (o != content) { + AnalyzeByXPath().parse(o) + } else getAnalyzeByXPath() + } + + private fun getAnalyzeByXPath(): AnalyzeByXPath { + if (analyzeByXPath == null || objectChangedXP) { + analyzeByXPath = AnalyzeByXPath() + analyzeByXPath?.parse(content!!) + objectChangedXP = false + } + return analyzeByXPath as AnalyzeByXPath + } + + /** + * 获取JSOUP解析类 + */ + private fun getAnalyzeByJSoup(o: Any): AnalyzeByJSoup { + return if (o != content) { + AnalyzeByJSoup().parse(o) + } else getAnalyzeByJSoup() + } + + private fun getAnalyzeByJSoup(): AnalyzeByJSoup { + if (analyzeByJSoup == null || objectChangedJS) { + analyzeByJSoup = AnalyzeByJSoup() + analyzeByJSoup?.parse(content!!) + objectChangedJS = false + } + return analyzeByJSoup as AnalyzeByJSoup + } + + /** + * 获取JSON解析类 + */ + private fun getAnalyzeByJSonPath(o: Any): AnalyzeByJSonPath { + return if (o != content) { + AnalyzeByJSonPath().parse(o) + } else getAnalyzeByJSonPath() + } + + private fun getAnalyzeByJSonPath(): AnalyzeByJSonPath { + if (analyzeByJSonPath == null || objectChangedJP) { + analyzeByJSonPath = AnalyzeByJSonPath() + analyzeByJSonPath?.parse(content!!) + objectChangedJP = false + } + return analyzeByJSonPath as AnalyzeByJSonPath + } + + /** + * 获取文本列表 + */ + @Throws(Exception::class) + @JvmOverloads + fun getStringList(rule: String, isUrl: Boolean = false): List? { + if (TextUtils.isEmpty(rule)) return null + val ruleList = splitSourceRule(rule) + return getStringList(ruleList, isUrl) + } + + @Throws(Exception::class) + fun getStringList(ruleList: List, isUrl: Boolean): List? { + var result: Any? = null + content?.let { o -> + if (ruleList.isNotEmpty()) { + if (ruleList.isNotEmpty()) result = o + for (sourceRule in ruleList) { + putRule(sourceRule.putMap) + sourceRule.makeUpRule(result) + result?.let { + if (sourceRule.rule.isNotEmpty()) { + result = when (sourceRule.mode) { + Mode.Js -> evalJS(sourceRule.rule, result) + Mode.Json -> getAnalyzeByJSonPath(it).getStringList(sourceRule.rule) + Mode.XPath -> getAnalyzeByXPath(it).getStringList(sourceRule.rule) + Mode.Default -> getAnalyzeByJSoup(it).getStringList(sourceRule.rule) + else -> sourceRule.rule + } + } + if (sourceRule.replaceRegex.isNotEmpty() && result is List<*>) { + val newList = ArrayList() + for (item in result as List<*>) { + newList.add(replaceRegex(item.toString(), sourceRule)) + } + result = newList + } else if (sourceRule.replaceRegex.isNotEmpty()) { + result = replaceRegex(result.toString(), sourceRule) + } + } + } + } + } + if (result == null) return ArrayList() + if (result is String) { + result = listOf((result as String).htmlFormat().split("\n")) + } + if (isUrl) { + val urlList = ArrayList() + if (result is List<*>) { + for (url in result as List<*>) { + val absoluteURL = NetworkUtils.getAbsoluteURL(baseUrl, url.toString()) + if (!absoluteURL.isNullOrEmpty() && !urlList.contains(absoluteURL)) { + urlList.add(absoluteURL) + } + } + } + return urlList + } + @Suppress("UNCHECKED_CAST") + return result as? List + } + + /** + * 获取文本 + */ + @Throws(Exception::class) + fun getString(rule: String): String? { + return getString(rule, false) + } + + @Throws(Exception::class) + fun getString(ruleStr: String, isUrl: Boolean): String? { + if (TextUtils.isEmpty(ruleStr)) return null + val ruleList = splitSourceRule(ruleStr) + return getString(ruleList, isUrl) + } + + @Throws(Exception::class) + @JvmOverloads + fun getString(ruleList: List, isUrl: Boolean = false): String { + var result: Any? = null + val content = this.content + if (content != null && ruleList.isNotEmpty()) { + result = content + if (content is NativeObject) { + result = content[ruleList[0].rule]?.toString() + } else { + for (sourceRule in ruleList) { + putRule(sourceRule.putMap) + sourceRule.makeUpRule(result) + result?.let { + if (sourceRule.rule.isNotBlank()) { + result = when (sourceRule.mode) { + Mode.Js -> evalJS(sourceRule.rule, it) + Mode.Json -> getAnalyzeByJSonPath(it).getString(sourceRule.rule) + Mode.XPath -> getAnalyzeByXPath(it).getString(sourceRule.rule) + Mode.Default -> if (isUrl) { + getAnalyzeByJSoup(it).getString0(sourceRule.rule) + } else { + getAnalyzeByJSoup(it).getString(sourceRule.rule) + } + else -> sourceRule.rule + } + } + if (sourceRule.replaceRegex.isNotEmpty()) { + result = replaceRegex(result.toString(), sourceRule) + } + } + } + } + } + if (result == null) result = "" + if (isUrl) { + return NetworkUtils.getAbsoluteURL(baseUrl, result.toString()) ?: "" + } + return result.toString() + } + + /** + * 获取Element + */ + @Throws(Exception::class) + fun getElement(ruleStr: String): Any? { + if (TextUtils.isEmpty(ruleStr)) return null + var result: Any? = null + val ruleList = splitSourceRule(ruleStr) + content?.let { o -> + if (ruleList.isNotEmpty()) result = o + for (sourceRule in ruleList) { + putRule(sourceRule.putMap) + result?.let { + result = when (sourceRule.mode) { + Mode.Regex -> AnalyzeByRegex.getElement( + result.toString(), + sourceRule.rule.splitNotBlank("&&") + ) + Mode.Js -> evalJS(sourceRule.rule, it) + Mode.Json -> getAnalyzeByJSonPath(it).getObject(sourceRule.rule) + Mode.XPath -> getAnalyzeByXPath(it).getElements(sourceRule.rule) + else -> getAnalyzeByJSoup(it).getElements(sourceRule.rule) + } + if (sourceRule.replaceRegex.isNotEmpty()) { + result = replaceRegex(result.toString(), sourceRule) + } + } + } + } + return result + } + + /** + * 获取列表 + */ + @Suppress("UNCHECKED_CAST") + @Throws(Exception::class) + fun getElements(ruleStr: String): List { + var result: Any? = null + val ruleList = splitSourceRule(ruleStr) + content?.let { o -> + if (ruleList.isNotEmpty()) result = o + for (sourceRule in ruleList) { + putRule(sourceRule.putMap) + result?.let { + result = when (sourceRule.mode) { + Mode.Regex -> AnalyzeByRegex.getElements( + result.toString(), + sourceRule.rule.splitNotBlank("&&") + ) + Mode.Js -> evalJS(sourceRule.rule, result) + Mode.Json -> getAnalyzeByJSonPath(it).getList(sourceRule.rule) + Mode.XPath -> getAnalyzeByXPath(it).getElements(sourceRule.rule) + else -> getAnalyzeByJSoup(it).getElements(sourceRule.rule) + } + if (sourceRule.replaceRegex.isNotEmpty()) { + result = replaceRegex(result.toString(), sourceRule) + } + } + } + } + result?.let { + return it as List + } + return ArrayList() + } + + + /** + * 保存变量 + */ + @Throws(Exception::class) + private fun putRule(map: Map) { + for ((key, value) in map) { + getString(value)?.let { + book?.putVariable(key, it) + } + } + } + + /** + * 分离put规则 + */ + @Throws(Exception::class) + private fun splitPutRule(ruleStr: String, putMap: HashMap): String { + var vRuleStr = ruleStr + val putMatcher = putPattern.matcher(vRuleStr) + while (putMatcher.find()) { + vRuleStr = vRuleStr.replace(putMatcher.group(), "") + val map = GSON.fromJsonObject>(putMatcher.group(1)) + map?.let { putMap.putAll(map) } + } + return vRuleStr + } + + /** + * 正则替换 + */ + private fun replaceRegex(result: String, rule: SourceRule): String { + var vResult = result + if (rule.replaceRegex.isNotEmpty()) { + vResult = if (rule.replaceFirst) { + val pattern = Pattern.compile(rule.replaceRegex) + val matcher = pattern.matcher(vResult) + if (matcher.find()) { + matcher.group(0).replaceFirst(rule.replaceRegex.toRegex(), rule.replacement) + } else { + "" + } + } else { + vResult.replace(rule.replaceRegex.toRegex(), rule.replacement) + } + } + return vResult + } + + /** + * 分解规则生成规则列表 + */ + @Throws(Exception::class) + fun splitSourceRule(ruleStr: String, mode: Mode = Mode.Default): List { + var vRuleStr = ruleStr + val ruleList = ArrayList() + if (TextUtils.isEmpty(vRuleStr)) return ruleList + //检测Mode + var mMode: Mode = mode + when { + vRuleStr.startsWith("@XPath:", true) -> { + mMode = Mode.XPath + vRuleStr = vRuleStr.substring(7) + } + vRuleStr.startsWith("@Json:", true) -> { + mMode = Mode.Json + vRuleStr = vRuleStr.substring(6) + } + vRuleStr.startsWith(":") -> { + mMode = Mode.Regex + isRegex = true + vRuleStr = vRuleStr.substring(1) + } + isRegex -> mMode = Mode.Regex + isJSON -> mMode = Mode.Json + } + //拆分为规则列表 + var start = 0 + var tmp: String + val jsMatcher = JS_PATTERN.matcher(vRuleStr) + while (jsMatcher.find()) { + if (jsMatcher.start() > start) { + tmp = vRuleStr.substring(start, jsMatcher.start()).replace("\n", "") + .trim { it <= ' ' } + if (!TextUtils.isEmpty(tmp)) { + ruleList.add(SourceRule(tmp, mMode)) + } + } + ruleList.add(SourceRule(jsMatcher.group(), Mode.Js)) + start = jsMatcher.end() + } + if (vRuleStr.length > start) { + tmp = vRuleStr.substring(start).replace("\n", "").trim { it <= ' ' } + if (!TextUtils.isEmpty(tmp)) { + ruleList.add(SourceRule(tmp, mMode)) + } + } + return ruleList + } + + /** + * 规则类 + */ + inner class SourceRule internal constructor(ruleStr: String, mainMode: Mode) { + internal var mode: Mode + internal var rule: String + internal var replaceRegex = "" + internal var replacement = "" + internal var replaceFirst = false + internal val putMap = HashMap() + private val ruleParam = ArrayList() + private val ruleType = ArrayList() + + init { + this.mode = mainMode + if (mode == Mode.Js) { + rule = if (ruleStr.startsWith("")) { + ruleStr.substring(4, ruleStr.lastIndexOf("<")) + } else { + ruleStr.substring(4) + } + } else { + when { + ruleStr.startsWith("@XPath:", true) -> { + mode = Mode.XPath + rule = ruleStr.substring(7) + } + ruleStr.startsWith("//") -> {//XPath特征很明显,无需配置单独的识别标头 + mode = Mode.XPath + rule = ruleStr + } + ruleStr.startsWith("@Json:", true) -> { + mode = Mode.Json + rule = ruleStr.substring(6) + } + ruleStr.startsWith("$.") -> { + mode = Mode.Json + rule = ruleStr + } + else -> rule = ruleStr + } + } + //分离正则表达式 + val ruleStrS = + rule.trim { it <= ' ' }.split("##") + rule = ruleStrS[0] + if (ruleStrS.size > 1) { + replaceRegex = ruleStrS[1] + } + if (ruleStrS.size > 2) { + replacement = ruleStrS[2] + } + if (ruleStrS.size > 3) { + replaceFirst = true + } + //分离put + rule = splitPutRule(rule, putMap) + //@get,{{ }},$1, 拆分 + var start = 0 + var tmp: String + val evalMatcher = evalPattern.matcher(rule) + while (evalMatcher.find()) { + if (evalMatcher.start() > start) { + tmp = rule.substring(start, evalMatcher.start()) + ruleType.add(0) + ruleParam.add(tmp) + } + tmp = evalMatcher.group() + when { + tmp.startsWith("$") -> { + ruleType.add(tmp.substring(1).toInt()) + ruleParam.add(tmp) + } + tmp.startsWith("@get:", true) -> { + ruleType.add(-2) + ruleParam.add(tmp.substring(6, tmp.lastIndex)) + } + tmp.startsWith("{{") -> { + ruleType.add(-1) + ruleParam.add(tmp.substring(2, tmp.length - 2)) + } + else -> { + ruleType.add(0) + ruleParam.add(tmp) + } + } + start = evalMatcher.end() + } + if (rule.length > start) { + tmp = rule.substring(start) + ruleType.add(0) + ruleParam.add(tmp) + } + } + + /** + * 替换@get,{{ }},$1, + */ + fun makeUpRule(result: Any?) { + val infoVal = StringBuilder() + if (ruleParam.isNotEmpty()) { + var index = ruleParam.size + while (index-- > 0) { + val regType = ruleType[index] + when { + regType > 0 -> { + @Suppress("UNCHECKED_CAST") + val resultList = result as? List + if (resultList != null) { + if (resultList.size > regType) { + resultList[regType]?.let { + infoVal.insert(0, resultList[regType]) + } + } + } else { + infoVal.insert(0, ruleParam[index]) + } + } + regType == -1 -> { + val jsEval: Any = evalJS(ruleParam[index], result) + if (jsEval is String) { + infoVal.insert(0, jsEval) + } else if (jsEval is Double && jsEval % 1.0 == 0.0) { + infoVal.insert(0, String.format("%.0f", jsEval)) + } else { + infoVal.insert(0, jsEval.toString()) + } + } + regType == -2 -> { + infoVal.insert(0, get(ruleParam[index])) + } + else -> infoVal.insert(0, ruleParam[index]) + } + } + rule = infoVal.toString() + } + } + } + + enum class Mode { + XPath, Json, Default, Js, Regex + } + + fun put(key: String, value: String): String { + book?.putVariable(key, value) + return value + } + + operator fun get(key: String): String { + return book?.variableMap?.get(key) ?: "" + } + + /** + * 执行JS + */ + @Throws(Exception::class) + private fun evalJS(jsStr: String, result: Any?): Any { + val bindings = SimpleBindings() + bindings["java"] = this + bindings["book"] = book + bindings["result"] = result + bindings["baseUrl"] = baseUrl + return SCRIPT_ENGINE.eval(jsStr, bindings) + } + + /** + * js实现跨域访问,不能删 + */ + fun ajax(urlStr: String): String? { + return try { + val analyzeUrl = AnalyzeUrl(urlStr, null, null, null, baseUrl, book) + val call = analyzeUrl.getResponse() + val response = call.execute() + response.body() + } catch (e: Exception) { + e.localizedMessage + } + } + + /** + * js实现解码,不能删 + */ + fun base64Decode(str: String): String { + return EncoderUtils.base64Decode(str) + } + + fun base64Encode(str: String): String? { + return EncoderUtils.base64Encode(str) + } + + fun strToMd5By32(str: String): String? { + return MD5Utils.strToMd5By32(str) + } + + fun strToMd5By16(str: String): String? { + return MD5Utils.strToMd5By16(str) + } + + /** + * 章节数转数字 + */ + fun toNumChapter(s: String?): String? { + if (s == null) { + return null + } + val pattern = Pattern.compile("(第)(.+?)(章)") + val matcher = pattern.matcher(s) + return if (matcher.find()) { + matcher.group(1) + StringUtils.stringToInt(matcher.group(2)) + matcher.group(3) + } else { + s + } + } + + companion object { + private val putPattern = Pattern.compile("@put:(\\{[^}]+?\\})", Pattern.CASE_INSENSITIVE) + private val getPattern = Pattern.compile("@get:\\{([^}]+?)\\}", Pattern.CASE_INSENSITIVE) + private val evalPattern = Pattern.compile( + "@get:\\{[^}]+?\\}|\\{\\{[\\w\\W]*?\\}\\}|\\$\\d{1,2}", + Pattern.CASE_INSENSITIVE + ) + } + +} diff --git a/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt b/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt new file mode 100644 index 000000000..8e6ded010 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt @@ -0,0 +1,299 @@ +package io.legado.app.model.analyzeRule + +import android.annotation.SuppressLint +import android.text.TextUtils +import androidx.annotation.Keep +import io.legado.app.constant.AppConst.SCRIPT_ENGINE +import io.legado.app.constant.Pattern.EXP_PATTERN +import io.legado.app.constant.Pattern.JS_PATTERN +import io.legado.app.data.api.IHttpGetApi +import io.legado.app.data.api.IHttpPostApi +import io.legado.app.data.entities.BaseBook +import io.legado.app.help.JsExtensions +import io.legado.app.help.http.HttpHelper +import io.legado.app.utils.* +import kotlinx.coroutines.Deferred +import okhttp3.FormBody +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import retrofit2.Call +import retrofit2.Response +import java.net.URLEncoder +import java.util.* +import java.util.regex.Pattern +import javax.script.SimpleBindings + + +/** + * Created by GKF on 2018/1/24. + * 搜索URL规则解析 + */ +@Keep +@SuppressLint("DefaultLocale") +class AnalyzeUrl( + var ruleUrl: String, + key: String? = null, + page: Int? = null, + headerMapF: Map? = null, + baseUrl: String? = null, + book: BaseBook? = null +) { + companion object { + private val pagePattern = Pattern.compile("<(.*?)>") + private val jsonType = "application/json; charset=utf-8".toMediaTypeOrNull() + } + + private var baseUrl: String = "" + lateinit var url: String + private set + var path: String? = null + private set + private var queryStr: String? = null + private val fieldMap = LinkedHashMap() + private val headerMap = HashMap() + private var charset: String? = null + private var bodyTxt: String? = null + private lateinit var body: RequestBody + private var method = Method.GET + + val postData: ByteArray + get() { + val builder = StringBuilder() + val keys = fieldMap.keys + for (key in keys) { + builder.append(String.format("%s=%s&", key, fieldMap[key])) + } + builder.deleteCharAt(builder.lastIndexOf("&")) + return builder.toString().toByteArray() + } + + init { + baseUrl?.let { + this.baseUrl = it.split(",[^\\{]*".toRegex(), 1)[0] + } + headerMapF?.let { headerMap.putAll(it) } + //替换参数 + analyzeJs(key, page, book) + replaceKeyPageJs(key, page, book) + //处理URL + initUrl() + } + + private fun analyzeJs(key: String?, page: Int?, book: BaseBook?) { + val ruleList = arrayListOf() + var start = 0 + var tmp: String + val jsMatcher = JS_PATTERN.matcher(ruleUrl) + while (jsMatcher.find()) { + if (jsMatcher.start() > start) { + tmp = + ruleUrl.substring(start, jsMatcher.start()).replace("\n", "").trim { it <= ' ' } + if (!TextUtils.isEmpty(tmp)) { + ruleList.add(tmp) + } + } + ruleList.add(jsMatcher.group()) + start = jsMatcher.end() + } + if (ruleUrl.length > start) { + tmp = ruleUrl.substring(start).replace("\n", "").trim { it <= ' ' } + if (!TextUtils.isEmpty(tmp)) { + ruleList.add(tmp) + } + } + for (rule in ruleList) { + var ruleStr = rule + when { + ruleStr.startsWith("") -> { + ruleStr = ruleStr.substring(4, ruleStr.lastIndexOf("<")) + ruleUrl = evalJS(ruleStr, ruleUrl, page, key, book) as String + } + ruleStr.startsWith("@js", true) -> { + ruleStr = ruleStr.substring(4) + ruleUrl = evalJS(ruleStr, ruleUrl, page, key, book) as String + } + else -> ruleUrl = ruleStr.replace("@result", ruleUrl) + } + } + } + + /** + * 替换关键字,页数,JS + */ + private fun replaceKeyPageJs(key: String?, page: Int?, book: BaseBook?) { + //page + page?.let { + val matcher = pagePattern.matcher(ruleUrl) + while (matcher.find()) { + val pages = + matcher.group(1).splitNotBlank(",") + ruleUrl = if (page <= pages.size) { + ruleUrl.replace(matcher.group(), pages[page - 1].trim { it <= ' ' }) + } else { + ruleUrl.replace(matcher.group(), pages.last().trim { it <= ' ' }) + } + } + } + //js + if (ruleUrl.contains("{{") && ruleUrl.contains("}}")) { + var jsEval: Any + val sb = StringBuffer(ruleUrl.length) + val simpleBindings = SimpleBindings() + simpleBindings["java"] = JsExtensions + simpleBindings["baseUrl"] = baseUrl + simpleBindings["page"] = page + simpleBindings["key"] = key + simpleBindings["book"] = book + val expMatcher = EXP_PATTERN.matcher(ruleUrl) + while (expMatcher.find()) { + jsEval = SCRIPT_ENGINE.eval(expMatcher.group(1), simpleBindings) + if (jsEval is String) { + expMatcher.appendReplacement(sb, jsEval) + } else if (jsEval is Double && jsEval % 1.0 == 0.0) { + expMatcher.appendReplacement(sb, String.format("%.0f", jsEval)) + } else { + expMatcher.appendReplacement(sb, jsEval.toString()) + } + } + expMatcher.appendTail(sb) + ruleUrl = sb.toString() + } + } + + /** + * 处理URL + */ + private fun initUrl() { + var urlArray = ruleUrl.split(",[^\\{]*".toRegex(), 2) + url = urlArray[0] + NetworkUtils.getBaseUrl(url)?.let { + baseUrl = it + } + if (urlArray.size > 1) { + val options = GSON.fromJsonObject>(urlArray[1]) + options?.let { + options["method"]?.let { if (it.equals("POST", true)) method = Method.POST } + options["headers"]?.let { headers -> + GSON.fromJsonObject>(headers)?.let { headerMap.putAll(it) } + } + options["body"]?.let { bodyTxt = it } + options["charset"]?.let { charset = it } + } + } + when (method) { + Method.GET -> { + urlArray = url.split("?") + url = urlArray[0] + if (urlArray.size > 1) { + analyzeFields(urlArray[1]) + } + } + Method.POST -> { + bodyTxt?.let { + if (it.isJson()) { + body = it.toRequestBody(jsonType) + } else { + analyzeFields(it) + } + } ?: let { + body = FormBody.Builder().build() + } + } + } + } + + + /** + * 解析QueryMap + */ + @Throws(Exception::class) + private fun analyzeFields(fieldsTxt: String) { + queryStr = fieldsTxt + val queryS = fieldsTxt.splitNotBlank("&") + for (query in queryS) { + val queryM = query.splitNotBlank("=") + val value = if (queryM.size > 1) queryM[1] else "" + if (TextUtils.isEmpty(charset)) { + if (NetworkUtils.hasUrlEncoded(value)) { + fieldMap[queryM[0]] = value + } else { + fieldMap[queryM[0]] = URLEncoder.encode(value, "UTF-8") + } + } else if (charset == "escape") { + fieldMap[queryM[0]] = EncoderUtils.escape(value) + } else { + fieldMap[queryM[0]] = URLEncoder.encode(value, charset) + } + } + } + + /** + * 执行JS + */ + @Throws(Exception::class) + private fun evalJS( + jsStr: String, + result: Any?, + page: Int?, + key: String?, + book: BaseBook? + ): Any { + val bindings = SimpleBindings() + bindings["java"] = JsExtensions + bindings["page"] = page + bindings["key"] = key + bindings["book"] = book + bindings["result"] = result + bindings["baseUrl"] = baseUrl + return SCRIPT_ENGINE.eval(jsStr, bindings) + } + + enum class Method { + GET, POST + } + + fun getResponse(): Call { + return when { + method == Method.POST -> { + if (fieldMap.isNotEmpty()) { + HttpHelper + .getApiService(baseUrl) + .postMap(url, fieldMap, headerMap) + } else { + HttpHelper + .getApiService(baseUrl) + .postBody(url, body, headerMap) + } + } + fieldMap.isEmpty() -> HttpHelper + .getApiService(baseUrl) + .get(url, headerMap) + else -> HttpHelper + .getApiService(baseUrl) + .getMap(url, fieldMap, headerMap) + } + } + + fun getResponseAsync(): Deferred> { + return when { + method == Method.POST -> { + if (fieldMap.isNotEmpty()) { + HttpHelper + .getApiService(baseUrl) + .postMapAsync(url, fieldMap, headerMap) + } else { + HttpHelper + .getApiService(baseUrl) + .postBodyAsync(url, body, headerMap) + } + } + fieldMap.isEmpty() -> HttpHelper + .getApiService(baseUrl) + .getAsync(url, headerMap) + else -> HttpHelper + .getApiService(baseUrl) + .getMapAsync(url, fieldMap, headerMap) + } + } +} diff --git a/app/src/main/java/io/legado/app/model/rss/RssParser.kt b/app/src/main/java/io/legado/app/model/rss/RssParser.kt new file mode 100644 index 000000000..b8d56b939 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/rss/RssParser.kt @@ -0,0 +1,128 @@ +package io.legado.app.model.rss + +import io.legado.app.constant.RSSKeywords +import io.legado.app.data.entities.RssArticle +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserException +import org.xmlpull.v1.XmlPullParserFactory +import java.io.IOException +import java.io.StringReader + +object RssParser { + + @Throws(XmlPullParserException::class, IOException::class) + fun parseXML(xml: String, sourceUrl: String): MutableList { + + val articleList = mutableListOf() + var currentArticle = RssArticle() + + val factory = XmlPullParserFactory.newInstance() + factory.isNamespaceAware = false + + val xmlPullParser = factory.newPullParser() + xmlPullParser.setInput(StringReader(xml)) + + // A flag just to be sure of the correct parsing + var insideItem = false + + var eventType = xmlPullParser.eventType + + // Start parsing the xml + loop@ while (eventType != XmlPullParser.END_DOCUMENT) { + + // Start parsing the item + if (eventType == XmlPullParser.START_TAG) { + when { + xmlPullParser.name.equals(RSSKeywords.RSS_ITEM, true) -> + insideItem = true + xmlPullParser.name.equals(RSSKeywords.RSS_ITEM_TITLE, true) -> + if (insideItem) currentArticle.title = xmlPullParser.nextText().trim() + xmlPullParser.name.equals(RSSKeywords.RSS_ITEM_LINK, true) -> + if (insideItem) currentArticle.link = xmlPullParser.nextText().trim() + xmlPullParser.name.equals(RSSKeywords.RSS_ITEM_AUTHOR, true) -> + if (insideItem) currentArticle.author = xmlPullParser.nextText().trim() + xmlPullParser.name.equals(RSSKeywords.RSS_ITEM_CATEGORY, true) -> + if (insideItem) currentArticle.categoryList.add(xmlPullParser.nextText().trim()) + xmlPullParser.name.equals(RSSKeywords.RSS_ITEM_THUMBNAIL, true) -> + if (insideItem) currentArticle.image = + xmlPullParser.getAttributeValue(null, RSSKeywords.RSS_ITEM_URL) + xmlPullParser.name.equals(RSSKeywords.RSS_ITEM_ENCLOSURE, true) -> + if (insideItem) { + val type = + xmlPullParser.getAttributeValue(null, RSSKeywords.RSS_ITEM_TYPE) + if (type != null && type.contains("image/")) { + currentArticle.image = + xmlPullParser.getAttributeValue(null, RSSKeywords.RSS_ITEM_URL) + } + } + xmlPullParser.name + .equals(RSSKeywords.RSS_ITEM_DESCRIPTION, true) -> + if (insideItem) { + val description = xmlPullParser.nextText() + currentArticle.description = description.trim() + if (currentArticle.image == null) { + currentArticle.image = getImageUrl(description) + } + } + xmlPullParser.name.equals(RSSKeywords.RSS_ITEM_CONTENT, true) -> + if (insideItem) { + val content = xmlPullParser.nextText().trim() + currentArticle.content = content + if (currentArticle.image == null) { + currentArticle.image = getImageUrl(content) + } + } + xmlPullParser.name + .equals(RSSKeywords.RSS_ITEM_PUB_DATE, true) -> + if (insideItem) { + val nextTokenType = xmlPullParser.next() + if (nextTokenType == XmlPullParser.TEXT) { + currentArticle.pubDate = xmlPullParser.text.trim() + } + // Skip to be able to find date inside 'tag' tag + continue@loop + } + xmlPullParser.name.equals(RSSKeywords.RSS_ITEM_TIME, true) -> + if (insideItem) currentArticle.pubDate = xmlPullParser.nextText() + } + } else if (eventType == XmlPullParser.END_TAG + && xmlPullParser.name.equals("item", true) + ) { + // The item is correctly parsed + insideItem = false + currentArticle.categories = currentArticle.categoryList.joinToString(",") + currentArticle.origin = sourceUrl + articleList.add(currentArticle) + currentArticle = RssArticle() + } + eventType = xmlPullParser.next() + } + articleList.reverse() + for ((index: Int, item: RssArticle) in articleList.withIndex()) { + item.order = System.currentTimeMillis() + index + } + return articleList + } + + /** + * Finds the first img tag and get the src as featured image + * + * @param input The content in which to search for the tag + * @return The url, if there is one + */ + private fun getImageUrl(input: String): String? { + + var url: String? = null + val patternImg = "()".toPattern() + val matcherImg = patternImg.matcher(input) + if (matcherImg.find()) { + val imgTag = matcherImg.group(1) + val patternLink = "src\\s*=\\s*\"(.+?)\"".toPattern() + val matcherLink = patternLink.matcher(imgTag) + if (matcherLink.find()) { + url = matcherLink.group(1).trim() + } + } + return url + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/rss/RssParserByRule.kt b/app/src/main/java/io/legado/app/model/rss/RssParserByRule.kt new file mode 100644 index 000000000..d5fea37fd --- /dev/null +++ b/app/src/main/java/io/legado/app/model/rss/RssParserByRule.kt @@ -0,0 +1,93 @@ +package io.legado.app.model.rss + +import io.legado.app.App +import io.legado.app.R +import io.legado.app.data.entities.RssArticle +import io.legado.app.data.entities.RssSource +import io.legado.app.model.analyzeRule.AnalyzeRule +import retrofit2.Response + +object RssParserByRule { + + @Throws(Exception::class) + fun parseXML(response: Response, rssSource: RssSource): MutableList { + + val xml = response.body() + if (xml.isNullOrBlank()) { + throw Exception( + App.INSTANCE.getString( + R.string.error_get_web_content, + rssSource.sourceUrl + ) + ) + } + var ruleArticles = rssSource.ruleArticles + if (ruleArticles.isNullOrBlank()) { + return RssParser.parseXML(xml, rssSource.sourceUrl) + } else { + val articleList = mutableListOf() + val analyzeRule = AnalyzeRule() + analyzeRule.setContent(xml, rssSource.sourceUrl) + var reverse = true + if (ruleArticles.startsWith("-")) { + reverse = false + ruleArticles = ruleArticles.substring(1) + } + val collections = analyzeRule.getElements(ruleArticles) + val ruleTitle = analyzeRule.splitSourceRule(rssSource.ruleTitle ?: "") + val rulePubDate = analyzeRule.splitSourceRule(rssSource.rulePubDate ?: "") + val ruleCategories = analyzeRule.splitSourceRule(rssSource.ruleCategories ?: "") + val ruleDescription = analyzeRule.splitSourceRule(rssSource.ruleDescription ?: "") + val ruleImage = analyzeRule.splitSourceRule(rssSource.ruleImage ?: "") + val ruleLink = analyzeRule.splitSourceRule(rssSource.ruleLink ?: "") + for ((index, item) in collections.withIndex()) { + getItem( + item, + analyzeRule, + index == 0, + ruleTitle, + rulePubDate, + ruleCategories, + ruleDescription, + ruleImage, + ruleLink + )?.let { + it.origin = rssSource.sourceUrl + articleList.add(it) + } + } + if (reverse) { + articleList.reverse() + } + for ((index: Int, item: RssArticle) in articleList.withIndex()) { + item.order = System.currentTimeMillis() + index + } + return articleList + } + } + + private fun getItem( + item: Any, + analyzeRule: AnalyzeRule, + printLog: Boolean, + ruleTitle: List, + rulePubDate: List, + ruleCategories: List, + ruleDescription: List, + ruleImage: List, + ruleLink: List + ): RssArticle? { + val rssArticle = RssArticle() + analyzeRule.setContent(item) + rssArticle.title = analyzeRule.getString(ruleTitle) + rssArticle.pubDate = analyzeRule.getString(rulePubDate) + rssArticle.categories = analyzeRule.getString(ruleCategories) + rssArticle.description = analyzeRule.getString(ruleDescription) + rssArticle.image = analyzeRule.getString(ruleImage, true) + rssArticle.link = analyzeRule.getString(ruleLink) + if (rssArticle.title.isBlank()) { + return null + } + return rssArticle + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/webbook/BookChapterList.kt b/app/src/main/java/io/legado/app/model/webbook/BookChapterList.kt new file mode 100644 index 000000000..67968f39f --- /dev/null +++ b/app/src/main/java/io/legado/app/model/webbook/BookChapterList.kt @@ -0,0 +1,177 @@ +package io.legado.app.model.webbook + +import android.text.TextUtils +import io.legado.app.App +import io.legado.app.R +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.rule.TocRule +import io.legado.app.model.analyzeRule.AnalyzeRule +import io.legado.app.model.analyzeRule.AnalyzeUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.withContext + +object BookChapterList { + + suspend fun analyzeChapterList( + coroutineScope: CoroutineScope, + book: Book, + body: String?, + bookSource: BookSource, + baseUrl: String + ): List { + var chapterList = arrayListOf() + body ?: throw Exception( + App.INSTANCE.getString(R.string.error_get_web_content, baseUrl) + ) + SourceDebug.printLog(bookSource.bookSourceUrl, "获取成功:${baseUrl}") + val tocRule = bookSource.getTocRule() + val nextUrlList = arrayListOf(baseUrl) + var reverse = false + var listRule = tocRule.chapterList ?: "" + if (listRule.startsWith("-")) { + reverse = true + listRule = listRule.substring(1) + } + if (listRule.startsWith("+")) { + listRule = listRule.substring(1) + } + var chapterData = + analyzeChapterList(body, baseUrl, tocRule, listRule, book, bookSource, printLog = true) + chapterData.chapterList?.let { + chapterList.addAll(it) + } + if (chapterData.nextUrl.size == 1) { + var nextUrl = chapterData.nextUrl[0] + while (nextUrl.isNotEmpty() && !nextUrlList.contains(nextUrl)) { + nextUrlList.add(nextUrl) + AnalyzeUrl( + ruleUrl = nextUrl, + book = book, + headerMapF = bookSource.getHeaderMap() + ).getResponseAsync().await() + .body()?.let { nextBody -> + chapterData = analyzeChapterList( + nextBody, + nextUrl, + tocRule, + listRule, + book, + bookSource + ) + nextUrl = if (chapterData.nextUrl.isNotEmpty()) + chapterData.nextUrl[0] + else "" + chapterData.chapterList?.let { + chapterList.addAll(it) + } + } + } + } else if (chapterData.nextUrl.size > 1) { + val chapterDataList = arrayListOf>() + for (item in chapterData.nextUrl) { + val data = ChapterData(nextUrl = item) + chapterDataList.add(data) + } + for (item in chapterDataList) { + withContext(coroutineScope.coroutineContext) { + val nextResponse = AnalyzeUrl( + ruleUrl = item.nextUrl, + book = book, + headerMapF = bookSource.getHeaderMap() + ).getResponseAsync().await() + val nextChapterData = analyzeChapterList( + nextResponse.body() ?: "", + item.nextUrl, + tocRule, + listRule, + book, + bookSource, + getNextUrl = false + ) + item.chapterList = nextChapterData.chapterList + } + } + for (item in chapterDataList) { + item.chapterList?.let { + chapterList.addAll(it) + } + } + } + //去重 + if (!reverse) { + chapterList.reverse() + } + val lh = LinkedHashSet(chapterList) + chapterList = ArrayList(lh) + chapterList.reverse() + for ((index, item) in chapterList.withIndex()) { + item.index = index + } + book.latestChapterTitle = chapterList.last().title + if (book.totalChapterNum < chapterList.size) { + book.lastCheckCount = chapterList.size - book.totalChapterNum + } + book.totalChapterNum = chapterList.size + return chapterList + } + + + private fun analyzeChapterList( + body: String, + baseUrl: String, + tocRule: TocRule, + listRule: String, + book: Book, + bookSource: BookSource, + getNextUrl: Boolean = true, + printLog: Boolean = false + ): ChapterData> { + val chapterList = arrayListOf() + val nextUrlList = arrayListOf() + val analyzeRule = AnalyzeRule(book) + analyzeRule.setContent(body, baseUrl) + if (getNextUrl) { + SourceDebug.printLog(bookSource.bookSourceUrl, "获取目录下一页列表", printLog) + analyzeRule.getStringList(tocRule.nextTocUrl ?: "", true)?.let { + for (item in it) { + if (item != baseUrl) { + nextUrlList.add(item) + } + } + } + SourceDebug.printLog( + bookSource.bookSourceUrl, + TextUtils.join(",", nextUrlList), + printLog + ) + } + SourceDebug.printLog(bookSource.bookSourceUrl, "解析目录列表", printLog) + val elements = analyzeRule.getElements(listRule) + SourceDebug.printLog(bookSource.bookSourceUrl, "目录数${elements.size}", printLog) + if (elements.isNotEmpty()) { + SourceDebug.printLog(bookSource.bookSourceUrl, "获取目录", printLog) + val nameRule = analyzeRule.splitSourceRule(tocRule.chapterName ?: "") + val urlRule = analyzeRule.splitSourceRule(tocRule.chapterUrl ?: "") + for (item in elements) { + analyzeRule.setContent(item) + val title = analyzeRule.getString(nameRule) + if (title.isNotEmpty()) { + val bookChapter = BookChapter(bookUrl = book.bookUrl) + bookChapter.title = title + bookChapter.url = analyzeRule.getString(urlRule, true) + if (bookChapter.url.isEmpty()) bookChapter.url = baseUrl + chapterList.add(bookChapter) + } + } + SourceDebug.printLog( + bookSource.bookSourceUrl, + "${chapterList[0].title}${chapterList[0].url}", + printLog + ) + } + return ChapterData(chapterList, nextUrlList) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/webbook/BookContent.kt b/app/src/main/java/io/legado/app/model/webbook/BookContent.kt new file mode 100644 index 000000000..65384aa77 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/webbook/BookContent.kt @@ -0,0 +1,119 @@ +package io.legado.app.model.webbook + +import io.legado.app.App +import io.legado.app.R +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.rule.ContentRule +import io.legado.app.model.analyzeRule.AnalyzeRule +import io.legado.app.model.analyzeRule.AnalyzeUrl +import io.legado.app.utils.NetworkUtils +import io.legado.app.utils.htmlFormat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.withContext +import retrofit2.Response + +object BookContent { + + @Throws(Exception::class) + suspend fun analyzeContent( + coroutineScope: CoroutineScope, + response: Response, + book: Book, + bookChapter: BookChapter, + bookSource: BookSource, + analyzeUrl: AnalyzeUrl, + nextChapterUrlF: String? = null + ): String { + val baseUrl: String = NetworkUtils.getUrl(response) + val body: String? = response.body() + body ?: throw Exception( + App.INSTANCE.getString( + R.string.error_get_web_content, + analyzeUrl.ruleUrl + ) + ) + SourceDebug.printLog(bookSource.bookSourceUrl, "获取成功:${analyzeUrl.ruleUrl}") + val content = StringBuilder() + val nextUrlList = arrayListOf(baseUrl) + val contentRule = bookSource.getContentRule() + var contentData = analyzeContent(body, contentRule, book, baseUrl) + content.append(contentData.content) + if (contentData.nextUrl.size == 1) { + var nextUrl = contentData.nextUrl[0] + val nextChapterUrl = if (!nextChapterUrlF.isNullOrEmpty()) + nextChapterUrlF + else + App.db.bookChapterDao().getChapter(book.bookUrl, bookChapter.index + 1)?.url + while (nextUrl.isNotEmpty() && !nextUrlList.contains(nextUrl)) { + if (!nextChapterUrl.isNullOrEmpty() + && NetworkUtils.getAbsoluteURL(baseUrl, nextUrl) + == NetworkUtils.getAbsoluteURL(baseUrl, nextChapterUrl) + ) break + nextUrlList.add(nextUrl) + AnalyzeUrl( + ruleUrl = nextUrl, + book = book, + headerMapF = bookSource.getHeaderMap() + ).getResponseAsync().await() + .body()?.let { nextBody -> + contentData = analyzeContent(nextBody, contentRule, book, baseUrl) + nextUrl = + if (contentData.nextUrl.isNotEmpty()) contentData.nextUrl[0] else "" + content.append(contentData.content) + } + } + } else if (contentData.nextUrl.size > 1) { + val contentDataList = arrayListOf>() + for (item in contentData.nextUrl) { + if (!nextUrlList.contains(item)) + contentDataList.add(ContentData(nextUrl = item)) + } + for (item in contentDataList) { + withContext(coroutineScope.coroutineContext) { + val nextResponse = AnalyzeUrl( + ruleUrl = item.nextUrl, + book = book, + headerMapF = bookSource.getHeaderMap() + ).getResponseAsync().await() + val nextContentData = analyzeContent( + nextResponse.body() ?: "", + contentRule, + book, + item.nextUrl + ) + item.content = nextContentData.content + } + } + for (item in contentDataList) { + content.append(item.content) + } + } + if (content.isNotEmpty()) { + if (!content[0].toString().startsWith(bookChapter.title)) { + content + .insert(0, "\n") + .insert(0, bookChapter.title) + } + } + return content.toString() + } + + @Throws(Exception::class) + private fun analyzeContent( + body: String, + contentRule: ContentRule, + book: Book, + baseUrl: String + ): ContentData> { + val nextUrlList = arrayListOf() + val analyzeRule = AnalyzeRule(book) + analyzeRule.setContent(body, baseUrl) + analyzeRule.getStringList(contentRule.nextContentUrl ?: "", true)?.let { + nextUrlList.addAll(it) + } + val content = analyzeRule.getString(contentRule.content ?: "")?.htmlFormat() ?: "" + return ContentData(content, nextUrlList) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/webbook/BookInfo.kt b/app/src/main/java/io/legado/app/model/webbook/BookInfo.kt new file mode 100644 index 000000000..a3b1f29ba --- /dev/null +++ b/app/src/main/java/io/legado/app/model/webbook/BookInfo.kt @@ -0,0 +1,71 @@ +package io.legado.app.model.webbook + +import io.legado.app.App +import io.legado.app.R +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookSource +import io.legado.app.model.analyzeRule.AnalyzeRule +import io.legado.app.utils.htmlFormat + +object BookInfo { + + @Throws(Exception::class) + fun analyzeBookInfo( + book: Book, + body: String?, + bookSource: BookSource, + baseUrl: String + ) { + body ?: throw Exception( + App.INSTANCE.getString(R.string.error_get_web_content, baseUrl) + ) + SourceDebug.printLog(bookSource.bookSourceUrl, "获取成功:${baseUrl}") + val infoRule = bookSource.getBookInfoRule() + val analyzeRule = AnalyzeRule(book) + analyzeRule.setContent(body, baseUrl) + infoRule.init?.let { + if (it.isNotEmpty()) { + SourceDebug.printLog(bookSource.bookSourceUrl, "执行详情页初始化规则") + analyzeRule.setContent(analyzeRule.getElement(it)) + } + } + SourceDebug.printLog(bookSource.bookSourceUrl, "获取书名") + analyzeRule.getString(infoRule.name ?: "")?.let { + if (it.isNotEmpty()) book.name = it + } + SourceDebug.printLog(bookSource.bookSourceUrl, book.name) + SourceDebug.printLog(bookSource.bookSourceUrl, "获取作者") + analyzeRule.getString(infoRule.author ?: "")?.let { + if (it.isNotEmpty()) book.author = it + } + SourceDebug.printLog(bookSource.bookSourceUrl, book.author) + SourceDebug.printLog(bookSource.bookSourceUrl, "获取分类") + analyzeRule.getString(infoRule.kind ?: "")?.let { + if (it.isNotEmpty()) book.kind = it + } + SourceDebug.printLog(bookSource.bookSourceUrl, book.kind) + SourceDebug.printLog(bookSource.bookSourceUrl, "获取简介") + analyzeRule.getString(infoRule.intro ?: "")?.let { + if (it.isNotEmpty()) book.intro = it.htmlFormat() + } + SourceDebug.printLog(bookSource.bookSourceUrl, book.intro, isHtml = true) + SourceDebug.printLog(bookSource.bookSourceUrl, "获取字数") + analyzeRule.getString(infoRule.wordCount ?: "")?.let { + if (it.isNotEmpty()) book.wordCount = it + } + SourceDebug.printLog(bookSource.bookSourceUrl, book.wordCount) + SourceDebug.printLog(bookSource.bookSourceUrl, "获取最新章节") + analyzeRule.getString(infoRule.lastChapter ?: "")?.let { + if (it.isNotEmpty()) book.latestChapterTitle = it + } + SourceDebug.printLog(bookSource.bookSourceUrl, book.latestChapterTitle) + SourceDebug.printLog(bookSource.bookSourceUrl, "获取目录Url") + book.tocUrl = analyzeRule.getString(infoRule.tocUrl ?: "", true) ?: baseUrl + if (book.tocUrl.isEmpty()) book.tocUrl = baseUrl + if (book.tocUrl == baseUrl) { + book.tocHtml = body + } + SourceDebug.printLog(bookSource.bookSourceUrl, book.tocUrl) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/webbook/BookList.kt b/app/src/main/java/io/legado/app/model/webbook/BookList.kt new file mode 100644 index 000000000..a896aa3c0 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/webbook/BookList.kt @@ -0,0 +1,206 @@ +package io.legado.app.model.webbook + +import io.legado.app.App +import io.legado.app.R +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.SearchBook +import io.legado.app.help.BookHelp +import io.legado.app.model.analyzeRule.AnalyzeRule +import io.legado.app.model.analyzeRule.AnalyzeUrl +import io.legado.app.utils.NetworkUtils +import retrofit2.Response + +object BookList { + + @Throws(Exception::class) + fun analyzeBookList( + response: Response, + bookSource: BookSource, + analyzeUrl: AnalyzeUrl, + isSearch: Boolean = true + ): ArrayList { + val bookList = ArrayList() + val baseUrl: String = NetworkUtils.getUrl(response) + val body: String? = response.body() + body ?: throw Exception( + App.INSTANCE.getString( + R.string.error_get_web_content, + analyzeUrl.ruleUrl + ) + ) + SourceDebug.printLog(bookSource.bookSourceUrl, "获取成功:${analyzeUrl.ruleUrl}") + val analyzeRule = AnalyzeRule(null) + analyzeRule.setContent(body, baseUrl) + bookSource.bookUrlPattern?.let { + if (baseUrl.matches(it.toRegex())) { + SourceDebug.printLog(bookSource.bookSourceUrl, "url为详情页") + getInfoItem(analyzeRule, bookSource, baseUrl)?.let { searchBook -> + searchBook.infoHtml = body + bookList.add(searchBook) + } + return bookList + } + } + val collections: List + var reverse = false + val bookListRule = when { + isSearch -> bookSource.getSearchRule() + bookSource.getExploreRule().bookList.isNullOrBlank() -> bookSource.getSearchRule() + else -> bookSource.getExploreRule() + } + var ruleList = bookListRule.bookList ?: "" + if (ruleList.startsWith("-")) { + reverse = true + ruleList = ruleList.substring(1) + } + if (ruleList.startsWith("+")) { + ruleList = ruleList.substring(1) + } + SourceDebug.printLog(bookSource.bookSourceUrl, "解析书籍列表") + collections = analyzeRule.getElements(ruleList) + if (collections.isEmpty() && bookSource.bookUrlPattern.isNullOrEmpty()) { + SourceDebug.printLog(bookSource.bookSourceUrl, "列表为空,按详情页解析") + getInfoItem(analyzeRule, bookSource, baseUrl)?.let { searchBook -> + searchBook.infoHtml = body + bookList.add(searchBook) + } + } else { + val ruleName = analyzeRule.splitSourceRule(bookListRule.name ?: "") + val ruleBookUrl = analyzeRule.splitSourceRule(bookListRule.bookUrl ?: "") + val ruleAuthor = analyzeRule.splitSourceRule(bookListRule.author ?: "") + val ruleCoverUrl = analyzeRule.splitSourceRule(bookListRule.coverUrl ?: "") + val ruleIntro = analyzeRule.splitSourceRule(bookListRule.intro ?: "") + val ruleKind = analyzeRule.splitSourceRule(bookListRule.kind ?: "") + val ruleLastChapter = analyzeRule.splitSourceRule(bookListRule.lastChapter ?: "") + val ruleWordCount = analyzeRule.splitSourceRule(bookListRule.wordCount ?: "") + SourceDebug.printLog(bookSource.bookSourceUrl, "列表数为${collections.size}") + for ((index, item) in collections.withIndex()) { + getSearchItem( + item, + analyzeRule, + bookSource, + baseUrl, + index == 0, + ruleName = ruleName, + ruleBookUrl = ruleBookUrl, + ruleAuthor = ruleAuthor, + ruleCoverUrl = ruleCoverUrl, + ruleIntro = ruleIntro, + ruleKind = ruleKind, + ruleLastChapter = ruleLastChapter, + ruleWordCount = ruleWordCount + )?.let { searchBook -> + if (baseUrl == searchBook.bookUrl) { + searchBook.infoHtml = body + } + bookList.add(searchBook) + } + } + if (reverse) { + bookList.reverse() + } + } + return bookList + } + + private fun getInfoItem( + analyzeRule: AnalyzeRule, + bookSource: BookSource, + baseUrl: String + ): SearchBook? { + val searchBook = SearchBook() + searchBook.bookUrl = baseUrl + searchBook.origin = bookSource.bookSourceUrl + searchBook.originName = bookSource.bookSourceName + searchBook.originOrder = bookSource.customOrder + analyzeRule.setBook(searchBook) + with(bookSource.getBookInfoRule()) { + init?.let { + if (it.isNotEmpty()) { + SourceDebug.printLog(bookSource.bookSourceUrl, "执行详情页初始化规则") + analyzeRule.setContent(analyzeRule.getElement(it)) + } + } + SourceDebug.printLog(bookSource.bookSourceUrl, "获取书名") + searchBook.name = analyzeRule.getString(name ?: "") ?: "" + SourceDebug.printLog(bookSource.bookSourceUrl, searchBook.name) + if (searchBook.name.isNotEmpty()) { + SourceDebug.printLog(bookSource.bookSourceUrl, "获取作者") + searchBook.author = BookHelp.formatAuthor(analyzeRule.getString(author ?: "")) + SourceDebug.printLog(bookSource.bookSourceUrl, searchBook.author) + SourceDebug.printLog(bookSource.bookSourceUrl, "获取分类") + searchBook.kind = analyzeRule.getString(kind ?: "") + SourceDebug.printLog(bookSource.bookSourceUrl, searchBook.kind) + SourceDebug.printLog(bookSource.bookSourceUrl, "获取简介") + searchBook.intro = analyzeRule.getString(intro ?: "") + SourceDebug.printLog(bookSource.bookSourceUrl, searchBook.intro, true) + SourceDebug.printLog(bookSource.bookSourceUrl, "获取字数") + searchBook.wordCount = analyzeRule.getString(wordCount ?: "") + SourceDebug.printLog(bookSource.bookSourceUrl, searchBook.wordCount) + SourceDebug.printLog(bookSource.bookSourceUrl, "获取封面Url") + searchBook.coverUrl = analyzeRule.getString(coverUrl ?: "", true) + SourceDebug.printLog(bookSource.bookSourceUrl, searchBook.coverUrl) + SourceDebug.printLog(bookSource.bookSourceUrl, "获取最新章节") + searchBook.latestChapterTitle = analyzeRule.getString(lastChapter ?: "") + SourceDebug.printLog(bookSource.bookSourceUrl, searchBook.latestChapterTitle) + return searchBook + } + } + return null + } + + private fun getSearchItem( + item: Any, + analyzeRule: AnalyzeRule, + bookSource: BookSource, + baseUrl: String, + printLog: Boolean, + ruleName: List, + ruleBookUrl: List, + ruleAuthor: List, + ruleKind: List, + ruleCoverUrl: List, + ruleWordCount: List, + ruleIntro: List, + ruleLastChapter: List + ): SearchBook? { + val searchBook = SearchBook() + searchBook.origin = bookSource.bookSourceUrl + searchBook.originName = bookSource.bookSourceName + searchBook.originOrder = bookSource.customOrder + analyzeRule.setBook(searchBook) + analyzeRule.setContent(item) + SourceDebug.printLog(bookSource.bookSourceUrl, "获取书名", printLog) + searchBook.name = analyzeRule.getString(ruleName) + SourceDebug.printLog(bookSource.bookSourceUrl, searchBook.name, printLog) + if (searchBook.name.isNotEmpty()) { + SourceDebug.printLog(bookSource.bookSourceUrl, "获取书籍Url", printLog) + searchBook.bookUrl = analyzeRule.getString(ruleBookUrl, true) + if (searchBook.bookUrl.isEmpty()) { + searchBook.bookUrl = baseUrl + } + SourceDebug.printLog(bookSource.bookSourceUrl, searchBook.bookUrl, printLog) + SourceDebug.printLog(bookSource.bookSourceUrl, "获取作者", printLog) + searchBook.author = BookHelp.formatAuthor(analyzeRule.getString(ruleAuthor)) + SourceDebug.printLog(bookSource.bookSourceUrl, searchBook.author, printLog) + SourceDebug.printLog(bookSource.bookSourceUrl, "获取分类", printLog) + searchBook.kind = analyzeRule.getString(ruleKind) + SourceDebug.printLog(bookSource.bookSourceUrl, searchBook.kind, printLog) + SourceDebug.printLog(bookSource.bookSourceUrl, "获取简介", printLog) + searchBook.intro = analyzeRule.getString(ruleIntro) + SourceDebug.printLog(bookSource.bookSourceUrl, searchBook.intro, printLog, true) + SourceDebug.printLog(bookSource.bookSourceUrl, "获取字数", printLog) + searchBook.wordCount = analyzeRule.getString(ruleWordCount) + SourceDebug.printLog(bookSource.bookSourceUrl, searchBook.wordCount, printLog) + SourceDebug.printLog(bookSource.bookSourceUrl, "获取封面Url", printLog) + searchBook.coverUrl = analyzeRule.getString(ruleCoverUrl, true) + SourceDebug.printLog(bookSource.bookSourceUrl, searchBook.coverUrl, printLog) + SourceDebug.printLog(bookSource.bookSourceUrl, "获取最新章节", printLog) + searchBook.latestChapterTitle = analyzeRule.getString(ruleLastChapter) + SourceDebug.printLog(bookSource.bookSourceUrl, searchBook.latestChapterTitle, printLog) + return searchBook + } + return null + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/webbook/ChapterData.kt b/app/src/main/java/io/legado/app/model/webbook/ChapterData.kt new file mode 100644 index 000000000..bbbf6060b --- /dev/null +++ b/app/src/main/java/io/legado/app/model/webbook/ChapterData.kt @@ -0,0 +1,8 @@ +package io.legado.app.model.webbook + +import io.legado.app.data.entities.BookChapter + +data class ChapterData( + var chapterList: List? = null, + var nextUrl: T +) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/webbook/ContentData.kt b/app/src/main/java/io/legado/app/model/webbook/ContentData.kt new file mode 100644 index 000000000..195778ce8 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/webbook/ContentData.kt @@ -0,0 +1,6 @@ +package io.legado.app.model.webbook + +data class ContentData( + var content: String = "", + var nextUrl: T +) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/webbook/SourceDebug.kt b/app/src/main/java/io/legado/app/model/webbook/SourceDebug.kt new file mode 100644 index 000000000..5348ea070 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/webbook/SourceDebug.kt @@ -0,0 +1,148 @@ +package io.legado.app.model.webbook + +import android.annotation.SuppressLint +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.help.coroutine.CompositeCoroutine +import io.legado.app.model.WebBook +import io.legado.app.utils.htmlFormat +import io.legado.app.utils.isAbsUrl +import java.text.SimpleDateFormat +import java.util.* + +class SourceDebug(private val webBook: WebBook, callback: Callback) { + + companion object { + private var debugSource: String? = null + private var callback: Callback? = null + private val tasks: CompositeCoroutine = CompositeCoroutine() + + @SuppressLint("ConstantLocale") + private val DEBUG_TIME_FORMAT = SimpleDateFormat("[mm:ss.SSS]", Locale.getDefault()) + private var startTime: Long = System.currentTimeMillis() + + @Synchronized + fun printLog( + sourceUrl: String?, + msg: String?, + print: Boolean = true, + isHtml: Boolean = false, + showTime: Boolean = true, + state: Int = 1 + ) { + if (debugSource != sourceUrl || callback == null || !print) return + var printMsg = msg ?: "" + if (isHtml) { + printMsg = printMsg.htmlFormat() + } + if (showTime) { + printMsg = + "${DEBUG_TIME_FORMAT.format(Date(System.currentTimeMillis() - startTime))} $printMsg" + } + callback?.printLog(state, printMsg) + } + + fun cancelDebug(destroy: Boolean = false) { + tasks.clear() + + if (destroy) { + debugSource = null + callback = null + } + } + + } + + init { + debugSource = webBook.sourceUrl + SourceDebug.callback = callback + } + + fun startDebug(key: String) { + cancelDebug() + startTime = System.currentTimeMillis() + if (key.isAbsUrl()) { + val book = Book() + book.origin = webBook.sourceUrl + book.bookUrl = key + printLog(webBook.sourceUrl, "开始访问$key") + infoDebug(book) + } else { + printLog(webBook.sourceUrl, "开始搜索关键字$key") + searchDebug(key) + } + } + + private fun searchDebug(key: String) { + val search = webBook.searchBook(key, 1) + .onSuccess { searchBooks -> + searchBooks?.let { + if (searchBooks.isNotEmpty()) { + printLog(debugSource, "搜索完成") + printLog(debugSource, "", showTime = false) + infoDebug(searchBooks[0].toBook()) + } else { + printLog(debugSource, "未获取到书籍", state = -1) + } + } + } + .onError { + printLog(debugSource, it.localizedMessage, state = -1) + } + tasks.add(search) + } + + private fun infoDebug(book: Book) { + printLog(debugSource, "开始获取详情页") + val info = webBook.getBookInfo(book) + .onSuccess { + printLog(debugSource, "详情页完成") + printLog(debugSource, "", showTime = false) + tocDebug(book) + } + .onError { + printLog(debugSource, it.localizedMessage, state = -1) + } + tasks.add(info) + } + + private fun tocDebug(book: Book) { + printLog(debugSource, "开始获取目录页") + val chapterList = webBook.getChapterList(book) + .onSuccess { chapterList -> + chapterList?.let { + if (it.isNotEmpty()) { + printLog(debugSource, "目录完成") + printLog(debugSource, "", showTime = false) + val nextChapterUrl = if (it.size > 1) it[1].url else null + contentDebug(book, it[0], nextChapterUrl) + } else { + printLog(debugSource, "目录列表为空", state = -1) + } + } + } + .onError { + printLog(debugSource, it.localizedMessage, state = -1) + } + tasks.add(chapterList) + } + + private fun contentDebug(book: Book, bookChapter: BookChapter, nextChapterUrl: String?) { + printLog(debugSource, "开始获取内容") + val content = webBook.getContent(book, bookChapter, nextChapterUrl) + .onSuccess { content -> + content?.let { + printLog(debugSource, it, state = 1000) + } + } + .onError { + printLog(debugSource, it.localizedMessage, state = -1) + } + tasks.add(content) + } + + interface Callback { + fun printLog(state: Int, msg: String) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/receiver/MediaButtonReceiver.kt b/app/src/main/java/io/legado/app/receiver/MediaButtonReceiver.kt new file mode 100644 index 000000000..3cad86893 --- /dev/null +++ b/app/src/main/java/io/legado/app/receiver/MediaButtonReceiver.kt @@ -0,0 +1,52 @@ +package io.legado.app.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.view.KeyEvent +import io.legado.app.constant.Bus +import io.legado.app.help.ActivityHelp +import io.legado.app.ui.book.read.ReadBookActivity +import io.legado.app.utils.postEvent + + +/** + * Created by GKF on 2018/1/6. + * 监听耳机键 + */ + +class MediaButtonReceiver : BroadcastReceiver() { + + companion object { + + fun handleIntent(context: Context, intent: Intent): Boolean { + val intentAction = intent.action + val keyEventAction = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT)?.action + if (Intent.ACTION_MEDIA_BUTTON == intentAction) { + if (keyEventAction == KeyEvent.ACTION_DOWN) { + readAloud(context) + return true + } + } + return false + } + + private fun readAloud(context: Context) { + if (!ActivityHelp.isExist(ReadBookActivity::class.java)) { + val intent = Intent(context, ReadBookActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.putExtra("readAloud", true) + context.startActivity(intent) + } else { + postEvent(Bus.READ_ALOUD_BUTTON, true) + } + } + } + + override fun onReceive(context: Context, intent: Intent) { + if (handleIntent(context, intent) && isOrderedBroadcast) { + abortBroadcast() + } + } + +} diff --git a/app/src/main/java/io/legado/app/receiver/SharedReceiverActivity.kt b/app/src/main/java/io/legado/app/receiver/SharedReceiverActivity.kt new file mode 100644 index 000000000..e6b78f0f5 --- /dev/null +++ b/app/src/main/java/io/legado/app/receiver/SharedReceiverActivity.kt @@ -0,0 +1,57 @@ +package io.legado.app.receiver + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import io.legado.app.ui.book.search.SearchActivity +import io.legado.app.ui.main.MainActivity +import org.jetbrains.anko.startActivity + +class SharedReceiverActivity : AppCompatActivity() { + + private val receivingType = "text/plain" + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initIntent() + finish() + } + + private fun initIntent() { + if (Intent.ACTION_SEND == intent.action && intent.type == receivingType) { + intent.getStringExtra(Intent.EXTRA_TEXT)?.let { + if (openUrl(it)) { + startActivity(Pair("key", it)) + } + } + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + && Intent.ACTION_PROCESS_TEXT == intent.action + && intent.type == receivingType + ) { + intent.getStringExtra(Intent.EXTRA_PROCESS_TEXT)?.let { + if (openUrl(it)) { + startActivity(Pair("key", it)) + } + } + } + } + + private fun openUrl(text: String): Boolean { + if (text.isBlank()) { + return false + } + val urls = text.split("\\s".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val result = StringBuilder() + for (url in urls) { + if (url.matches("http.+".toRegex())) + result.append("\n").append(url.trim { it <= ' ' }) + } + return if (result.length > 1) { + startActivity() + false + } else { + true + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/receiver/TimeElectricityReceiver.kt b/app/src/main/java/io/legado/app/receiver/TimeElectricityReceiver.kt new file mode 100644 index 000000000..613db4fe4 --- /dev/null +++ b/app/src/main/java/io/legado/app/receiver/TimeElectricityReceiver.kt @@ -0,0 +1,41 @@ +package io.legado.app.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.BatteryManager +import io.legado.app.constant.Bus +import io.legado.app.utils.postEvent + + +class TimeElectricityReceiver : BroadcastReceiver() { + + companion object { + + fun register(context: Context): TimeElectricityReceiver { + val receiver = TimeElectricityReceiver() + val filter = IntentFilter() + filter.addAction(Intent.ACTION_TIME_TICK) + filter.addAction(Intent.ACTION_BATTERY_CHANGED) + context.registerReceiver(receiver, filter) + return receiver + } + + } + + override fun onReceive(context: Context?, intent: Intent?) { + intent?.action?.let { + when (it) { + Intent.ACTION_TIME_TICK -> { + postEvent(Bus.TIME_CHANGED, "") + } + Intent.ACTION_BATTERY_CHANGED -> { + val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) + postEvent(Bus.BATTERY_CHANGED, level) + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/service/BaseReadAloudService.kt b/app/src/main/java/io/legado/app/service/BaseReadAloudService.kt new file mode 100644 index 000000000..9af47f861 --- /dev/null +++ b/app/src/main/java/io/legado/app/service/BaseReadAloudService.kt @@ -0,0 +1,341 @@ +package io.legado.app.service + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.BitmapFactory +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.os.Build +import android.os.Handler +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import androidx.annotation.CallSuper +import androidx.core.app.NotificationCompat +import io.legado.app.App +import io.legado.app.R +import io.legado.app.base.BaseService +import io.legado.app.constant.Action +import io.legado.app.constant.AppConst +import io.legado.app.constant.Bus +import io.legado.app.constant.Status +import io.legado.app.help.IntentDataHelp +import io.legado.app.help.IntentHelp +import io.legado.app.help.MediaHelp +import io.legado.app.receiver.MediaButtonReceiver +import io.legado.app.ui.book.read.ReadBookActivity +import io.legado.app.ui.widget.page.TextChapter +import io.legado.app.utils.getPrefBoolean +import io.legado.app.utils.postEvent + +abstract class BaseReadAloudService : BaseService(), + AudioManager.OnAudioFocusChangeListener { + + companion object { + var isRun = false + var timeMinute: Int = 0 + + } + + private val handler = Handler() + private lateinit var audioManager: AudioManager + private lateinit var mFocusRequest: AudioFocusRequest + private var broadcastReceiver: BroadcastReceiver? = null + private var mediaSessionCompat: MediaSessionCompat? = null + var pause = false + var title: String = "" + private var subtitle: String = "" + val contentList = arrayListOf() + var nowSpeak: Int = 0 + var readAloudNumber: Int = 0 + var textChapter: TextChapter? = null + var pageIndex = 0 + private val dsRunnable: Runnable? = Runnable { doDs() } + + override fun onCreate() { + super.onCreate() + isRun = true + audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + mFocusRequest = MediaHelp.getFocusRequest(this) + } + initMediaSession() + initBroadcastReceiver() + upNotification() + upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING) + } + + override fun onDestroy() { + super.onDestroy() + isRun = false + unregisterReceiver(broadcastReceiver) + postEvent(Bus.ALOUD_STATE, Status.STOP) + upMediaSessionPlaybackState(PlaybackStateCompat.STATE_STOPPED) + mediaSessionCompat?.release() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + intent?.action?.let { action -> + when (action) { + Action.play -> { + title = intent.getStringExtra("title") ?: "" + subtitle = intent.getStringExtra("subtitle") ?: "" + pageIndex = intent.getIntExtra("pageIndex", 0) + newReadAloud( + intent.getStringExtra("dataKey"), + intent.getBooleanExtra("play", true) + ) + } + Action.pause -> pauseReadAloud(true) + Action.resume -> resumeReadAloud() + Action.upTtsSpeechRate -> upSpeechRate(true) + Action.prevParagraph -> prevP() + Action.nextParagraph -> nextP() + Action.addTimer -> addTimer() + Action.setTimer -> setTimer(intent.getIntExtra("minute", 0)) + else -> stopSelf() + } + } + return super.onStartCommand(intent, flags, startId) + } + + private fun newReadAloud(dataKey: String?, play: Boolean) { + dataKey?.let { + textChapter = IntentDataHelp.getData(dataKey) as? TextChapter + textChapter?.let { textChapter -> + nowSpeak = 0 + readAloudNumber = textChapter.getReadLength(pageIndex) + contentList.clear() + if (getPrefBoolean("readAloudByPage")) { + for (index in pageIndex..textChapter.lastIndex()) { + textChapter.page(index)?.text?.split("\n")?.let { + contentList.addAll(it) + } + } + } else { + contentList.addAll(textChapter.getUnRead(pageIndex).split("\n")) + } + if (play) play() + } ?: stopSelf() + } ?: stopSelf() + } + + open fun play() { + postEvent(Bus.ALOUD_STATE, Status.PLAY) + upNotification() + } + + @CallSuper + open fun pauseReadAloud(pause: Boolean) { + postEvent(Bus.ALOUD_STATE, Status.PAUSE) + this.pause = pause + upNotification() + upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PAUSED) + } + + @CallSuper + open fun resumeReadAloud() { + pause = false + upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING) + } + + abstract fun upSpeechRate(reset: Boolean = false) + + abstract fun prevP() + + abstract fun nextP() + + private fun setTimer(minute: Int) { + timeMinute = minute + if (minute > 0) { + handler.removeCallbacks(dsRunnable) + handler.postDelayed(dsRunnable, 60000) + } + upNotification() + } + + private fun addTimer() { + if (timeMinute == 60) { + timeMinute = 0 + handler.removeCallbacks(dsRunnable) + } else { + timeMinute += 10 + if (timeMinute > 60) timeMinute = 60 + handler.removeCallbacks(dsRunnable) + handler.postDelayed(dsRunnable, 60000) + } + postEvent(Bus.TTS_DS, timeMinute) + upNotification() + } + + /** + * 定时 + */ + private fun doDs() { + if (!pause) { + timeMinute-- + if (timeMinute == 0) { + stopSelf() + } else if (timeMinute > 0) { + handler.postDelayed(dsRunnable, 60000) + } + } + postEvent(Bus.TTS_DS, timeMinute) + upNotification() + } + + /** + * @return 音频焦点 + */ + fun requestFocus(): Boolean { + val request: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + audioManager.requestAudioFocus(mFocusRequest) + } else { + @Suppress("DEPRECATION") + audioManager.requestAudioFocus( + this, + AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN + ) + } + return request == AudioManager.AUDIOFOCUS_REQUEST_GRANTED + } + + /** + * 更新媒体状态 + */ + private fun upMediaSessionPlaybackState(state: Int) { + mediaSessionCompat?.setPlaybackState( + PlaybackStateCompat.Builder() + .setActions(MediaHelp.MEDIA_SESSION_ACTIONS) + .setState(state, nowSpeak.toLong(), 1f) + .build() + ) + } + + /** + * 初始化MediaSession, 注册多媒体按钮 + */ + private fun initMediaSession() { + mediaSessionCompat = MediaSessionCompat(this, "readAloud") + mediaSessionCompat?.setCallback(object : MediaSessionCompat.Callback() { + override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { + return MediaButtonReceiver.handleIntent(this@BaseReadAloudService, mediaButtonEvent) + } + }) + mediaSessionCompat?.setMediaButtonReceiver( + PendingIntent.getBroadcast( + this, + 0, + Intent( + Intent.ACTION_MEDIA_BUTTON, + null, + App.INSTANCE, + MediaButtonReceiver::class.java + ), + PendingIntent.FLAG_CANCEL_CURRENT + ) + ) + mediaSessionCompat?.isActive = true + } + + /** + * 断开耳机监听 + */ + private fun initBroadcastReceiver() { + broadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (AudioManager.ACTION_AUDIO_BECOMING_NOISY == intent.action) { + pauseReadAloud(true) + } + } + } + val intentFilter = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY) + registerReceiver(broadcastReceiver, intentFilter) + } + + /** + * 音频焦点变化 + */ + override fun onAudioFocusChange(focusChange: Int) { + when (focusChange) { + AudioManager.AUDIOFOCUS_GAIN -> { + // 重新获得焦点, 可做恢复播放,恢复后台音量的操作 + if (!pause) resumeReadAloud() + } + AudioManager.AUDIOFOCUS_LOSS -> { + // 永久丢失焦点除非重新主动获取,这种情况是被其他播放器抢去了焦点, 为避免与其他播放器混音,可将音乐暂停 + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + // 暂时丢失焦点,这种情况是被其他应用申请了短暂的焦点,可压低后台音量 + if (!pause) pauseReadAloud(false) + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { + // 短暂丢失焦点,这种情况是被其他应用申请了短暂的焦点希望其他声音能压低音量(或者关闭声音)凸显这个声音(比如短信提示音), + } + } + } + + /** + * 更新通知 + */ + private fun upNotification() { + var nTitle: String = when { + pause -> getString(R.string.read_aloud_pause) + timeMinute in 1..60 -> getString( + R.string.read_aloud_timer, + timeMinute + ) + else -> getString(R.string.read_aloud_t) + } + nTitle += ": $title" + var nSubtitle = subtitle + if (subtitle.isEmpty()) + nSubtitle = getString(R.string.read_aloud_s) + val builder = NotificationCompat.Builder(this, AppConst.channelIdReadAloud) + .setSmallIcon(R.drawable.ic_volume_up) + .setLargeIcon(BitmapFactory.decodeResource(resources, R.drawable.icon_read_book)) + .setOngoing(true) + .setContentTitle(nTitle) + .setContentText(nSubtitle) + .setContentIntent( + IntentHelp.activityPendingIntent(this, "activity") + ) + if (pause) { + builder.addAction( + R.drawable.ic_play_24dp, + getString(R.string.resume), + aloudServicePendingIntent(Action.resume) + ) + } else { + builder.addAction( + R.drawable.ic_pause_24dp, + getString(R.string.pause), + aloudServicePendingIntent(Action.pause) + ) + } + builder.addAction( + R.drawable.ic_stop_black_24dp, + getString(R.string.stop), + aloudServicePendingIntent(Action.stop) + ) + builder.addAction( + R.drawable.ic_time_add_24dp, + getString(R.string.set_timer), + aloudServicePendingIntent(Action.addTimer) + ) + builder.setStyle( + androidx.media.app.NotificationCompat.MediaStyle() + .setMediaSession(mediaSessionCompat?.sessionToken) + .setShowActionsInCompactView(0, 1, 2) + ) + builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + val notification = builder.build() + startForeground(112201, notification) + } + + abstract fun aloudServicePendingIntent(actionStr: String): PendingIntent? + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/service/CheckSourceService.kt b/app/src/main/java/io/legado/app/service/CheckSourceService.kt new file mode 100644 index 000000000..5fa36c1ba --- /dev/null +++ b/app/src/main/java/io/legado/app/service/CheckSourceService.kt @@ -0,0 +1,54 @@ +package io.legado.app.service + +import android.content.Intent +import androidx.core.app.NotificationCompat +import io.legado.app.R +import io.legado.app.base.BaseService +import io.legado.app.constant.Action +import io.legado.app.constant.AppConst +import io.legado.app.data.entities.BookSource +import io.legado.app.help.IntentHelp +import io.legado.app.ui.book.source.manage.BookSourceActivity + +class CheckSourceService : BaseService() { + + private var sourceList: List? = null + + override fun onCreate() { + super.onCreate() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return super.onStartCommand(intent, flags, startId) + } + + override fun onDestroy() { + super.onDestroy() + } + + /** + * 更新通知 + */ + private fun updateNotification(state: Int, msg: String) { + val builder = NotificationCompat.Builder(this, AppConst.channelIdReadAloud) + .setSmallIcon(R.drawable.ic_network_check) + .setOngoing(true) + .setContentTitle(getString(R.string.check_book_source)) + .setContentText(msg) + .setContentIntent( + IntentHelp.activityPendingIntent(this, "activity") + ) + .addAction( + R.drawable.ic_stop_black_24dp, + getString(R.string.cancel), + IntentHelp.servicePendingIntent(this, Action.stop) + ) + sourceList?.let { + builder.setProgress(it.size, state, false) + } + builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + val notification = builder.build() + startForeground(112202, notification) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/service/DownloadService.kt b/app/src/main/java/io/legado/app/service/DownloadService.kt new file mode 100644 index 000000000..5a1233708 --- /dev/null +++ b/app/src/main/java/io/legado/app/service/DownloadService.kt @@ -0,0 +1,8 @@ +package io.legado.app.service + +import io.legado.app.base.BaseService + +class DownloadService : BaseService() { + + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/service/HttpReadAloudService.kt b/app/src/main/java/io/legado/app/service/HttpReadAloudService.kt new file mode 100644 index 000000000..fb25b4e2d --- /dev/null +++ b/app/src/main/java/io/legado/app/service/HttpReadAloudService.kt @@ -0,0 +1,145 @@ +package io.legado.app.service + +import android.app.PendingIntent +import android.media.MediaPlayer +import io.legado.app.constant.Bus +import io.legado.app.data.api.IHttpPostApi +import io.legado.app.help.FileHelp +import io.legado.app.help.IntentHelp +import io.legado.app.help.http.HttpHelper +import io.legado.app.utils.getPrefInt +import io.legado.app.utils.getPrefString +import io.legado.app.utils.postEvent +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jetbrains.anko.toast +import java.io.File +import java.io.FileInputStream + +class HttpReadAloudService : BaseReadAloudService(), + MediaPlayer.OnPreparedListener, + MediaPlayer.OnErrorListener, + MediaPlayer.OnCompletionListener { + + private var mediaPlayer = MediaPlayer() + + override fun onCreate() { + super.onCreate() + mediaPlayer.setOnErrorListener(this) + mediaPlayer.setOnPreparedListener(this) + mediaPlayer.setOnCompletionListener(this) + } + + override fun onDestroy() { + super.onDestroy() + mediaPlayer.release() + } + + private fun getAudioBody(): Map { + val spd = (getPrefInt("ttsSpeechRate", 25) + 5) / 5 + val per = getPrefString("ttsSpeechPer") ?: "0" + return mapOf( + Pair("idx", "1"), + Pair("tex", contentList[nowSpeak]), + Pair("cuid", "baidu_speech_demo "), + Pair("cod", "2"), + Pair("lan", "zh"), + Pair("ctp", "1"), + Pair("pdt", "1"), + Pair("spd", spd.toString()), + Pair("per", per), + Pair("vol", "5"), + Pair("pit", "5"), + Pair("_res_tag_", "audio") + ) + } + + override fun play() { + if (contentList.isEmpty()) return + launch(IO) { + if (requestFocus()) { + val bytes = HttpHelper.getByteRetrofit("http://tts.baidu.com") + .create(IHttpPostApi::class.java) + .postMapByte("http://tts.baidu.com/text2audio", getAudioBody(), mapOf()) + .execute().body() + if (bytes == null) { + withContext(Main) { + toast("访问失败") + } + } else { + val file = + FileHelp.getFile(cacheDir.absolutePath + File.separator + "bdTts.mp3") + file.writeBytes(bytes) + mediaPlayer.reset() + mediaPlayer.setDataSource(FileInputStream(file).fd) + mediaPlayer.prepareAsync() + } + } + } + } + + override fun pauseReadAloud(pause: Boolean) { + super.pauseReadAloud(pause) + mediaPlayer.pause() + } + + override fun resumeReadAloud() { + super.resumeReadAloud() + mediaPlayer.start() + } + + override fun upSpeechRate(reset: Boolean) { + play() + } + + override fun prevP() { + if (nowSpeak > 0) { + mediaPlayer.stop() + nowSpeak-- + readAloudNumber -= contentList[nowSpeak].length.minus(1) + play() + } + } + + override fun nextP() { + if (nowSpeak < contentList.size - 1) { + mediaPlayer.stop() + readAloudNumber += contentList[nowSpeak].length.plus(1) + nowSpeak++ + play() + } + } + + override fun onPrepared(mp: MediaPlayer?) { + super.play() + if (pause) return + mp?.start() + textChapter?.let { + if (readAloudNumber + 1 > it.getReadLength(pageIndex + 1)) { + pageIndex++ + postEvent(Bus.TTS_TURN_PAGE, 1) + } + } + postEvent(Bus.TTS_START, readAloudNumber + 1) + } + + override fun onError(mp: MediaPlayer?, what: Int, extra: Int): Boolean { + return true + } + + override fun onCompletion(mp: MediaPlayer?) { + readAloudNumber += contentList[nowSpeak].length + 1 + if (nowSpeak < contentList.size) { + nowSpeak++ + play() + } else { + postEvent(Bus.TTS_TURN_PAGE, 2) + } + } + + override fun aloudServicePendingIntent(actionStr: String): PendingIntent? { + return IntentHelp.servicePendingIntent(this, actionStr) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/service/ShareService.kt b/app/src/main/java/io/legado/app/service/ShareService.kt new file mode 100644 index 000000000..49624ecc3 --- /dev/null +++ b/app/src/main/java/io/legado/app/service/ShareService.kt @@ -0,0 +1,8 @@ +package io.legado.app.service + +import io.legado.app.base.BaseService + +class ShareService : BaseService() { + + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/service/TTSReadAloudService.kt b/app/src/main/java/io/legado/app/service/TTSReadAloudService.kt new file mode 100644 index 000000000..e3fc37479 --- /dev/null +++ b/app/src/main/java/io/legado/app/service/TTSReadAloudService.kt @@ -0,0 +1,194 @@ +package io.legado.app.service + +import android.app.PendingIntent +import android.os.Build +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import io.legado.app.R +import io.legado.app.constant.AppConst +import io.legado.app.constant.Bus +import io.legado.app.help.IntentHelp +import io.legado.app.help.MediaHelp +import io.legado.app.utils.getPrefBoolean +import io.legado.app.utils.getPrefInt +import io.legado.app.utils.postEvent +import kotlinx.coroutines.launch +import org.jetbrains.anko.toast +import java.util.* + +class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener { + + companion object { + var textToSpeech: TextToSpeech? = null + + fun clearTTS() { + textToSpeech?.stop() + textToSpeech?.shutdown() + textToSpeech = null + } + } + + private var ttsIsSuccess: Boolean = false + + override fun onCreate() { + super.onCreate() + textToSpeech = TextToSpeech(this, this) + upSpeechRate() + } + + override fun onDestroy() { + super.onDestroy() + clearTTS() + } + + override fun onInit(status: Int) { + launch { + if (status == TextToSpeech.SUCCESS) { + val result = textToSpeech?.setLanguage(Locale.CHINA) + if (result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) { + toast(R.string.tts_fix) + IntentHelp.toTTSSetting(this@TTSReadAloudService) + stopSelf() + } else { + textToSpeech?.setOnUtteranceProgressListener(TTSUtteranceListener()) + ttsIsSuccess = true + play() + } + } else { + toast(R.string.tts_init_failed) + } + } + } + + @Suppress("DEPRECATION") + override fun play() { + if (contentList.isEmpty() || !ttsIsSuccess) { + return + } + if (requestFocus()) { + MediaHelp.playSilentSound(this) + super.play() + for (i in nowSpeak until contentList.size) { + if (i == 0) { + speak(contentList[i], TextToSpeech.QUEUE_FLUSH, AppConst.APP_TAG + i) + } else { + speak(contentList[i], TextToSpeech.QUEUE_ADD, AppConst.APP_TAG + i) + } + } + } + } + + private fun speak(content: String, queueMode: Int, utteranceId: String) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + textToSpeech?.speak(content, queueMode, null, utteranceId) + } else { + @Suppress("DEPRECATION") + textToSpeech?.speak( + content, + queueMode, + hashMapOf(Pair(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, utteranceId)) + ) + } + } + + /** + * 更新朗读速度 + */ + override fun upSpeechRate(reset: Boolean) { + if (this.getPrefBoolean("ttsFollowSys", true)) { + if (reset) { + clearTTS() + textToSpeech = TextToSpeech(this, this) + } + } else { + textToSpeech?.setSpeechRate((this.getPrefInt("ttsSpeechRate", 5) + 5) / 10f) + } + } + + /** + * 上一段 + */ + override fun prevP() { + if (nowSpeak > 0) { + textToSpeech?.stop() + nowSpeak-- + readAloudNumber -= contentList[nowSpeak].length.minus(1) + play() + } + } + + /** + * 下一段 + */ + override fun nextP() { + if (nowSpeak < contentList.size - 1) { + textToSpeech?.stop() + readAloudNumber += contentList[nowSpeak].length.plus(1) + nowSpeak++ + play() + } + } + + /** + * 暂停朗读 + */ + override fun pauseReadAloud(pause: Boolean) { + super.pauseReadAloud(pause) + textToSpeech?.stop() + } + + /** + * 恢复朗读 + */ + override fun resumeReadAloud() { + super.resumeReadAloud() + play() + } + + /** + * 朗读监听 + */ + private inner class TTSUtteranceListener : UtteranceProgressListener() { + + override fun onStart(s: String) { + textChapter?.let { + if (readAloudNumber + 1 > it.getReadLength(pageIndex + 1)) { + pageIndex++ + postEvent(Bus.TTS_TURN_PAGE, 1) + } + } + postEvent(Bus.TTS_START, readAloudNumber + 1) + } + + override fun onDone(s: String) { + readAloudNumber += contentList[nowSpeak].length + 1 + nowSpeak++ + if (nowSpeak >= contentList.size) { + postEvent(Bus.TTS_TURN_PAGE, 2) + } + } + + override fun onRangeStart(utteranceId: String?, start: Int, end: Int, frame: Int) { + super.onRangeStart(utteranceId, start, end, frame) + textChapter?.let { + if (readAloudNumber + start > it.getReadLength(pageIndex + 1)) { + pageIndex++ + postEvent(Bus.TTS_TURN_PAGE, 1) + postEvent(Bus.TTS_START, readAloudNumber + start) + } + } + } + + override fun onError(s: String) { + launch { + toast(s) + } + } + + } + + override fun aloudServicePendingIntent(actionStr: String): PendingIntent? { + return IntentHelp.servicePendingIntent(this, actionStr) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/service/UpdateService.kt b/app/src/main/java/io/legado/app/service/UpdateService.kt new file mode 100644 index 000000000..9841d1b9a --- /dev/null +++ b/app/src/main/java/io/legado/app/service/UpdateService.kt @@ -0,0 +1,8 @@ +package io.legado.app.service + +import io.legado.app.base.BaseService + +class UpdateService : BaseService() { + + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/service/WebService.kt b/app/src/main/java/io/legado/app/service/WebService.kt new file mode 100644 index 000000000..3dcefd09d --- /dev/null +++ b/app/src/main/java/io/legado/app/service/WebService.kt @@ -0,0 +1,44 @@ +package io.legado.app.service + +import android.content.Context +import android.content.Intent +import io.legado.app.base.BaseService +import io.legado.app.constant.Action +import org.jetbrains.anko.startService + +class WebService : BaseService() { + + companion object { + var isRun = false + + fun start(context: Context) { + context.startService() + } + + fun stop(context: Context) { + if (isRun) { + val intent = Intent(context, WebService::class.java) + intent.action = Action.stop + context.startService(intent) + } + } + + } + + override fun onCreate() { + super.onCreate() + isRun = true + } + + override fun onDestroy() { + super.onDestroy() + isRun = false + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + Action.stop -> stopSelf() + } + return super.onStartCommand(intent, flags, startId) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/about/AboutActivity.kt b/app/src/main/java/io/legado/app/ui/about/AboutActivity.kt index 4e47cc604..f5636c134 100644 --- a/app/src/main/java/io/legado/app/ui/about/AboutActivity.kt +++ b/app/src/main/java/io/legado/app/ui/about/AboutActivity.kt @@ -1,13 +1,44 @@ package io.legado.app.ui.about +import android.content.Intent +import android.net.Uri import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity +import android.view.Menu +import android.view.MenuItem import io.legado.app.R +import io.legado.app.base.BaseActivity +import org.jetbrains.anko.toast -class AboutActivity : AppCompatActivity() { +class AboutActivity : BaseActivity(R.layout.activity_about) { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_about) + override fun onActivityCreated(savedInstanceState: Bundle?) { + val fTag = "aboutFragment" + var aboutFragment = supportFragmentManager.findFragmentByTag(fTag) + if (aboutFragment == null) aboutFragment = AboutFragment() + supportFragmentManager.beginTransaction() + .replace(R.id.fl_fragment, aboutFragment, fTag) + .commit() + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.about, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_scoring -> openIntent("market://details?id=$packageName") + } + return super.onCompatOptionsItemSelected(item) + } + + private fun openIntent(address: String) { + try { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(address) + startActivity(intent) + } catch (e: Exception) { + toast(R.string.can_not_open) + } } } diff --git a/app/src/main/java/io/legado/app/ui/about/AboutFragment.kt b/app/src/main/java/io/legado/app/ui/about/AboutFragment.kt new file mode 100644 index 000000000..093c5a9ec --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/about/AboutFragment.kt @@ -0,0 +1,42 @@ +package io.legado.app.ui.about + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import io.legado.app.App +import io.legado.app.R +import io.legado.app.utils.toast + +class AboutFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.about) + findPreference("version")?.summary = App.INSTANCE.versionName + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + listView.overScrollMode = View.OVER_SCROLL_NEVER + } + + override fun onPreferenceTreeClick(preference: Preference?): Boolean { + when (preference?.key) { + "mail" -> openIntent(Intent.ACTION_SENDTO, "mailto:kunfei.ge@gmail.com") + "git" -> openIntent(Intent.ACTION_VIEW, getString(R.string.this_github_url)) + } + return super.onPreferenceTreeClick(preference) + } + + private fun openIntent(intentName: String, address: String) { + try { + val intent = Intent(intentName) + intent.data = Uri.parse(address) + startActivity(intent) + } catch (e: Exception) { + toast(R.string.can_not_open) + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/about/DonateActivity.kt b/app/src/main/java/io/legado/app/ui/about/DonateActivity.kt new file mode 100644 index 000000000..5134e1ec2 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/about/DonateActivity.kt @@ -0,0 +1,90 @@ +package io.legado.app.ui.about + + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.widget.Toast +import io.legado.app.R +import io.legado.app.base.BaseActivity +import io.legado.app.lib.theme.ATH +import io.legado.app.utils.ACache +import kotlinx.android.synthetic.main.activity_donate.* +import kotlinx.android.synthetic.main.view_title_bar.* +import org.jetbrains.anko.toast +import java.net.URLEncoder + +/** + * Created by GKF on 2018/1/13. + * 捐赠页面 + */ + +class DonateActivity : BaseActivity(R.layout.activity_donate) { + + override fun onActivityCreated(savedInstanceState: Bundle?) { + setSupportActionBar(toolbar) + ATH.applyEdgeEffectColor(scroll_view) + vw_zfb_tz.setOnClickListener { aliDonate(this) } + cv_wx_gzh.setOnClickListener { + val clipboard = this.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + val clipData = ClipData.newPlainText(null, "开源阅读软件") + clipboard?.let { + clipboard.primaryClip = clipData + toast(R.string.copy_complete) + } + } + vw_zfb_hb.setOnClickListener { openActionViewIntent("https://gedoor.github.io/MyBookshelf/zfbhbrwm.png") } + vw_zfb_rwm.setOnClickListener { openActionViewIntent("https://gedoor.github.io/MyBookshelf/zfbskrwm.jpg") } + vw_wx_rwm.setOnClickListener { openActionViewIntent("https://gedoor.github.io/MyBookshelf/wxskrwm.jpg") } + vw_qq_rwm.setOnClickListener { openActionViewIntent("https://gedoor.github.io/MyBookshelf/qqskrwm.jpg") } + vw_zfb_hb_ssm.setOnClickListener { getZfbHb(this) } + } + + private fun getZfbHb(context: Context) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + val clipData = ClipData.newPlainText(null, "537954522") + clipboard?.let { + clipboard.primaryClip = clipData + Toast.makeText(context, "高级功能已开启\n红包码已复制\n支付宝首页搜索“537954522” 立即领红包", Toast.LENGTH_LONG) + .show() + } + try { + val packageManager = context.applicationContext.packageManager + val intent = packageManager.getLaunchIntentForPackage("com.eg.android.AlipayGphone")!! + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } catch (e: Exception) { + e.printStackTrace() + } finally { + ACache.get(this, cacheDir = false).put("proTime", System.currentTimeMillis()) + } + } + + private fun openActionViewIntent(address: String) { + try { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(address) + startActivity(intent) + } catch (e: Exception) { + e.printStackTrace() + Toast.makeText(this, R.string.can_not_open, Toast.LENGTH_SHORT).show() + } + + } + + private fun aliDonate(context: Context) { + try { + val qrCode = URLEncoder.encode("tsx06677nwdk3javroq4ef0", "utf-8") + val aliPayQr = "alipayqr://platformapi/startapp?" + + "saId=10000007&qrcode=https://qr.alipay.com/$qrCode" + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(aliPayQr)) + context.startActivity(intent) + } catch (e: Exception) { + e.printStackTrace() + } + + } +} diff --git a/app/src/main/java/io/legado/app/ui/audio/AudioPlayActivity.kt b/app/src/main/java/io/legado/app/ui/audio/AudioPlayActivity.kt new file mode 100644 index 000000000..23e2dd03b --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/audio/AudioPlayActivity.kt @@ -0,0 +1,17 @@ +package io.legado.app.ui.audio + +import android.os.Bundle +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.utils.getViewModel + +class AudioPlayActivity : VMBaseActivity(R.layout.activity_audio_play) { + override val viewModel: AudioPlayViewModel + get() = getViewModel(AudioPlayViewModel::class.java) + + override fun onActivityCreated(savedInstanceState: Bundle?) { + + } + + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/audio/AudioPlayViewModel.kt b/app/src/main/java/io/legado/app/ui/audio/AudioPlayViewModel.kt new file mode 100644 index 000000000..aebf4da98 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/audio/AudioPlayViewModel.kt @@ -0,0 +1,9 @@ +package io.legado.app.ui.audio + +import android.app.Application +import io.legado.app.base.BaseViewModel + +class AudioPlayViewModel(application: Application) : BaseViewModel(application) { + + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt new file mode 100644 index 000000000..b9bb67838 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt @@ -0,0 +1,259 @@ +package io.legado.app.ui.book.info + +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.help.ImageLoader +import io.legado.app.lib.theme.ATH +import io.legado.app.ui.book.info.edit.BookInfoEditActivity +import io.legado.app.ui.book.read.ReadBookActivity +import io.legado.app.ui.book.source.edit.BookSourceEditActivity +import io.legado.app.ui.changesource.ChangeSourceDialog +import io.legado.app.utils.getCompatDrawable +import io.legado.app.utils.getViewModel +import io.legado.app.utils.gone +import io.legado.app.utils.visible +import kotlinx.android.synthetic.main.activity_book_info.* +import kotlinx.android.synthetic.main.view_title_bar.* +import org.jetbrains.anko.sdk27.listeners.onClick +import org.jetbrains.anko.startActivity + + +class BookInfoActivity : VMBaseActivity(R.layout.activity_book_info), + ChapterListAdapter.CallBack, + ChangeSourceDialog.CallBack { + override val viewModel: BookInfoViewModel + get() = getViewModel(BookInfoViewModel::class.java) + + private var changeSourceDialog: ChangeSourceDialog? = null + private lateinit var adapter: ChapterListAdapter + + override fun onActivityCreated(savedInstanceState: Bundle?) { + setSupportActionBar(toolbar) + initRecyclerView() + viewModel.bookData.observe(this, Observer { showBook(it) }) + viewModel.isLoadingData.observe(this, Observer { upLoading(it) }) + viewModel.chapterListData.observe(this, Observer { showChapter(it) }) + viewModel.bookData.value?.let { + showBook(it) + upLoading(false) + viewModel.chapterListData.value?.let { chapters -> + showChapter(chapters) + } + } ?: viewModel.loadBook(intent) + initOnClick() + savedInstanceState?.let { + changeSourceDialog = + supportFragmentManager.findFragmentByTag(ChangeSourceDialog.tag) as? ChangeSourceDialog + } + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.book_info, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_edit -> { + if (viewModel.inBookshelf) { + viewModel.bookData.value?.let { + startActivity(Pair("bookUrl", it.bookUrl)) + } + } + } + R.id.menu_refresh -> { + upLoading(true) + viewModel.bookData.value?.let { + viewModel.loadBookInfo(it) + } + } + } + return super.onCompatOptionsItemSelected(item) + } + + private fun showBook(book: Book) { + tv_name.text = book.name + tv_author.text = getString(R.string.author_show, book.author) + tv_origin.text = getString(R.string.origin_show, book.originName) + tv_lasted.text = getString(R.string.lasted_show, book.latestChapterTitle) + tv_intro.text = + book.getDisplayIntro() // getString(R.string.intro_show, book.getDisplayIntro()) + book.getDisplayCover()?.let { + ImageLoader.load(this, it) + .placeholder(R.drawable.image_cover_default) + .error(R.drawable.image_cover_default) + .centerCrop() + .setAsDrawable(iv_cover) + } + val kinds = book.getKindList() + if (kinds.isEmpty()) { + ll_kind.gone() + } else { + ll_kind.visible() + for (index in 0..2) { + if (kinds.size > index) { + when (index) { + 0 -> { + tv_kind.text = kinds[index] + tv_kind.visible() + } + 1 -> { + tv_kind_1.text = kinds[index] + tv_kind_1.visible() + } + 2 -> { + tv_kind_2.text = kinds[index] + tv_kind_2.visible() + } + } + } else { + when (index) { + 0 -> tv_kind.gone() + 1 -> tv_kind_1.gone() + 2 -> tv_kind_2.gone() + } + } + } + } + } + + private fun showChapter(chapterList: List) { + viewModel.bookData.value?.let { + if (it.durChapterIndex < chapterList.size) { + tv_current_chapter_info.text = chapterList[it.durChapterIndex].title + } else { + tv_current_chapter_info.text = chapterList.last().title + } + } + adapter.clearItems() + adapter.addItems(chapterList) + rv_chapter_list.scrollToPosition(viewModel.durChapterIndex) + upLoading(false) + } + + private fun upLoading(isLoading: Boolean) { + if (isLoading) { + tv_loading.visible() + } else { + if (viewModel.inBookshelf) { + tv_shelf.text = getString(R.string.remove_from_bookshelf) + } else { + tv_shelf.text = getString(R.string.add_to_shelf) + } + tv_loading.gone() + } + } + + private fun initRecyclerView() { + adapter = ChapterListAdapter(this, this) + ATH.applyEdgeEffectColor(rv_chapter_list) + rv_chapter_list.layoutManager = LinearLayoutManager(this) + getCompatDrawable(R.drawable.recyclerview_item_divider)?.let { drawable -> + rv_chapter_list.addItemDecoration( + DividerItemDecoration(this, DividerItemDecoration.VERTICAL).apply { + setDrawable(drawable) + } + ) + } + rv_chapter_list.adapter = adapter + } + + private fun initOnClick() { + tv_read.onClick { + viewModel.bookData.value?.let { + readBook(it) + } + } + tv_shelf.onClick { + if (viewModel.inBookshelf) { + viewModel.delBook { + tv_shelf.text = getString(R.string.add_to_shelf) + } + } else { + viewModel.addToBookshelf { + tv_shelf.text = getString(R.string.remove_from_bookshelf) + } + } + } + tv_loading.onClick { + viewModel.bookData.value?.let { + viewModel.loadBookInfo(it) + } + } + tv_origin.onClick { + viewModel.bookData.value?.let { + startActivity(Pair("data", it.origin)) + } + } + tv_change_source.onClick { + if (changeSourceDialog == null) { + viewModel.bookData.value?.let { + changeSourceDialog = ChangeSourceDialog + .newInstance(it.name, it.author) + } + } + changeSourceDialog?.show(supportFragmentManager, ChangeSourceDialog.tag) + } + tv_current_chapter_info.onClick { + viewModel.bookData.value?.let { + rv_chapter_list.scrollToPosition(it.durChapterIndex) + } + } + iv_chapter_top.onClick { + rv_chapter_list.scrollToPosition(0) + } + iv_chapter_bottom.onClick { + rv_chapter_list.scrollToPosition(adapter.itemCount - 1) + } + } + + private fun readBook(book: Book) { + if (!viewModel.inBookshelf) { + viewModel.saveBook { + viewModel.saveChapterList { + startActivity( + Pair("bookUrl", book.bookUrl), + Pair("inBookshelf", false) + ) + } + } + } else { + viewModel.saveBook { + startActivity(Pair("bookUrl", book.bookUrl)) + } + } + } + + override val curOrigin: String? + get() = viewModel.bookData.value?.origin + + override val oldBook: Book? + get() = viewModel.bookData.value + + override fun changeTo(book: Book) { + upLoading(true) + viewModel.changeTo(book) + } + + override fun openChapter(chapter: BookChapter) { + if (chapter.index != viewModel.durChapterIndex) { + viewModel.bookData.value?.let { + it.durChapterIndex = chapter.index + it.durChapterPos = 0 + readBook(it) + } + } + } + + override fun durChapterIndex(): Int { + return viewModel.durChapterIndex + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt new file mode 100644 index 000000000..c1b992275 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt @@ -0,0 +1,192 @@ +package io.legado.app.ui.book.info + +import android.app.Application +import android.content.Intent +import androidx.lifecycle.MutableLiveData +import io.legado.app.App +import io.legado.app.R +import io.legado.app.base.BaseViewModel +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.help.BookHelp +import io.legado.app.model.WebBook +import kotlinx.coroutines.Dispatchers.IO + +class BookInfoViewModel(application: Application) : BaseViewModel(application) { + + val bookData = MutableLiveData() + val chapterListData = MutableLiveData>() + val isLoadingData = MutableLiveData() + var durChapterIndex = 0 + var inBookshelf = false + + fun loadBook(intent: Intent) { + execute { + intent.getStringExtra("bookUrl")?.let { + App.db.bookDao().getBook(it)?.let { book -> + inBookshelf = true + durChapterIndex = book.durChapterIndex + bookData.postValue(book) + val chapterList = App.db.bookChapterDao().getChapterList(it) + if (chapterList.isNotEmpty()) { + chapterListData.postValue(chapterList) + isLoadingData.postValue(false) + } else { + loadChapter(book) + } + } + } ?: intent.getStringExtra("searchBookUrl")?.let { + App.db.searchBookDao().getSearchBook(it)?.toBook()?.let { book -> + durChapterIndex = book.durChapterIndex + bookData.postValue(book) + if (book.tocUrl.isEmpty()) { + loadBookInfo(book) + } else { + loadChapter(book) + } + } + } + } + } + + fun loadBookInfo( + book: Book, + changeDruChapterIndex: ((chapters: List) -> Unit)? = null + ) { + execute { + isLoadingData.postValue(true) + App.db.bookSourceDao().getBookSource(book.origin)?.let { bookSource -> + WebBook(bookSource).getBookInfo(book, this) + .onSuccess(IO) { + it?.let { + bookData.postValue(book) + if (inBookshelf) { + App.db.bookDao().update(book) + } + loadChapter(it, changeDruChapterIndex) + } + }.onError { + isLoadingData.postValue(false) + toast(R.string.error_get_book_info) + } + } ?: let { + isLoadingData.postValue(false) + toast(R.string.error_no_source) + } + } + } + + private fun loadChapter( + book: Book, + changeDruChapterIndex: ((chapters: List) -> Unit)? = null + ) { + execute { + isLoadingData.postValue(true) + App.db.bookSourceDao().getBookSource(book.origin)?.let { bookSource -> + WebBook(bookSource).getChapterList(book, this) + .onSuccess(IO) { + it?.let { + if (it.isNotEmpty()) { + if (inBookshelf) { + App.db.bookDao().update(book) + App.db.bookChapterDao().insert(*it.toTypedArray()) + } + if (changeDruChapterIndex == null) { + chapterListData.postValue(it) + isLoadingData.postValue(false) + } else { + changeDruChapterIndex(it) + } + } else { + isLoadingData.postValue(false) + toast(R.string.chapter_list_empty) + } + } + }.onError { + isLoadingData.postValue(false) + toast(R.string.error_get_chapter_list) + } + } ?: let { + isLoadingData.postValue(false) + toast(R.string.error_no_source) + } + } + } + + fun changeTo(book: Book) { + execute { + if (inBookshelf) { + bookData.value?.let { + App.db.bookDao().delete(it.bookUrl) + } + App.db.bookDao().insert(book) + } + bookData.postValue(book) + if (book.tocUrl.isEmpty()) { + loadBookInfo(book) { upChangeDurChapterIndex(book, it) } + } else { + loadChapter(book) { upChangeDurChapterIndex(book, it) } + } + } + } + + private fun upChangeDurChapterIndex(book: Book, chapters: List) { + execute { + book.durChapterIndex = BookHelp.getDurChapterIndexByChapterTitle( + book.durChapterTitle, + book.durChapterIndex, + chapters + ) + book.durChapterTitle = chapters[book.durChapterIndex].title + App.db.bookDao().insert(book) + App.db.bookChapterDao().insert(*chapters.toTypedArray()) + bookData.postValue(book) + chapterListData.postValue(chapters) + } + } + + fun saveBook(success: (() -> Unit)?) { + execute { + bookData.value?.let { book -> + App.db.bookDao().insert(book) + } + }.onSuccess { + success?.invoke() + } + } + + fun saveChapterList(success: (() -> Unit)?) { + execute { + chapterListData.value?.let { + App.db.bookChapterDao().insert(*it.toTypedArray()) + } + }.onSuccess { + success?.invoke() + } + } + + fun addToBookshelf(success: (() -> Unit)?) { + execute { + bookData.value?.let { book -> + App.db.bookDao().insert(book) + } + chapterListData.value?.let { + App.db.bookChapterDao().insert(*it.toTypedArray()) + } + inBookshelf = true + }.onSuccess { + success?.invoke() + } + } + + fun delBook(success: (() -> Unit)?) { + execute { + bookData.value?.let { + App.db.bookDao().delete(it.bookUrl) + } + inBookshelf = false + }.onSuccess { + success?.invoke() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/info/ChapterListAdapter.kt b/app/src/main/java/io/legado/app/ui/book/info/ChapterListAdapter.kt new file mode 100644 index 000000000..bfe98f4d8 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/info/ChapterListAdapter.kt @@ -0,0 +1,40 @@ +package io.legado.app.ui.book.info + +import android.content.Context +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.SimpleRecyclerAdapter +import io.legado.app.data.entities.BookChapter +import io.legado.app.lib.theme.accentColor +import kotlinx.android.synthetic.main.item_chapter_list.view.* +import org.jetbrains.anko.sdk27.listeners.onClick +import org.jetbrains.anko.textColorResource + +class ChapterListAdapter(context: Context, var callBack: CallBack) : + SimpleRecyclerAdapter(context, R.layout.item_chapter_list) { + + var reorder: Boolean = false; // 是否倒序 + + override fun convert(holder: ItemViewHolder, item: BookChapter, payloads: MutableList) { + holder.itemView.apply { + var _item: BookChapter = item; + if (reorder) { + _item = getItems().get(getItems().size - item.index - 1); + } + tv_chapter_name.text = _item.title + if (_item.index == callBack.durChapterIndex()) { + tv_chapter_name.setTextColor(context.accentColor) + } else { + tv_chapter_name.textColorResource = R.color.tv_text_secondary + } + this.onClick { + callBack.openChapter(_item) + } + } + } + + interface CallBack { + fun openChapter(chapter: BookChapter) + fun durChapterIndex(): Int + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditActivity.kt b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditActivity.kt new file mode 100644 index 000000000..fcc4e7e24 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditActivity.kt @@ -0,0 +1,56 @@ +package io.legado.app.ui.book.info.edit + +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.lifecycle.Observer +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.data.entities.Book +import io.legado.app.utils.getViewModel +import kotlinx.android.synthetic.main.activity_book_info_edit.* +import org.jetbrains.anko.toast + +class BookInfoEditActivity : VMBaseActivity(R.layout.activity_book_info_edit) { + override val viewModel: BookInfoEditViewModel + get() = getViewModel(BookInfoEditViewModel::class.java) + + override fun onActivityCreated(savedInstanceState: Bundle?) { + viewModel.bookData.observe(this, Observer { upView(it) }) + if (viewModel.bookData.value == null) { + intent.getStringExtra("bookUrl")?.let { + viewModel.loadBook(it) + } + } + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.book_info_edit, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_save -> saveData() + } + return super.onCompatOptionsItemSelected(item) + } + + private fun upView(book: Book) { + tie_book_name.setText(book.name) + tie_book_author.setText(book.author) + tie_cover_url.setText(book.getDisplayCover()) + tie_book_intro.setText(book.getDisplayIntro()) + } + + private fun saveData() { + viewModel.bookData.value?.let { book -> + book.name = tie_book_name.text?.toString() ?: "" + book.author = tie_book_author.text?.toString() ?: "" + val customCoverUrl = tie_cover_url.text?.toString() + book.customCoverUrl = if (customCoverUrl == book.coverUrl) null else customCoverUrl + book.customIntro = tie_book_intro.text?.toString() + viewModel.saveBook(book, success = { finish() }, error = { toast(it) }) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt new file mode 100644 index 000000000..a947719cd --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt @@ -0,0 +1,31 @@ +package io.legado.app.ui.book.info.edit + +import android.app.Application +import androidx.lifecycle.MutableLiveData +import io.legado.app.App +import io.legado.app.base.BaseViewModel +import io.legado.app.data.entities.Book + +class BookInfoEditViewModel(application: Application) : BaseViewModel(application) { + + val bookData = MutableLiveData() + + + fun loadBook(bookUrl: String) { + execute { + App.db.bookDao().getBook(bookUrl)?.let { + bookData.postValue(it) + } + } + } + + fun saveBook(book: Book, success: (() -> Unit)?, error: ((msg: String) -> Unit)?) { + execute { + App.db.bookDao().insert(book) + }.onSuccess { + success?.invoke() + }.onError { + error?.invoke(it.localizedMessage) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/Help.kt b/app/src/main/java/io/legado/app/ui/book/read/Help.kt new file mode 100644 index 000000000..1ff72d7dc --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/Help.kt @@ -0,0 +1,69 @@ +package io.legado.app.ui.book.read + +import android.app.Activity +import android.view.View +import android.view.View.NO_ID +import android.view.ViewGroup +import io.legado.app.App +import io.legado.app.help.ReadBookConfig +import io.legado.app.lib.theme.ATH +import io.legado.app.lib.theme.ThemeStore +import io.legado.app.utils.getPrefBoolean +import io.legado.app.utils.isTransparentStatusBar + + +object Help { + + private const val NAVIGATION = "navigationBarBackground" + + fun upSystemUiVisibility(activity: Activity, toolBarHide: Boolean = true) { + var flag = (View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_IMMERSIVE + or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY) + val hideNavigationBar = App.INSTANCE.getPrefBoolean("hideNavigationBar") + if (hideNavigationBar) { + flag = flag or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + } + if (toolBarHide) { + if (App.INSTANCE.getPrefBoolean("hideStatusBar")) { + flag = flag or View.SYSTEM_UI_FLAG_FULLSCREEN + } + if (hideNavigationBar) { + flag = flag or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + } + } + activity.window.decorView.systemUiVisibility = flag + if (toolBarHide) { + ATH.setLightStatusBar(activity, ReadBookConfig.getConfig().statusIconDark()) + } else { + ATH.setLightStatusBarAuto( + activity, + ThemeStore.statusBarColor(activity, activity.isTransparentStatusBar) + ) + } + } + + /** + * 返回NavigationBar是否存在 + * 该方法需要在View完全被绘制出来之后调用,否则判断不了 + * 在比如 onWindowFocusChanged()方法中可以得到正确的结果 + */ + fun isNavigationBarExist(activity: Activity?): Boolean { + activity?.let { + val vp = it.window.decorView as? ViewGroup + if (vp != null) { + for (i in 0 until vp.childCount) { + vp.getChildAt(i).context.packageName + if (vp.getChildAt(i).id != NO_ID + && NAVIGATION == activity.resources.getResourceEntryName(vp.getChildAt(i).id) + ) { + return true + } + } + } + } + return false + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt new file mode 100644 index 000000000..4dc9d14b2 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt @@ -0,0 +1,636 @@ +package io.legado.app.ui.book.read + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.text.SpannableStringBuilder +import android.view.KeyEvent +import android.view.Menu +import android.view.MenuItem +import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES +import androidx.core.view.isVisible +import androidx.lifecycle.Observer +import com.jaredrummler.android.colorpicker.ColorPickerDialogListener +import io.legado.app.BuildConfig +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.constant.Bus +import io.legado.app.constant.Status +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.help.IntentDataHelp +import io.legado.app.help.ReadAloud +import io.legado.app.help.ReadBookConfig +import io.legado.app.help.storage.Backup +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.dialogs.noButton +import io.legado.app.lib.dialogs.okButton +import io.legado.app.receiver.TimeElectricityReceiver +import io.legado.app.service.BaseReadAloudService +import io.legado.app.ui.book.read.config.* +import io.legado.app.ui.book.read.config.BgTextConfigDialog.Companion.BG_COLOR +import io.legado.app.ui.book.read.config.BgTextConfigDialog.Companion.TEXT_COLOR +import io.legado.app.ui.book.source.edit.BookSourceEditActivity +import io.legado.app.ui.changesource.ChangeSourceDialog +import io.legado.app.ui.chapterlist.ChapterListActivity +import io.legado.app.ui.main.MainActivity +import io.legado.app.ui.replacerule.ReplaceRuleActivity +import io.legado.app.ui.replacerule.edit.ReplaceEditDialog +import io.legado.app.ui.widget.page.ChapterProvider +import io.legado.app.ui.widget.page.PageView +import io.legado.app.ui.widget.page.TextChapter +import io.legado.app.ui.widget.page.delegate.PageDelegate +import io.legado.app.utils.* +import kotlinx.android.synthetic.main.activity_book_read.* +import kotlinx.android.synthetic.main.view_book_page.* +import kotlinx.android.synthetic.main.view_read_menu.* +import kotlinx.android.synthetic.main.view_title_bar.* +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jetbrains.anko.sdk27.listeners.onClick +import org.jetbrains.anko.startActivity +import org.jetbrains.anko.startActivityForResult +import org.jetbrains.anko.toast + +class ReadBookActivity : VMBaseActivity(R.layout.activity_book_read), + PageView.CallBack, + ReadMenu.CallBack, + ReadAloudDialog.CallBack, + ChangeSourceDialog.CallBack, + ReadBookViewModel.CallBack, + ColorPickerDialogListener { + override val viewModel: ReadBookViewModel + get() = getViewModel(ReadBookViewModel::class.java) + + private val requestCodeEditSource = 111 + private var changeSourceDialog: ChangeSourceDialog? = null + private var timeElectricityReceiver: TimeElectricityReceiver? = null + override var readAloudStatus = Status.STOP + + override fun onActivityCreated(savedInstanceState: Bundle?) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + window.attributes = window.attributes.apply { + layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + } + } + setSupportActionBar(toolbar) + initView() + viewModel.callBack = this + viewModel.bookData.observe(this, Observer { title_bar.title = it.name }) + viewModel.chapterListFinish.observe(this, Observer { loadContent() }) + viewModel.initData(intent) + savedInstanceState?.let { + changeSourceDialog = + supportFragmentManager.findFragmentByTag(ChangeSourceDialog.tag) as? ChangeSourceDialog + } + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + upSystemUiVisibility() + } + + override fun onResume() { + super.onResume() + upSystemUiVisibility() + timeElectricityReceiver = TimeElectricityReceiver.register(this) + page_view.upTime() + } + + override fun onPause() { + super.onPause() + timeElectricityReceiver?.let { + unregisterReceiver(it) + timeElectricityReceiver = null + } + upSystemUiVisibility() + } + + /** + * 初始化View + */ + private fun initView() { + tv_chapter_name.onClick { + viewModel.webBook?.let { + startActivityForResult( + requestCodeEditSource, + Pair("data", it.bookSource.bookSourceUrl) + ) + } + } + tv_chapter_url.onClick { + runCatching { + val url = tv_chapter_url.text.toString() + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(url) + startActivity(intent) + } + } + } + + fun showPaddingConfig() { + PaddingConfigDialog().show(supportFragmentManager, "paddingConfig") + } + + fun showBgTextConfig() { + BgTextConfigDialog().show(supportFragmentManager, "bgTextConfig") + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.read_book, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + /** + * 菜单 + */ + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_change_source -> { + read_menu.runMenuOut() + if (changeSourceDialog == null) { + viewModel.bookData.value?.let { + changeSourceDialog = ChangeSourceDialog + .newInstance(it.name, it.author) + } + } + changeSourceDialog?.show(supportFragmentManager, ChangeSourceDialog.tag) + } + R.id.menu_refresh -> { + viewModel.bookData.value?.let { + viewModel.curTextChapter = null + page_view.upContent() + viewModel.refreshContent(it) + } + } + } + return super.onCompatOptionsItemSelected(item) + } + + /** + * 按键拦截,显示菜单 + */ + override fun dispatchKeyEvent(event: KeyEvent?): Boolean { + val keyCode = event?.keyCode + val action = event?.action + val isDown = action == 0 + + if (keyCode == KeyEvent.KEYCODE_MENU) { + if (isDown && !read_menu.cnaShowMenu) { + read_menu.runMenuIn() + return true + } + if (!isDown && !read_menu.cnaShowMenu) { + read_menu.cnaShowMenu = true + return true + } + } + return super.dispatchKeyEvent(event) + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + when (keyCode) { + KeyEvent.KEYCODE_VOLUME_UP -> { + if (volumeKeyPage(PageDelegate.Direction.PREV)) { + return true + } + } + KeyEvent.KEYCODE_VOLUME_DOWN -> { + if (volumeKeyPage(PageDelegate.Direction.NEXT)) { + return true + } + } + KeyEvent.KEYCODE_SPACE -> { + page_view.moveToNextPage() + } + } + return super.onKeyDown(keyCode, event) + } + + override fun onKeyLongPress(keyCode: Int, event: KeyEvent?): Boolean { + when (keyCode) { + KeyEvent.KEYCODE_BACK -> { + page_view.snackbar("转到后台", "确定") { + startActivity() + } + return true + } + } + return super.onKeyLongPress(keyCode, event) + } + + override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { + when (keyCode) { + KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN -> { + if (volumeKeyPage(PageDelegate.Direction.NONE)) { + return true + } + } + KeyEvent.KEYCODE_BACK -> { + event?.let { + if ((event.flags and KeyEvent.FLAG_CANCELED_LONG_PRESS == 0) + && event.isTracking + && !event.isCanceled + ) { + if (readAloudStatus == Status.PLAY) { + ReadAloud.pause(this) + toast(R.string.read_aloud_pause) + return true + } + } + } + } + } + return super.onKeyUp(keyCode, event) + } + + private fun volumeKeyPage(direction: PageDelegate.Direction): Boolean { + if (!read_menu.isVisible) { + if (getPrefBoolean("volumeKeyPage", true)) { + if (getPrefBoolean("volumeKeyPageOnPlay") + || readAloudStatus != Status.PLAY + ) { + when (direction) { + PageDelegate.Direction.PREV -> page_view.moveToPrevPage() + PageDelegate.Direction.NEXT -> page_view.moveToNextPage() + else -> return true + } + return true + } + } + } + return false + } + + /** + * 加载章节内容 + */ + private fun loadContent() { + viewModel.bookData.value?.let { + viewModel.loadContent(it, viewModel.durChapterIndex) + viewModel.loadContent(it, viewModel.durChapterIndex + 1) + viewModel.loadContent(it, viewModel.durChapterIndex - 1) + } + } + + /** + * 加载章节内容, index章节序号 + */ + override fun loadContent(index: Int) { + viewModel.bookData.value?.let { + viewModel.loadContent(it, index) + } + } + + /** + * 内容加载完成 + */ + override fun contentLoadFinish(bookChapter: BookChapter, content: String) { + when (bookChapter.index) { + viewModel.durChapterIndex -> launch { + viewModel.curTextChapter = ChapterProvider + .getTextChapter(content_text_view, bookChapter, content, viewModel.chapterSize) + page_view.upContent() + curChapterChanged() + if (intent.getBooleanExtra("readAloud", false)) { + intent.removeExtra("readAloud") + readAloud() + } + } + viewModel.durChapterIndex - 1 -> launch { + viewModel.prevTextChapter = ChapterProvider + .getTextChapter(content_text_view, bookChapter, content, viewModel.chapterSize) + page_view.upContent(-1) + } + viewModel.durChapterIndex + 1 -> launch { + viewModel.nextTextChapter = ChapterProvider + .getTextChapter(content_text_view, bookChapter, content, viewModel.chapterSize) + page_view.upContent(1) + } + } + } + + override fun upContent() { + page_view.upContent() + } + + private fun curChapterChanged() { + viewModel.curTextChapter?.let { + tv_chapter_name.text = it.title + tv_chapter_name.visible() + if (!viewModel.isLocalBook) { + tv_chapter_url.text = it.url + tv_chapter_url.visible() + } + seek_read_page.max = it.pageSize().minus(1) + tv_pre.isEnabled = viewModel.durChapterIndex != 0 + tv_next.isEnabled = viewModel.durChapterIndex != viewModel.chapterSize - 1 + curPageChanged() + } + } + + private fun curPageChanged() { + seek_read_page.progress = viewModel.durPageIndex + when (readAloudStatus) { + Status.PLAY -> readAloud() + Status.PAUSE -> { + readAloud(false) + } + } + } + + override fun showMenu() { + read_menu.runMenuIn() + } + + override fun chapterSize(): Int { + return viewModel.chapterSize + } + + override val curOrigin: String? + get() = viewModel.bookData.value?.origin + + override val oldBook: Book? + get() = viewModel.bookData.value + + override fun changeTo(book: Book) { + viewModel.changeTo(book) + } + + override fun durChapterIndex(): Int { + return viewModel.durChapterIndex + } + + override fun durChapterPos(): Int { + viewModel.curTextChapter?.let { + if (viewModel.durPageIndex < it.pageSize()) { + return viewModel.durPageIndex + } + return it.pageSize() - 1 + } + return viewModel.durPageIndex + } + + override fun setPageIndex(pageIndex: Int) { + viewModel.durPageIndex = pageIndex + viewModel.saveRead() + curPageChanged() + } + + /** + * chapterOnDur: 0为当前页,1为下一页,-1为上一页 + */ + override fun textChapter(chapterOnDur: Int): TextChapter? { + return when (chapterOnDur) { + 0 -> viewModel.curTextChapter + 1 -> viewModel.nextTextChapter + -1 -> viewModel.prevTextChapter + else -> null + } + } + + /** + * 下一页 + */ + override fun moveToNextChapter(upContent: Boolean): Boolean { + return if (viewModel.durChapterIndex < viewModel.chapterSize - 1) { + viewModel.durPageIndex = 0 + viewModel.moveToNextChapter(upContent) + viewModel.saveRead() + curChapterChanged() + true + } else { + false + } + } + + /** + * 上一页 + */ + override fun moveToPrevChapter(upContent: Boolean, last: Boolean): Boolean { + return if (viewModel.durChapterIndex > 0) { + viewModel.durPageIndex = if (last) viewModel.prevTextChapter?.lastIndex() ?: 0 else 0 + viewModel.moveToPrevChapter(upContent) + viewModel.saveRead() + curChapterChanged() + true + } else { + false + } + } + + override fun clickCenter() { + if (readAloudStatus != Status.STOP) { + showReadAloudDialog() + } else { + read_menu.runMenuIn() + } + } + + override fun showReadAloudDialog() { + ReadAloudDialog().show(supportFragmentManager, "readAloud") + } + + override fun autoPage() { + + } + + override fun skipToPage(page: Int) { + viewModel.durPageIndex = page + page_view.upContent() + curPageChanged() + viewModel.saveRead() + } + + override fun openReplaceRule() { + startActivity() + } + + override fun openChapterList() { + viewModel.bookData.value?.let { + startActivity(Pair("bookUrl", it.bookUrl)) + } + } + + override fun showReadStyle() { + ReadStyleDialog().show(supportFragmentManager, "readStyle") + } + + override fun showMoreSetting() { + MoreConfigDialog().show(supportFragmentManager, "moreConfig") + } + + override fun upSystemUiVisibility() { + Help.upSystemUiVisibility(this, !read_menu.isVisible) + } + + /** + * 朗读按钮 + */ + private fun onClickReadAloud() { + if (!BaseReadAloudService.isRun) { + readAloudStatus = Status.STOP + SystemUtils.ignoreBatteryOptimization(this) + } + when (readAloudStatus) { + Status.STOP -> readAloud() + Status.PLAY -> ReadAloud.pause(this) + Status.PAUSE -> ReadAloud.resume(this) + } + } + + /** + * 朗读 + */ + private fun readAloud(play: Boolean = true) { + val book = viewModel.bookData.value + val textChapter = viewModel.curTextChapter + if (book != null && textChapter != null) { + val key = IntentDataHelp.putData(textChapter) + ReadAloud.play( + this, + book.name, + textChapter.title, + viewModel.durPageIndex, + key, + play + ) + } + } + + override fun onColorSelected(dialogId: Int, color: Int) = with(ReadBookConfig.getConfig()) { + when (dialogId) { + TEXT_COLOR -> { + setTextColor(color) + postEvent(Bus.UP_CONFIG, false) + } + BG_COLOR -> { + setBg(0, "#${color.hexString}") + ReadBookConfig.upBg() + postEvent(Bus.UP_CONFIG, false) + } + } + } + + /** + * colorSelectDialog + */ + override fun onDialogDismissed(dialogId: Int) = Unit + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (resultCode == Activity.RESULT_OK) { + when (requestCode) { + requestCodeEditSource -> viewModel.upBookSource() + } + } + } + + override fun finish() { + viewModel.bookData.value?.let { + if (!viewModel.inBookshelf) { + this.alert(title = getString(R.string.add_to_shelf)) { + message = getString(R.string.check_add_bookshelf, it.name) + okButton { viewModel.inBookshelf = true } + noButton { viewModel.removeFromBookshelf { super.finish() } } + }.show().applyTint() + } else { + super.finish() + } + } ?: super.finish() + } + + override fun onDestroy() { + super.onDestroy() + if (!BuildConfig.DEBUG) { + Backup.autoBackup() + } + } + + override fun observeLiveBus() { + super.observeLiveBus() + observeEvent(Bus.ALOUD_STATE) { + readAloudStatus = it + if (it == Status.STOP || it == Status.PAUSE) { + viewModel.curTextChapter?.let { textChapter -> + val page = textChapter.page(viewModel.durPageIndex) + if (page != null && page.text is SpannableStringBuilder) { + page.text.removeSpan(ChapterProvider.readAloudSpan) + page_view.upContent() + } + } + } + } + observeEvent(Bus.TIME_CHANGED) { page_view.upTime() } + observeEvent(Bus.BATTERY_CHANGED) { page_view.upBattery(it) } + observeEvent(Bus.OPEN_CHAPTER) { + viewModel.openChapter(it) + page_view.upContent() + } + observeEvent(Bus.READ_ALOUD_BUTTON) { + if (it) { + onClickReadAloud() + } else { + readAloud(readAloudStatus == Status.PLAY) + } + } + observeEvent(Bus.UP_CONFIG) { + upSystemUiVisibility() + page_view.upBg() + content_view.upStyle() + page_view.upStyle() + if (it) { + loadContent() + } else { + page_view.upContent() + } + } + observeEvent(Bus.TTS_START) { chapterStart -> + launch(IO) { + viewModel.curTextChapter?.let { + val pageStart = chapterStart - it.getReadLength(viewModel.durPageIndex) + it.page(viewModel.durPageIndex)?.upPageAloudSpan(pageStart) + withContext(Main) { + page_view.upContent() + } + } + } + } + observeEvent(Bus.TTS_TURN_PAGE) { + when (it) { + 1 -> { + if (page_view.isScrollDelegate) { + page_view.moveToNextPage() + } else { + viewModel.durPageIndex = viewModel.durPageIndex + 1 + page_view.upContent() + viewModel.saveRead() + } + } + 2 -> if (!moveToNextChapter(true)) ReadAloud.stop(this) + -1 -> { + if (viewModel.durPageIndex > 0) { + if (page_view.isScrollDelegate) { + page_view.moveToPrevPage() + } else { + viewModel.durPageIndex = viewModel.durPageIndex - 1 + page_view.upContent() + viewModel.saveRead() + } + } else { + moveToPrevChapter(true) + } + } + -2 -> moveToPrevChapter(false) + } + } + observeEvent(Bus.REPLACE) { + ReplaceEditDialog().show(supportFragmentManager, "replaceEditDialog") + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt new file mode 100644 index 000000000..d6d6ae004 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt @@ -0,0 +1,341 @@ +package io.legado.app.ui.book.read + +import android.app.Application +import android.content.Intent +import androidx.lifecycle.MutableLiveData +import io.legado.app.App +import io.legado.app.R +import io.legado.app.base.BaseViewModel +import io.legado.app.constant.BookType +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.help.BookHelp +import io.legado.app.help.ReadAloud +import io.legado.app.model.WebBook +import io.legado.app.ui.widget.page.TextChapter +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ReadBookViewModel(application: Application) : BaseViewModel(application) { + var inBookshelf = false + var bookData = MutableLiveData() + val chapterListFinish = MutableLiveData() + var chapterSize = 0 + var callBack: CallBack? = null + var durChapterIndex = 0 + var durPageIndex = 0 + var isLocalBook = true + var prevTextChapter: TextChapter? = null + var curTextChapter: TextChapter? = null + var nextTextChapter: TextChapter? = null + var webBook: WebBook? = null + private val loadingChapters = arrayListOf() + private val loadingLock = "loadingLock" + + fun initData(intent: Intent) { + execute { + inBookshelf = intent.getBooleanExtra("inBookshelf", true) + val bookUrl = intent.getStringExtra("bookUrl") + val book = if (!bookUrl.isNullOrEmpty()) { + App.db.bookDao().getBook(bookUrl) + } else { + App.db.bookDao().lastReadBook + } + book?.let { + durChapterIndex = book.durChapterIndex + durPageIndex = book.durChapterPos + isLocalBook = book.origin == BookType.local + bookData.postValue(book) + App.db.bookSourceDao().getBookSource(book.origin)?.let { + webBook = WebBook(it) + } + val count = App.db.bookChapterDao().getChapterCount(book.bookUrl) + if (count == 0) { + if (book.tocUrl.isEmpty()) { + loadBookInfo(book) + } else { + loadChapterList(book) + } + } else { + if (durChapterIndex > count - 1) { + durChapterIndex = count - 1 + } + chapterSize = count + chapterListFinish.postValue(true) + } + } + saveRead(book) + } + } + + private fun loadBookInfo( + book: Book, + changeDruChapterIndex: ((chapters: List) -> Unit)? = null + ) { + execute { + webBook?.getBookInfo(book, this) + ?.onSuccess { + loadChapterList(book, changeDruChapterIndex) + } + } + } + + private fun loadChapterList( + book: Book, + changeDruChapterIndex: ((chapters: List) -> Unit)? = null + ) { + execute { + webBook?.getChapterList(book, this) + ?.onSuccess(IO) { cList -> + if (!cList.isNullOrEmpty()) { + if (changeDruChapterIndex == null) { + App.db.bookChapterDao().insert(*cList.toTypedArray()) + chapterSize = cList.size + chapterListFinish.postValue(true) + } else { + changeDruChapterIndex(cList) + } + } else { + toast(R.string.error_load_toc) + } + }?.onError { + toast(R.string.error_load_toc) + } ?: autoChangeSource() + } + } + + fun moveToNextChapter(upContent: Boolean) { + durChapterIndex++ + prevTextChapter = curTextChapter + curTextChapter = nextTextChapter + nextTextChapter = null + bookData.value?.let { + if (curTextChapter == null) { + loadContent(it, durChapterIndex) + } else if (upContent) { + callBack?.upContent() + } + loadContent(it, durChapterIndex.plus(1)) + launch(IO) { + for (i in 2..10) { + delay(100) + bookData.value?.let { book -> + download(book, durChapterIndex + i) + } + } + } + } + } + + fun moveToPrevChapter(upContent: Boolean) { + durChapterIndex-- + nextTextChapter = curTextChapter + curTextChapter = prevTextChapter + prevTextChapter = null + bookData.value?.let { + if (curTextChapter == null) { + loadContent(it, durChapterIndex) + } else if (upContent) { + callBack?.upContent() + } + loadContent(it, durChapterIndex.minus(1)) + launch(IO) { + for (i in -5..-2) { + delay(100) + bookData.value?.let { book -> + download(book, durChapterIndex + i) + } + } + } + } + } + + fun loadContent(book: Book, index: Int) { + if (addLoading(index)) { + execute { + App.db.bookChapterDao().getChapter(book.bookUrl, index)?.let { chapter -> + BookHelp.getContent(book, chapter)?.let { + contentLoadFinish(chapter, it) + removeLoading(chapter.index) + } ?: download(book, chapter) + } ?: removeLoading(index) + }.onError { + removeLoading(index) + } + } + } + + private fun download(book: Book, index: Int) { + if (addLoading(index)) { + execute { + App.db.bookChapterDao().getChapter(book.bookUrl, index)?.let { chapter -> + if (BookHelp.hasContent(book, chapter)) { + removeLoading(chapter.index) + } else { + download(book, chapter) + } + } ?: removeLoading(index) + }.onError { + removeLoading(index) + } + } + } + + private fun download(book: Book, chapter: BookChapter) { + webBook?.getContent(book, chapter, scope = this) + ?.onSuccess(IO) { content -> + if (content.isNullOrEmpty()) { + contentLoadFinish(chapter, context.getString(R.string.content_empty)) + removeLoading(chapter.index) + } else { + BookHelp.saveContent(book, chapter, content) + contentLoadFinish(chapter, content) + removeLoading(chapter.index) + } + }?.onError { + contentLoadFinish(chapter, it.localizedMessage) + removeLoading(chapter.index) + } + } + + private fun addLoading(index: Int): Boolean { + synchronized(loadingLock) { + if (loadingChapters.contains(index)) return false + loadingChapters.add(index) + return true + } + } + + private fun removeLoading(index: Int) { + synchronized(loadingLock) { + loadingChapters.remove(index) + } + } + + private fun contentLoadFinish(chapter: BookChapter, content: String) { + execute { + if (chapter.index in durChapterIndex - 1..durChapterIndex + 1) { + val c = BookHelp.disposeContent( + bookData.value?.name ?: "", + webBook?.bookSource?.bookSourceUrl, + content, + bookData.value?.useReplaceRule ?: true + ) + callBack?.contentLoadFinish(chapter, c) + } + } + } + + fun changeTo(book: Book) { + execute { + bookData.value?.let { + App.db.bookDao().delete(it.bookUrl) + } + prevTextChapter = null + curTextChapter = null + nextTextChapter = null + withContext(Main) { + callBack?.upContent() + } + App.db.bookDao().insert(book) + bookData.postValue(book) + App.db.bookSourceDao().getBookSource(book.origin)?.let { + webBook = WebBook(it) + } + if (book.tocUrl.isEmpty()) { + loadBookInfo(book) { upChangeDurChapterIndex(book, it) } + } else { + loadChapterList(book) { upChangeDurChapterIndex(book, it) } + } + } + } + + private fun autoChangeSource() { + + } + + private fun upChangeDurChapterIndex(book: Book, chapters: List) { + execute { + durChapterIndex = BookHelp.getDurChapterIndexByChapterTitle( + book.durChapterTitle, + book.durChapterIndex, + chapters + ) + book.durChapterIndex = durChapterIndex + book.durChapterTitle = chapters[durChapterIndex].title + App.db.bookDao().update(book) + App.db.bookChapterDao().insert(*chapters.toTypedArray()) + chapterSize = chapters.size + chapterListFinish.postValue(true) + } + } + + fun openChapter(chapter: BookChapter) { + prevTextChapter = null + curTextChapter = null + nextTextChapter = null + if (chapter.index != durChapterIndex) { + durChapterIndex = chapter.index + durPageIndex = 0 + } + saveRead() + chapterListFinish.postValue(true) + } + + fun saveRead(book: Book? = bookData.value) { + execute { + book?.let { book -> + book.lastCheckCount = 0 + book.durChapterTime = System.currentTimeMillis() + book.durChapterIndex = durChapterIndex + book.durChapterPos = durPageIndex + curTextChapter?.let { + book.durChapterTitle = it.title + } + App.db.bookDao().update(book) + } + } + } + + fun removeFromBookshelf(success: (() -> Unit)?) { + execute { + bookData.value?.let { + App.db.bookDao().delete(it.bookUrl) + } + }.onSuccess { + success?.invoke() + } + } + + fun upBookSource() { + execute { + bookData.value?.let { book -> + App.db.bookSourceDao().getBookSource(book.origin)?.let { + webBook = WebBook(it) + } + } + } + } + + fun refreshContent(book: Book) { + execute { + App.db.bookChapterDao().getChapter(book.bookUrl, durChapterIndex)?.let { chapter -> + BookHelp.delContent(book, chapter) + loadContent(book, durChapterIndex) + } + } + } + + override fun onCleared() { + super.onCleared() + ReadAloud.stop(context) + } + + interface CallBack { + fun contentLoadFinish(bookChapter: BookChapter, content: String) + fun upContent() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadMenu.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadMenu.kt new file mode 100644 index 000000000..0aa08497c --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/ReadMenu.kt @@ -0,0 +1,258 @@ +package io.legado.app.ui.book.read + +import android.content.Context +import android.util.AttributeSet +import android.view.WindowManager +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import android.widget.FrameLayout +import android.widget.SeekBar +import androidx.core.view.isVisible +import io.legado.app.App +import io.legado.app.R +import io.legado.app.constant.Bus +import io.legado.app.lib.theme.accentColor +import io.legado.app.lib.theme.buttonDisabledColor +import io.legado.app.utils.* +import kotlinx.android.synthetic.main.view_read_menu.view.* +import org.jetbrains.anko.sdk27.listeners.onClick +import org.jetbrains.anko.sdk27.listeners.onLongClick + +class ReadMenu : FrameLayout { + var cnaShowMenu: Boolean = false + private var callBack: CallBack? = null + private lateinit var menuTopIn: Animation + private lateinit var menuTopOut: Animation + private lateinit var menuBottomIn: Animation + private lateinit var menuBottomOut: Animation + private var onMenuOutEnd: (() -> Unit)? = null + + constructor(context: Context) : super(context) + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super( + context, + attrs, + defStyleAttr + ) + + init { + callBack = activity as? CallBack + inflate(context, R.layout.view_read_menu, this) + if (context.isNightTheme) { + fabNightTheme.setImageResource(R.drawable.ic_daytime) + } else { + fabNightTheme.setImageResource(R.drawable.ic_brightness) + } + initAnimation() + vw_bg.onClick { } + vwNavigationBar.onClick { } + seek_brightness.progress = context.getPrefInt("brightness", 100) + upBrightnessState() + bindEvent() + } + + private fun upBrightnessState() { + if (brightnessAuto()) { + iv_brightness_auto.setColorFilter(context.accentColor) + seek_brightness.isEnabled = false + } else { + iv_brightness_auto.setColorFilter(context.buttonDisabledColor) + seek_brightness.isEnabled = true + } + setScreenBrightness(context.getPrefInt("brightness", 100)) + } + + /** + * 设置屏幕亮度 + */ + private fun setScreenBrightness(value: Int) { + var brightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + if (!brightnessAuto()) { + brightness = value.toFloat() + if (brightness < 1f) brightness = 1f + brightness /= 255f + } + val params = activity?.window?.attributes + params?.screenBrightness = brightness + activity?.window?.attributes = params + } + + fun runMenuIn() { + this.visible() + title_bar.visible() + bottom_menu.visible() + title_bar.startAnimation(menuTopIn) + bottom_menu.startAnimation(menuBottomIn) + } + + fun runMenuOut(onMenuOutEnd: (() -> Unit)? = null) { + this.onMenuOutEnd = onMenuOutEnd + if (this.isVisible) { + title_bar.startAnimation(menuTopOut) + bottom_menu.startAnimation(menuBottomOut) + } + } + + private fun brightnessAuto(): Boolean { + return context.getPrefBoolean("brightnessAuto", true) + } + + private fun bindEvent() { + iv_brightness_auto.onClick { + context.putPrefBoolean("brightnessAuto", !brightnessAuto()) + upBrightnessState() + } + //亮度调节 + seek_brightness.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + setScreenBrightness(progress) + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) { + + } + + override fun onStopTrackingTouch(seekBar: SeekBar?) { + context.putPrefInt("brightness", seek_brightness.progress) + } + + }) + + //阅读进度 + seek_read_page.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar, i: Int, b: Boolean) { + + } + + override fun onStartTrackingTouch(seekBar: SeekBar) { + + } + + override fun onStopTrackingTouch(seekBar: SeekBar) { + callBack?.skipToPage(seekBar.progress) + } + }) + + //自动翻页 + fabAutoPage.onClick { callBack?.autoPage() } + + //替换 + fabReplaceRule.onClick { callBack?.openReplaceRule() } + + //夜间模式 + fabNightTheme.onClick { + context.putPrefBoolean("isNightTheme", !context.isNightTheme) + App.INSTANCE.applyDayNight() + } + + //上一章 + tv_pre.onClick { callBack?.moveToPrevChapter(upContent = true, last = false) } + + //下一章 + tv_next.onClick { callBack?.moveToNextChapter(true) } + + //目录 + ll_catalog.onClick { + runMenuOut { + callBack?.openChapterList() + } + } + + //朗读 + ll_read_aloud.onClick { + runMenuOut { + postEvent(Bus.READ_ALOUD_BUTTON, true) + } + } + ll_read_aloud.onLongClick { + runMenuOut { callBack?.showReadAloudDialog() } + true + } + //界面 + ll_font.onClick { + runMenuOut { + callBack?.showReadStyle() + } + } + + //设置 + ll_setting.onClick { + runMenuOut { + callBack?.showMoreSetting() + } + } + } + + private fun initAnimation() { + menuTopIn = AnimationUtils.loadAnimation(context, R.anim.anim_readbook_top_in) + menuBottomIn = AnimationUtils.loadAnimation(context, R.anim.anim_readbook_bottom_in) + menuTopIn.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation) { + callBack?.upSystemUiVisibility() + } + + override fun onAnimationEnd(animation: Animation) { + vw_menu_bg.onClick { runMenuOut() } + vwNavigationBar.layoutParams = vwNavigationBar.layoutParams.apply { + height = + if (context.getPrefBoolean("hideNavigationBar") + && Help.isNavigationBarExist(activity) + ) context.getNavigationBarHeight() + else 0 + } + } + + override fun onAnimationRepeat(animation: Animation) { + + } + }) + + //隐藏菜单 + menuTopOut = AnimationUtils.loadAnimation(context, R.anim.anim_readbook_top_out) + menuBottomOut = AnimationUtils.loadAnimation(context, R.anim.anim_readbook_bottom_out) + menuTopOut.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation) { + vw_menu_bg.setOnClickListener(null) + } + + override fun onAnimationEnd(animation: Animation) { + this@ReadMenu.invisible() + title_bar.invisible() + bottom_menu.invisible() + cnaShowMenu = false + onMenuOutEnd?.invoke() + callBack?.upSystemUiVisibility() + } + + override fun onAnimationRepeat(animation: Animation) { + + } + }) + } + + fun setAutoPage(autoPage: Boolean) { + if (autoPage) { + fabAutoPage.setImageResource(R.drawable.ic_auto_page_stop) + fabAutoPage.contentDescription = context.getString(R.string.auto_next_page_stop) + } else { + fabAutoPage.setImageResource(R.drawable.ic_auto_page) + fabAutoPage.contentDescription = context.getString(R.string.auto_next_page) + } + } + + interface CallBack { + fun autoPage() + fun skipToPage(page: Int) + fun moveToPrevChapter(upContent: Boolean, last: Boolean): Boolean + fun moveToNextChapter(upContent: Boolean): Boolean + fun openReplaceRule() + fun openChapterList() + fun showReadStyle() + fun showMoreSetting() + fun showReadAloudDialog() + fun upSystemUiVisibility() + } + +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/BgTextConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/BgTextConfigDialog.kt new file mode 100644 index 000000000..d8112b538 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/BgTextConfigDialog.kt @@ -0,0 +1,184 @@ +package io.legado.app.ui.book.read.config + +import android.annotation.SuppressLint +import android.app.Activity.RESULT_OK +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import android.util.DisplayMetrics +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.jaredrummler.android.colorpicker.ColorPickerDialog +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.SimpleRecyclerAdapter +import io.legado.app.constant.Bus +import io.legado.app.help.ImageLoader +import io.legado.app.help.ReadBookConfig +import io.legado.app.help.permission.Permissions +import io.legado.app.help.permission.PermissionsCompat +import io.legado.app.ui.book.read.Help +import io.legado.app.utils.FileUtils +import io.legado.app.utils.getCompatColor +import io.legado.app.utils.postEvent +import kotlinx.android.synthetic.main.dialog_read_bg_text.* +import kotlinx.android.synthetic.main.item_bg_image.view.* +import org.jetbrains.anko.sdk27.listeners.onCheckedChange +import org.jetbrains.anko.sdk27.listeners.onClick + +class BgTextConfigDialog : DialogFragment() { + + companion object { + const val TEXT_COLOR = 121 + const val BG_COLOR = 122 + } + + private val resultSelectBg = 123 + private lateinit var adapter: BgAdapter + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.dialog_read_bg_text, container) + } + + override fun onStart() { + super.onStart() + val dm = DisplayMetrics() + activity?.let { + Help.upSystemUiVisibility(it) + it.windowManager?.defaultDisplay?.getMetrics(dm) + } + dialog?.window?.let { + it.setBackgroundDrawableResource(R.color.transparent) + it.decorView.setPadding(0, 0, 0, 0) + val attr = it.attributes + attr.dimAmount = 0.0f + attr.gravity = Gravity.BOTTOM + it.attributes = attr + it.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initData() + initView() + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + ReadBookConfig.save() + } + + @SuppressLint("InflateParams") + private fun initData() = with(ReadBookConfig.getConfig()) { + sw_dark_status_icon.isChecked = statusIconDark() + adapter = BgAdapter(requireContext()) + recycler_view.layoutManager = + LinearLayoutManager(requireContext(), RecyclerView.HORIZONTAL, false) + recycler_view.adapter = adapter + val headerView = LayoutInflater.from(requireContext()) + .inflate(R.layout.item_bg_image, recycler_view, false) + adapter.addHeaderView(headerView) + headerView.tv_name.text = getString(R.string.select_image) + headerView.iv_bg.setImageResource(R.drawable.ic_image) + headerView.iv_bg.setColorFilter(getCompatColor(R.color.tv_text_default)) + headerView.onClick { selectImage() } + requireContext().assets.list("bg/")?.let { + adapter.setItems(it.toList()) + } + } + + private fun initView() = with(ReadBookConfig.getConfig()) { + sw_dark_status_icon.onCheckedChange { buttonView, isChecked -> + if (buttonView?.isPressed == true) { + setStatusIconDark(isChecked) + activity?.let { + Help.upSystemUiVisibility(it) + } + } + } + tv_text_color.onClick { + ColorPickerDialog.newBuilder() + .setColor(textColor()) + .setShowAlphaSlider(false) + .setDialogType(ColorPickerDialog.TYPE_CUSTOM) + .setDialogId(TEXT_COLOR) + .show(requireActivity()) + } + tv_bg_color.onClick { + val bgColor = + if (bgType() == 0) Color.parseColor(bgStr()) + else Color.parseColor("#015A86") + ColorPickerDialog.newBuilder() + .setColor(bgColor) + .setShowAlphaSlider(false) + .setDialogType(ColorPickerDialog.TYPE_CUSTOM) + .setDialogId(BG_COLOR) + .show(requireActivity()) + } + tv_default.onClick { + + } + } + + private fun selectImage() { + PermissionsCompat.Builder(this) + .addPermissions(Permissions.READ_EXTERNAL_STORAGE, Permissions.WRITE_EXTERNAL_STORAGE) + .rationale(R.string.bg_image_per) + .onGranted { + val intent = Intent(Intent.ACTION_GET_CONTENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = "image/*" + startActivityForResult(intent, resultSelectBg) + Unit + } + .request() + } + + class BgAdapter(context: Context) : + SimpleRecyclerAdapter(context, R.layout.item_bg_image) { + + override fun convert(holder: ItemViewHolder, item: String, payloads: MutableList) { + with(holder.itemView) { + ImageLoader.load(context, context.assets.open("bg/$item").readBytes()) + .centerCrop() + .setAsBitmap(iv_bg) + tv_name.text = item.substringBeforeLast(".") + this.onClick { + ReadBookConfig.getConfig().setBg(1, item) + ReadBookConfig.upBg() + postEvent(Bus.UP_CONFIG, false) + } + } + } + + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + when (requestCode) { + resultSelectBg -> { + if (resultCode == RESULT_OK) { + data?.data?.let { + FileUtils.getPath(requireContext(), it)?.let { path -> + ReadBookConfig.getConfig().setBg(2, path) + ReadBookConfig.upBg() + postEvent(Bus.UP_CONFIG, false) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/MoreConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/MoreConfigDialog.kt new file mode 100644 index 000000000..4a869a1ed --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/MoreConfigDialog.kt @@ -0,0 +1,100 @@ +package io.legado.app.ui.book.read.config + +import android.content.SharedPreferences +import android.os.Bundle +import android.util.DisplayMetrics +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.fragment.app.DialogFragment +import androidx.preference.PreferenceFragmentCompat +import io.legado.app.R +import io.legado.app.constant.Bus +import io.legado.app.lib.theme.ATH +import io.legado.app.ui.book.read.Help +import io.legado.app.utils.postEvent + +class MoreConfigDialog : DialogFragment() { + + private val readPreferTag = "readPreferenceFragment" + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val view = LinearLayout(context) + view.setBackgroundResource(R.color.background) + view.id = R.id.tag1 + container?.addView(view) + return view + } + + override fun onStart() { + super.onStart() + val dm = DisplayMetrics() + activity?.let { + Help.upSystemUiVisibility(it) + it.windowManager?.defaultDisplay?.getMetrics(dm) + } + dialog?.window?.let { + it.setBackgroundDrawableResource(R.color.transparent) + it.decorView.setPadding(0, 0, 0, 0) + val attr = it.attributes + attr.dimAmount = 0.0f + attr.gravity = Gravity.BOTTOM + it.attributes = attr + it.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + var preferenceFragment = childFragmentManager.findFragmentByTag(readPreferTag) + if (preferenceFragment == null) preferenceFragment = + ReadPreferenceFragment() + childFragmentManager.beginTransaction() + .replace(view.id, preferenceFragment, readPreferTag) + .commit() + } + + class ReadPreferenceFragment : PreferenceFragmentCompat(), + SharedPreferences.OnSharedPreferenceChangeListener { + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_config_read) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + ATH.applyEdgeEffectColor(listView) + } + + override fun onResume() { + super.onResume() + preferenceManager + .sharedPreferences + .registerOnSharedPreferenceChangeListener(this) + } + + override fun onPause() { + preferenceManager + .sharedPreferences + .unregisterOnSharedPreferenceChangeListener(this) + super.onPause() + } + + override fun onSharedPreferenceChanged( + sharedPreferences: SharedPreferences?, + key: String? + ) { + when (key) { + "hideStatusBar" -> postEvent(Bus.UP_CONFIG, true) + "hideNavigationBar" -> postEvent(Bus.UP_CONFIG, true) + } + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/PaddingConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/PaddingConfigDialog.kt new file mode 100644 index 000000000..43aeaad2d --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/PaddingConfigDialog.kt @@ -0,0 +1,147 @@ +package io.legado.app.ui.book.read.config + +import android.os.Bundle +import android.util.DisplayMetrics +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.SeekBar +import androidx.fragment.app.DialogFragment +import io.legado.app.R +import io.legado.app.constant.Bus +import io.legado.app.help.ReadBookConfig +import io.legado.app.ui.book.read.Help +import io.legado.app.utils.postEvent +import io.legado.app.utils.progressAdd +import kotlinx.android.synthetic.main.dialog_read_padding.* +import org.jetbrains.anko.sdk27.listeners.onClick + +class PaddingConfigDialog : DialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.dialog_read_padding, container) + } + + override fun onStart() { + super.onStart() + val dm = DisplayMetrics() + activity?.let { + Help.upSystemUiVisibility(it) + it.windowManager?.defaultDisplay?.getMetrics(dm) + } + dialog?.window?.let { + it.setBackgroundDrawableResource(R.color.transparent) + it.decorView.setPadding(0, 0, 0, 0) + val attr = it.attributes + attr.dimAmount = 0.0f + it.attributes = attr + it.setLayout((dm.widthPixels * 0.9).toInt(), ViewGroup.LayoutParams.WRAP_CONTENT) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initData() + initView() + } + + private fun initData() = with(ReadBookConfig.getConfig()) { + seek_padding_top.progress = paddingTop + seek_padding_bottom.progress = paddingBottom + seek_padding_left.progress = paddingLeft + seek_padding_right.progress = paddingRight + tv_padding_top.text = paddingTop.toString() + tv_padding_bottom.text = paddingBottom.toString() + tv_padding_left.text = paddingLeft.toString() + tv_padding_right.text = paddingRight.toString() + } + + private fun initView() = with(ReadBookConfig.getConfig()) { + iv_padding_top_add.onClick { + seek_padding_top.progressAdd(1) + postEvent(Bus.UP_CONFIG, true) + } + iv_padding_top_remove.onClick { + seek_padding_top.progressAdd(-1) + postEvent(Bus.UP_CONFIG, true) + } + iv_padding_bottom_add.onClick { + seek_padding_bottom.progressAdd(1) + postEvent(Bus.UP_CONFIG, true) + } + iv_padding_bottom_remove.onClick { + seek_padding_bottom.progressAdd(-1) + postEvent(Bus.UP_CONFIG, true) + } + iv_padding_left_add.onClick { + seek_padding_left.progressAdd(1) + postEvent(Bus.UP_CONFIG, true) + } + iv_padding_left_remove.onClick { + seek_padding_left.progressAdd(-1) + postEvent(Bus.UP_CONFIG, true) + } + iv_padding_right_add.onClick { + seek_padding_right.progressAdd(1) + postEvent(Bus.UP_CONFIG, true) + } + iv_padding_right_remove.onClick { + seek_padding_right.progressAdd(-1) + postEvent(Bus.UP_CONFIG, true) + } + + seek_padding_top.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + paddingTop = progress + tv_padding_top.text = paddingTop.toString() + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit + + override fun onStopTrackingTouch(seekBar: SeekBar?) { + postEvent(Bus.UP_CONFIG, true) + } + }) + seek_padding_bottom.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + paddingBottom = progress + tv_padding_bottom.text = paddingBottom.toString() + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit + + override fun onStopTrackingTouch(seekBar: SeekBar?) { + postEvent(Bus.UP_CONFIG, true) + } + }) + seek_padding_left.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + paddingLeft = progress + tv_padding_left.text = paddingLeft.toString() + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit + + override fun onStopTrackingTouch(seekBar: SeekBar?) { + postEvent(Bus.UP_CONFIG, true) + } + }) + seek_padding_right.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + paddingRight = progress + tv_padding_right.text = paddingRight.toString() + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit + + override fun onStopTrackingTouch(seekBar: SeekBar?) { + postEvent(Bus.UP_CONFIG, true) + } + }) + } + +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudConfigDialog.kt new file mode 100644 index 000000000..699037325 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudConfigDialog.kt @@ -0,0 +1,130 @@ +package io.legado.app.ui.book.read.config + +import android.content.SharedPreferences +import android.os.Bundle +import android.util.DisplayMetrics +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.fragment.app.DialogFragment +import androidx.preference.ListPreference +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import io.legado.app.R +import io.legado.app.constant.Bus +import io.legado.app.help.ReadAloud +import io.legado.app.lib.theme.ATH +import io.legado.app.service.BaseReadAloudService +import io.legado.app.ui.book.read.Help +import io.legado.app.utils.getPrefString +import io.legado.app.utils.postEvent + +class ReadAloudConfigDialog : DialogFragment() { + private val readAloudPreferTag = "readAloudPreferTag" + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val view = LinearLayout(context) + view.setBackgroundResource(R.color.background) + view.id = R.id.tag1 + container?.addView(view) + return view + } + + override fun onStart() { + super.onStart() + val dm = DisplayMetrics() + activity?.let { + Help.upSystemUiVisibility(it) + it.windowManager?.defaultDisplay?.getMetrics(dm) + } + dialog?.window?.let { + it.setBackgroundDrawableResource(R.color.transparent) + it.setLayout((dm.widthPixels * 0.9).toInt(), ViewGroup.LayoutParams.WRAP_CONTENT) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + var preferenceFragment = childFragmentManager.findFragmentByTag(readAloudPreferTag) + if (preferenceFragment == null) preferenceFragment = + ReadAloudPreferenceFragment() + childFragmentManager.beginTransaction() + .replace(view.id, preferenceFragment, readAloudPreferTag) + .commit() + } + + class ReadAloudPreferenceFragment : PreferenceFragmentCompat(), + SharedPreferences.OnSharedPreferenceChangeListener, + Preference.OnPreferenceChangeListener { + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_config_aloud) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + ATH.applyEdgeEffectColor(listView) + bindPreferenceSummaryToValue(findPreference("ttsSpeechPer")) + } + + override fun onResume() { + super.onResume() + preferenceManager.sharedPreferences.registerOnSharedPreferenceChangeListener(this) + } + + override fun onPause() { + preferenceManager.sharedPreferences.unregisterOnSharedPreferenceChangeListener(this) + super.onPause() + } + + override fun onSharedPreferenceChanged( + sharedPreferences: SharedPreferences?, + key: String? + ) { + when (key) { + "readAloudByPage" -> { + if (BaseReadAloudService.isRun) { + postEvent(Bus.READ_ALOUD_BUTTON, false) + } + } + "readAloudOnLine" -> { + if (BaseReadAloudService.isRun) { + ReadAloud.stop(requireContext()) + ReadAloud.aloudClass = ReadAloud.getReadAloudClass() + } + } + "ttsSpeechPer" -> ReadAloud.upTtsSpeechRate(requireContext()) + } + } + + override fun onPreferenceChange(preference: Preference?, newValue: Any?): Boolean { + val stringValue = newValue.toString() + + if (preference is ListPreference) { + val index = preference.findIndexOfValue(stringValue) + // Set the summary to reflect the new value. + preference.setSummary(if (index >= 0) preference.entries[index] else null) + } else { + // For all other preferences, set the summary to the value's + preference?.summary = stringValue + } + return true + } + + private fun bindPreferenceSummaryToValue(preference: Preference?) { + preference?.apply { + onPreferenceChangeListener = this@ReadAloudPreferenceFragment + onPreferenceChange( + this, + context.getPrefString(key) + ) + } + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudDialog.kt new file mode 100644 index 000000000..e9f524dcb --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudDialog.kt @@ -0,0 +1,141 @@ +package io.legado.app.ui.book.read.config + +import android.os.Bundle +import android.util.DisplayMetrics +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.SeekBar +import androidx.fragment.app.DialogFragment +import io.legado.app.R +import io.legado.app.constant.Bus +import io.legado.app.constant.Status +import io.legado.app.help.ReadAloud +import io.legado.app.service.BaseReadAloudService +import io.legado.app.ui.book.read.Help +import io.legado.app.utils.* +import kotlinx.android.synthetic.main.dialog_read_aloud.* +import org.jetbrains.anko.sdk27.listeners.onClick +import org.jetbrains.anko.sdk27.listeners.onLongClick + +class ReadAloudDialog : DialogFragment() { + var callBack: CallBack? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + callBack = activity as? CallBack + return inflater.inflate(R.layout.dialog_read_aloud, container) + } + + override fun onStart() { + super.onStart() + val dm = DisplayMetrics() + activity?.let { + Help.upSystemUiVisibility(it) + it.windowManager?.defaultDisplay?.getMetrics(dm) + } + dialog?.window?.let { + it.setBackgroundDrawableResource(R.color.transparent) + it.decorView.setPadding(0, 0, 0, 0) + val attr = it.attributes + attr.dimAmount = 0.0f + attr.gravity = Gravity.BOTTOM + it.attributes = attr + it.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initData() + initOnChange() + initOnClick() + } + + private fun initData() { + observeEvent(Bus.ALOUD_STATE) { upPlayState(it) } + observeEvent(Bus.TTS_DS) { seek_timer.progress = it } + callBack?.readAloudStatus?.let { + upPlayState(it) + } + seek_timer.progress = BaseReadAloudService.timeMinute + tv_timer.text = + requireContext().getString(R.string.timer_m, BaseReadAloudService.timeMinute) + cb_tts_follow_sys.isChecked = requireContext().getPrefBoolean("ttsFollowSys", true) + seek_tts_SpeechRate.isEnabled = !cb_tts_follow_sys.isChecked + seek_tts_SpeechRate.progress = requireContext().getPrefInt("ttsSpeechRate", 5) + } + + private fun initOnChange() { + cb_tts_follow_sys.setOnCheckedChangeListener { buttonView, isChecked -> + if (buttonView.isPressed) { + requireContext().putPrefBoolean("ttsFollowSys", isChecked) + seek_tts_SpeechRate.isEnabled = !isChecked + upTtsSpeechRate() + } + } + seek_tts_SpeechRate.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit + + override fun onStopTrackingTouch(seekBar: SeekBar?) { + requireContext().putPrefInt("ttsSpeechRate", seek_tts_SpeechRate.progress) + upTtsSpeechRate() + } + + }) + seek_timer.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + tv_timer.text = requireContext().getString(R.string.timer_m, progress) + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit + + override fun onStopTrackingTouch(seekBar: SeekBar?) { + ReadAloud.setTimer(requireContext(), seek_timer.progress) + } + }) + } + + private fun initOnClick() { + iv_menu.onClick { callBack?.showMenu(); dismiss() } + iv_other_config.onClick { + ReadAloudConfigDialog().show(childFragmentManager, "readAloudConfigDialog") + } + iv_menu.onLongClick { callBack?.openChapterList(); true } + iv_stop.onClick { ReadAloud.stop(requireContext()); dismiss() } + iv_play_pause.onClick { postEvent(Bus.READ_ALOUD_BUTTON, true) } + iv_play_prev.onClick { ReadAloud.prevParagraph(requireContext()) } + iv_play_prev.onLongClick { postEvent(Bus.TTS_TURN_PAGE, -2); true } + iv_play_next.onClick { ReadAloud.nextParagraph(requireContext()) } + iv_play_next.onLongClick { postEvent(Bus.TTS_TURN_PAGE, 2); true } + } + + private fun upPlayState(state: Int) { + if (state == Status.PLAY) { + iv_play_pause.setImageResource(R.drawable.ic_pause_24dp) + } else { + iv_play_pause.setImageResource(R.drawable.ic_play_24dp) + } + } + + private fun upTtsSpeechRate() { + ReadAloud.upTtsSpeechRate(requireContext()) + if (callBack?.readAloudStatus == Status.PLAY) { + ReadAloud.pause(requireContext()) + ReadAloud.resume(requireContext()) + } + } + + interface CallBack { + fun showMenu() + fun openChapterList() + var readAloudStatus: Int + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/ReadStyleDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/ReadStyleDialog.kt new file mode 100644 index 000000000..d64fc4db9 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/ReadStyleDialog.kt @@ -0,0 +1,272 @@ +package io.legado.app.ui.book.read.config + +import android.os.Bundle +import android.util.DisplayMetrics +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.SeekBar +import androidx.core.view.get +import androidx.fragment.app.DialogFragment +import io.legado.app.R +import io.legado.app.constant.Bus +import io.legado.app.help.ImageLoader +import io.legado.app.help.ReadBookConfig +import io.legado.app.lib.dialogs.selector +import io.legado.app.lib.theme.accentColor +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.book.read.Help +import io.legado.app.ui.book.read.ReadBookActivity +import io.legado.app.ui.widget.font.FontSelectDialog +import io.legado.app.utils.* +import kotlinx.android.synthetic.main.activity_book_read.* +import kotlinx.android.synthetic.main.dialog_read_book_style.* +import org.jetbrains.anko.sdk27.listeners.onCheckedChange +import org.jetbrains.anko.sdk27.listeners.onClick +import org.jetbrains.anko.sdk27.listeners.onLongClick + +class ReadStyleDialog : DialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.dialog_read_book_style, container) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initData() + initOnClick() + } + + override fun onStart() { + super.onStart() + val dm = DisplayMetrics() + activity?.let { + Help.upSystemUiVisibility(it) + it.windowManager?.defaultDisplay?.getMetrics(dm) + } + dialog?.window?.let { + it.setBackgroundDrawableResource(R.color.transparent) + it.decorView.setPadding(0, 0, 0, 0) + val attr = it.attributes + attr.dimAmount = 0.0f + attr.gravity = Gravity.BOTTOM + it.attributes = attr + it.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + } + } + + override fun onDestroy() { + super.onDestroy() + ReadBookConfig.save() + } + + private fun initData() { + requireContext().getPrefInt("pageAnim").let { + if (it >= 0 && it < rg_page_anim.childCount) { + rg_page_anim.check(rg_page_anim[it].id) + } + } + upStyle() + setBg() + upBg() + } + + private fun initOnClick() { + tv_text_bold.onClick { + with(ReadBookConfig.getConfig()) { + textBold = !textBold + tv_text_bold.isSelected = textBold + } + postEvent(Bus.UP_CONFIG, false) + } + tv_text_font.onClick { + FontSelectDialog(requireContext()).apply { + curPath = requireContext().getPrefString("readBookFont") + defaultFont = { + requireContext().putPrefString("readBookFont", "") + postEvent(Bus.UP_CONFIG, true) + } + selectFile = { + requireContext().putPrefString("readBookFont", it) + postEvent(Bus.UP_CONFIG, true) + } + }.show() + } + tv_text_indent.onClick { + selector( + title = getString(R.string.text_indent), + items = resources.getStringArray(R.array.indent).toList() + ) { _, index -> + putPrefInt("textIndent", index) + postEvent(Bus.UP_CONFIG, true) + } + } + tv_padding.onClick { + val activity = activity + dismiss() + if (activity is ReadBookActivity) { + activity.showPaddingConfig() + } + } + seek_text_size.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + ReadBookConfig.getConfig().textSize = progress + 5 + tv_text_size.text = ReadBookConfig.getConfig().textSize.toString() + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit + override fun onStopTrackingTouch(seekBar: SeekBar?) { + postEvent(Bus.UP_CONFIG, true) + } + }) + iv_text_size_add.onClick { + seek_text_size.progressAdd(1) + postEvent(Bus.UP_CONFIG, true) + } + iv_text_size_remove.onClick { + seek_text_size.progressAdd(-1) + postEvent(Bus.UP_CONFIG, true) + } + seek_text_letter_spacing.setOnSeekBarChangeListener(object : + SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + with(ReadBookConfig.getConfig()) { + letterSpacing = (seek_text_letter_spacing.progress - 5) / 10f + tv_text_letter_spacing.text = letterSpacing.toString() + } + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit + override fun onStopTrackingTouch(seekBar: SeekBar?) { + postEvent(Bus.UP_CONFIG, true) + } + }) + iv_text_letter_spacing_add.onClick { + seek_text_letter_spacing.progressAdd(1) + postEvent(Bus.UP_CONFIG, true) + } + iv_text_letter_spacing_remove.onClick { + seek_text_letter_spacing.progressAdd(-1) + postEvent(Bus.UP_CONFIG, true) + } + seek_line_size.setOnSeekBarChangeListener(object : + SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + with(ReadBookConfig.getConfig()) { + lineSpacingExtra = seek_line_size.progress + tv_line_size.text = lineSpacingExtra.toString() + } + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit + override fun onStopTrackingTouch(seekBar: SeekBar?) { + postEvent(Bus.UP_CONFIG, true) + } + }) + iv_line_size_add.onClick { + seek_line_size.progressAdd(1) + postEvent(Bus.UP_CONFIG, true) + } + iv_line_size_remove.onClick { + seek_line_size.progressAdd(-1) + postEvent(Bus.UP_CONFIG, true) + } + rg_page_anim.onCheckedChange { _, checkedId -> + for (i in 0 until rg_page_anim.childCount) { + if (checkedId == rg_page_anim[i].id) { + requireContext().putPrefInt("pageAnim", i) + val activity = activity + if (activity is ReadBookActivity) { + activity.page_view.upPageAnim() + } + break + } + } + } + tv_bg0.onClick { changeBg(0) } + tv_bg0.onLongClick { showBgTextConfig(0) } + tv_bg1.onClick { changeBg(1) } + tv_bg1.onLongClick { showBgTextConfig(1) } + tv_bg2.onClick { changeBg(2) } + tv_bg2.onLongClick { showBgTextConfig(2) } + tv_bg3.onClick { changeBg(3) } + tv_bg3.onLongClick { showBgTextConfig(3) } + tv_bg4.onClick { changeBg(4) } + tv_bg4.onLongClick { showBgTextConfig(4) } + } + + private fun changeBg(index: Int) { + if (ReadBookConfig.styleSelect != index) { + ReadBookConfig.styleSelect = index + ReadBookConfig.upBg() + upStyle() + upBg() + postEvent(Bus.UP_CONFIG, true) + } + } + + private fun showBgTextConfig(index: Int): Boolean { + dismiss() + changeBg(index) + val activity = activity + if (activity is ReadBookActivity) { + activity.showBgTextConfig() + } + return true + } + + private fun upStyle() { + ReadBookConfig.getConfig().let { + tv_text_bold.isSelected = it.textBold + seek_text_size.progress = it.textSize - 5 + tv_text_size.text = it.textSize.toString() + seek_text_letter_spacing.progress = (it.letterSpacing * 10).toInt() + 5 + tv_text_letter_spacing.text = it.letterSpacing.toString() + seek_line_size.progress = it.lineSpacingExtra + tv_line_size.text = it.lineSpacingExtra.toString() + } + } + + private fun setBg() { + tv_bg0.setTextColor(ReadBookConfig.getConfig(0).textColor()) + tv_bg1.setTextColor(ReadBookConfig.getConfig(1).textColor()) + tv_bg2.setTextColor(ReadBookConfig.getConfig(2).textColor()) + tv_bg3.setTextColor(ReadBookConfig.getConfig(3).textColor()) + tv_bg4.setTextColor(ReadBookConfig.getConfig(4).textColor()) + for (i in 0..4) { + val iv = when (i) { + 1 -> bg1 + 2 -> bg2 + 3 -> bg3 + 4 -> bg4 + else -> bg0 + } + ReadBookConfig.getConfig(i).apply { + when (bgType()) { + 2 -> ImageLoader.load(requireContext(), bgStr()).centerCrop().setAsDrawable(iv) + else -> iv.setImageDrawable(bgDrawable(100, 150)) + } + } + } + } + + private fun upBg() { + bg0.borderColor = requireContext().primaryColor + bg1.borderColor = requireContext().primaryColor + bg2.borderColor = requireContext().primaryColor + bg3.borderColor = requireContext().primaryColor + bg4.borderColor = requireContext().primaryColor + when (ReadBookConfig.styleSelect) { + 1 -> bg1.borderColor = requireContext().accentColor + 2 -> bg2.borderColor = requireContext().accentColor + 3 -> bg3.borderColor = requireContext().accentColor + 4 -> bg4.borderColor = requireContext().accentColor + else -> bg0.borderColor = requireContext().accentColor + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/search/BookAdapter.kt b/app/src/main/java/io/legado/app/ui/book/search/BookAdapter.kt new file mode 100644 index 000000000..3a08418e1 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/search/BookAdapter.kt @@ -0,0 +1,24 @@ +package io.legado.app.ui.book.search + +import android.content.Context +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.SimpleRecyclerAdapter +import io.legado.app.data.entities.Book +import kotlinx.android.synthetic.main.item_text.view.* +import org.jetbrains.anko.sdk27.listeners.onClick + +class BookAdapter(context: Context, val callBack: CallBack) : + SimpleRecyclerAdapter(context, R.layout.item_text) { + + override fun convert(holder: ItemViewHolder, item: Book, payloads: MutableList) { + with(holder.itemView) { + text_view.text = item.name + onClick { callBack.showBookInfo(item.bookUrl) } + } + } + + interface CallBack { + fun showBookInfo(url: String) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/search/DiffCallBack.kt b/app/src/main/java/io/legado/app/ui/book/search/DiffCallBack.kt new file mode 100644 index 000000000..0de21ee19 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/search/DiffCallBack.kt @@ -0,0 +1,30 @@ +package io.legado.app.ui.book.search + +import androidx.recyclerview.widget.DiffUtil +import io.legado.app.data.entities.SearchShow + +class DiffCallBack : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: SearchShow, newItem: SearchShow): Boolean { + return oldItem.name == newItem.name + && oldItem.author == newItem.author + } + + override fun areContentsTheSame(oldItem: SearchShow, newItem: SearchShow): Boolean { + return oldItem.originCount == newItem.originCount + && (oldItem.coverUrl == newItem.coverUrl || !oldItem.coverUrl.isNullOrEmpty()) + && (oldItem.kind == newItem.kind || !oldItem.kind.isNullOrEmpty()) + && (oldItem.latestChapterTitle == newItem.latestChapterTitle || !oldItem.kind.isNullOrEmpty()) + && oldItem.intro?.length ?: 0 > newItem.intro?.length ?: 0 + } + + override fun getChangePayload(oldItem: SearchShow, newItem: SearchShow): Any? { + return when { + oldItem.originCount != newItem.originCount -> 1 + oldItem.coverUrl != newItem.coverUrl -> 2 + oldItem.kind != newItem.kind -> 3 + oldItem.latestChapterTitle != newItem.latestChapterTitle -> 4 + oldItem.intro != newItem.intro -> 5 + else -> null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/search/HistoryKeyAdapter.kt b/app/src/main/java/io/legado/app/ui/book/search/HistoryKeyAdapter.kt new file mode 100644 index 000000000..213157e90 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/search/HistoryKeyAdapter.kt @@ -0,0 +1,42 @@ +package io.legado.app.ui.book.search + +import android.content.Context +import io.legado.app.App +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.SimpleRecyclerAdapter +import io.legado.app.data.entities.SearchKeyword +import io.legado.app.ui.widget.anima.explosion_field.ExplosionField +import kotlinx.android.synthetic.main.item_text.view.* +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.jetbrains.anko.sdk27.listeners.onClick +import org.jetbrains.anko.sdk27.listeners.onLongClick + + +class HistoryKeyAdapter(context: Context, val callBack: CallBack) : + SimpleRecyclerAdapter(context, R.layout.item_text) { + + override fun convert(holder: ItemViewHolder, item: SearchKeyword, payloads: MutableList) { + with(holder.itemView) { + text_view.text = item.word + onClick { + callBack.searchHistory(item.word) + } + onLongClick { + it?.let { + ExplosionField(context).explode(it, true) + } + GlobalScope.launch(IO) { + App.db.searchKeywordDao().delete(item) + } + true + } + } + } + + interface CallBack { + fun searchHistory(key: String) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt new file mode 100644 index 000000000..7214418dd --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt @@ -0,0 +1,239 @@ +package io.legado.app.ui.book.search + +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.appcompat.widget.SearchView +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.paging.LivePagedListBuilder +import androidx.paging.PagedList +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.App +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.SearchKeyword +import io.legado.app.data.entities.SearchShow +import io.legado.app.lib.theme.ATH +import io.legado.app.lib.theme.primaryTextColor +import io.legado.app.ui.book.info.BookInfoActivity +import io.legado.app.ui.book.source.manage.BookSourceActivity +import io.legado.app.utils.* +import kotlinx.android.synthetic.main.activity_book_search.* +import kotlinx.android.synthetic.main.view_search.* +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jetbrains.anko.sdk27.listeners.onClick +import org.jetbrains.anko.startActivity + +class SearchActivity : VMBaseActivity(R.layout.activity_book_search), + BookAdapter.CallBack, + HistoryKeyAdapter.CallBack, + SearchAdapter.CallBack { + + override val viewModel: SearchViewModel + get() = getViewModel(SearchViewModel::class.java) + + private lateinit var adapter: SearchAdapter + private lateinit var bookAdapter: BookAdapter + private lateinit var historyKeyAdapter: HistoryKeyAdapter + private var searchBookData: LiveData>? = null + private var historyData: LiveData>? = null + private var bookData: LiveData>? = null + private var menu: Menu? = null + private var precisionSearchMenuItem: MenuItem? = null + private var groups = hashSetOf() + + override fun onActivityCreated(savedInstanceState: Bundle?) { + initRecyclerView() + initSearchView() + initOtherView() + initData() + initIntent() + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.book_search, menu) + precisionSearchMenuItem = menu.findItem(R.id.menu_precision_search) + precisionSearchMenuItem?.isChecked = getPrefBoolean("precisionSearch") + this.menu = menu + upGroupMenu() + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_precision_search -> { + putPrefBoolean("precisionSearch", !getPrefBoolean("precisionSearch")) + precisionSearchMenuItem?.isChecked = getPrefBoolean("precisionSearch") + } + R.id.menu_source_manage -> startActivity() + else -> if (item.groupId == R.id.source_group) { + item.isChecked = true + if (item.title.toString() == getString(R.string.all_source)) { + putPrefString("searchGroup", "") + } else { + putPrefString("searchGroup", item.title.toString()) + } + } + } + return super.onCompatOptionsItemSelected(item) + } + + private fun initSearchView() { + ATH.setTint(search_view, primaryTextColor) + search_view.onActionViewExpanded() + search_view.isSubmitButtonEnabled = true + search_view.queryHint = getString(R.string.search_book_key) + search_view.clearFocus() + search_view.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + search_view.clearFocus() + query?.let { + viewModel.saveSearchKey(query) + viewModel.search(it, { + refresh_progress_bar.isAutoLoading = true + initData() + fb_stop.visible() + }, { + refresh_progress_bar.isAutoLoading = false + fb_stop.invisible() + }) + } + return true + } + + override fun onQueryTextChange(newText: String?): Boolean { + if (newText.isNullOrBlank()) viewModel.stop() + upHistory(newText) + return false + } + }) + search_view.setOnQueryTextFocusChangeListener { _, hasFocus -> + if (hasFocus) { + ll_history.visible() + } else { + ll_history.invisible() + } + } + } + + private fun initRecyclerView() { + ATH.applyEdgeEffectColor(recycler_view) + ATH.applyEdgeEffectColor(rv_bookshelf_search) + ATH.applyEdgeEffectColor(rv_history_key) + bookAdapter = BookAdapter(this, this) + rv_bookshelf_search.layoutManager = + LinearLayoutManager(this, RecyclerView.HORIZONTAL, false) + rv_bookshelf_search.adapter = bookAdapter + historyKeyAdapter = HistoryKeyAdapter(this, this) + rv_history_key.layoutManager = LinearLayoutManager(this, RecyclerView.HORIZONTAL, false) + rv_history_key.adapter = historyKeyAdapter + adapter = SearchAdapter(this) + recycler_view.layoutManager = LinearLayoutManager(this) + recycler_view.adapter = adapter + } + + private fun initOtherView() { + tv_clear_history.onClick { viewModel.clearHistory() } + fb_stop.onClick { + viewModel.stop() + refresh_progress_bar.isAutoLoading = false + } + } + + private fun initData() { + searchBookData?.removeObservers(this) + searchBookData = LivePagedListBuilder( + App.db.searchBookDao().observeShow( + viewModel.searchKey, + viewModel.startTime + ), 30 + ).build() + searchBookData?.observe(this, Observer { adapter.submitList(it) }) + App.db.bookSourceDao().liveGroupEnabled().observe(this, Observer { + groups.clear() + it.map { group -> + groups.addAll(group.splitNotBlank(",", ";")) + } + upGroupMenu() + }) + } + + private fun initIntent() { + intent.getStringExtra("key")?.let { + search_view.setQuery(it, true) + } ?: let { + search_view.requestFocus() + } + } + + private fun upGroupMenu() { + val selectedGroup = getPrefString("searchGroup") ?: "" + menu?.removeGroup(R.id.source_group) + var item = menu?.add(R.id.source_group, Menu.NONE, Menu.NONE, R.string.all_source) + if (selectedGroup == "") { + item?.isChecked = true + } + groups.map { + item = menu?.add(R.id.source_group, Menu.NONE, Menu.NONE, it) + if (it == selectedGroup) { + item?.isChecked = true + } + } + menu?.setGroupCheckable(R.id.source_group, true, true) + } + + private fun upHistory(key: String? = null) { + bookData?.removeObservers(this) + if (key.isNullOrBlank()) { + tv_book_show.gone() + rv_bookshelf_search.gone() + } else { + bookData = App.db.bookDao().liveDataSearch(key) + bookData?.observe(this, Observer { + if (it.isEmpty()) { + tv_book_show.gone() + rv_bookshelf_search.gone() + } else { + tv_book_show.visible() + rv_bookshelf_search.visible() + } + bookAdapter.setItems(it) + }) + } + historyData?.removeObservers(this) + historyData = + if (key.isNullOrBlank()) { + App.db.searchKeywordDao().liveDataByUsage() + } else { + App.db.searchKeywordDao().liveDataSearch(key) + } + historyData?.observe(this, Observer { historyKeyAdapter.setItems(it) }) + } + + override fun showBookInfo(name: String, author: String) { + viewModel.getSearchBook(name, author) { searchBook -> + searchBook?.let { + startActivity(Pair("searchBookUrl", it.bookUrl)) + } + } + } + + override fun showBookInfo(url: String) { + startActivity(Pair("bookUrl", url)) + } + + override fun searchHistory(key: String) { + launch { + if (withContext(IO) { App.db.bookDao().findByName(key).isEmpty() }) { + search_view.setQuery(key, true) + } else { + search_view.setQuery(key, false) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchAdapter.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchAdapter.kt new file mode 100644 index 000000000..38974157d --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchAdapter.kt @@ -0,0 +1,160 @@ +package io.legado.app.ui.book.search + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.paging.PagedListAdapter +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.R +import io.legado.app.data.entities.SearchShow +import io.legado.app.help.ImageLoader +import io.legado.app.utils.gone +import io.legado.app.utils.visible +import kotlinx.android.synthetic.main.item_bookshelf_list.view.iv_cover +import kotlinx.android.synthetic.main.item_bookshelf_list.view.tv_name +import kotlinx.android.synthetic.main.item_search.view.* +import org.jetbrains.anko.sdk27.listeners.onClick + +class SearchAdapter(val callBack: CallBack) : + PagedListAdapter(DiffCallBack()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder { + return MyViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_search, parent, false)) + } + + override fun onBindViewHolder(holder: MyViewHolder, position: Int, payloads: MutableList) { + if (payloads.isEmpty()) { + super.onBindViewHolder(holder, position, payloads) + } else { + getItem(position)?.let { + holder.bindChange(it, payloads) + } + } + } + + override fun onBindViewHolder(holder: MyViewHolder, position: Int) { + getItem(position)?.let { + holder.bind(it, callBack) + } + } + + + class MyViewHolder(view: View) : RecyclerView.ViewHolder(view) { + + fun bind(searchBook: SearchShow, callBack: CallBack?) = with(itemView) { + tv_name.text = searchBook.name + tv_author.text = context.getString(R.string.author_show, searchBook.author) + bv_originCount.setBadgeCount(searchBook.originCount) + if (searchBook.latestChapterTitle.isNullOrEmpty()) { + tv_lasted.gone() + } else { + tv_lasted.text = context.getString(R.string.lasted_show, searchBook.latestChapterTitle) + tv_lasted.visible() + } + tv_introduce.text = context.getString(R.string.intro_show, searchBook.intro) + val kinds = searchBook.getKindList() + if (kinds.isEmpty()) { + ll_kind.gone() + } else { + ll_kind.visible() + for (index in 0..2) { + if (kinds.size > index) { + when (index) { + 0 -> { + tv_kind.text = kinds[index] + tv_kind.visible() + } + 1 -> { + tv_kind_1.text = kinds[index] + tv_kind_1.visible() + } + 2 -> { + tv_kind_2.text = kinds[index] + tv_kind_2.visible() + } + } + } else { + when (index) { + 0 -> tv_kind.gone() + 1 -> tv_kind_1.gone() + 2 -> tv_kind_2.gone() + } + } + } + } + searchBook.coverUrl.let { + ImageLoader.load(context, it)//Glide自动识别http://和file:// + .placeholder(R.drawable.image_cover_default) + .error(R.drawable.image_cover_default) + .centerCrop() + .setAsDrawable(iv_cover) + } + onClick { + callBack?.showBookInfo(searchBook.name, searchBook.author) + } + } + + fun bindChange(searchBook: SearchShow, payloads: MutableList) = + with(itemView) { + when (payloads[0]) { + 1 -> bv_originCount.setBadgeCount(searchBook.originCount) + 2 -> searchBook.coverUrl.let { + ImageLoader.load(context, it)//Glide自动识别http://和file:// + .placeholder(R.drawable.image_cover_default) + .error(R.drawable.image_cover_default) + .centerCrop() + .setAsDrawable(iv_cover) + } + 3 -> { + val kinds = searchBook.getKindList() + if (kinds.isEmpty()) { + ll_kind.gone() + } else { + ll_kind.visible() + for (index in 0..2) { + if (kinds.size > index) { + when (index) { + 0 -> { + tv_kind.text = kinds[index] + tv_kind.visible() + } + 1 -> { + tv_kind_1.text = kinds[index] + tv_kind_1.visible() + } + 2 -> { + tv_kind_2.text = kinds[index] + tv_kind_2.visible() + } + } + } else { + when (index) { + 0 -> tv_kind.gone() + 1 -> tv_kind_1.gone() + 2 -> tv_kind_2.gone() + } + } + } + } + } + 4 -> { + if (searchBook.latestChapterTitle.isNullOrEmpty()) { + tv_lasted.gone() + } else { + tv_lasted.text = context.getString( + R.string.lasted_show, + searchBook.latestChapterTitle + ) + tv_lasted.visible() + } + } + 5 -> tv_introduce.text = + context.getString(R.string.intro_show, searchBook.intro) + } + } + } + + interface CallBack { + fun showBookInfo(name: String, author: String) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt new file mode 100644 index 000000000..f43738c22 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt @@ -0,0 +1,108 @@ +package io.legado.app.ui.book.search + +import android.app.Application +import io.legado.app.App +import io.legado.app.base.BaseViewModel +import io.legado.app.data.entities.SearchBook +import io.legado.app.data.entities.SearchKeyword +import io.legado.app.help.coroutine.Coroutine +import io.legado.app.model.WebBook +import io.legado.app.utils.getPrefBoolean +import io.legado.app.utils.getPrefString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher +import java.util.concurrent.Executors + +class SearchViewModel(application: Application) : BaseViewModel(application) { + private var searchPool = Executors.newFixedThreadPool(16).asCoroutineDispatcher() + private var task: Coroutine<*>? = null + var searchKey: String = "" + var startTime: Long = 0 + var searchPage = 0 + + fun search( + key: String, + start: (() -> Unit)? = null, + finally: (() -> Unit)? = null + ) { + task?.cancel() + if (key.isEmpty()) { + searchPage++ + } else { + searchKey = key + } + if (searchKey.isEmpty()) return + startTime = System.currentTimeMillis() + start?.invoke() + task = execute { + //onCleared时自动取消 + val searchGroup = context.getPrefString("searchGroup") ?: "" + val bookSourceList = if (searchGroup.isBlank()) { + App.db.bookSourceDao().allEnabled + } else { + App.db.bookSourceDao().getEnabledByGroup(searchGroup) + } + for (item in bookSourceList) { + //task取消时自动取消 by (scope = this@execute) + WebBook(item).searchBook( + searchKey, + searchPage, + scope = this@execute, + context = searchPool + ) + .timeout(30000L) + .onSuccess(Dispatchers.IO) { + it?.let { list -> + searchSuccess(list) + } + } + } + } + + task?.invokeOnCompletion { + finally?.invoke() + } + } + + private fun searchSuccess(searchBooks: List) { + searchBooks.forEach { searchBook -> + if (context.getPrefBoolean("precisionSearch")) { + if (searchBook.name.contains(searchKey) + || searchBook.author.contains(searchKey) + ) App.db.searchBookDao().insert(searchBook) + } else + App.db.searchBookDao().insert(searchBook) + } + } + + fun stop() { + task?.cancel() + } + + fun getSearchBook(name: String, author: String, success: ((searchBook: SearchBook?) -> Unit)?) { + execute { + val searchBook = App.db.searchBookDao().getFirstByNameAuthor(name, author) + success?.invoke(searchBook) + } + } + + fun saveSearchKey(key: String) { + execute { + App.db.searchKeywordDao().get(key)?.let { + it.usage = it.usage + 1 + App.db.searchKeywordDao().update(it) + } ?: App.db.searchKeywordDao().insert(SearchKeyword(key, 1)) + } + } + + fun clearHistory() { + execute { + App.db.searchKeywordDao().deleteAll() + } + } + + override fun onCleared() { + super.onCleared() + searchPool.close() + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugActivity.kt b/app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugActivity.kt new file mode 100644 index 000000000..dafeaa218 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugActivity.kt @@ -0,0 +1,105 @@ +package io.legado.app.ui.book.source.debug + +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.appcompat.widget.SearchView +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.lib.theme.ATH +import io.legado.app.lib.theme.accentColor +import io.legado.app.ui.qrcode.QrCodeActivity +import io.legado.app.utils.getViewModel +import kotlinx.android.synthetic.main.activity_source_debug.* +import kotlinx.android.synthetic.main.view_search.* +import kotlinx.coroutines.launch +import org.jetbrains.anko.startActivityForResult +import org.jetbrains.anko.toast + +class BookSourceDebugActivity : + VMBaseActivity(R.layout.activity_source_debug) { + + override val viewModel: BookSourceDebugModel + get() = getViewModel(BookSourceDebugModel::class.java) + + private lateinit var adapter: BookSourceDebugAdapter + private val qrRequestCode = 101 + + override fun onActivityCreated(savedInstanceState: Bundle?) { + viewModel.init(intent.getStringExtra("key")) + initRecyclerView() + initSearchView() + viewModel.observe{state, msg-> + launch { + adapter.addItem(msg) + if (state == -1 || state == 1000) { + rotate_loading.hide() + } + } + } + } + + private fun initRecyclerView() { + ATH.applyEdgeEffectColor(recycler_view) + adapter = BookSourceDebugAdapter(this) + recycler_view.layoutManager = LinearLayoutManager(this) + recycler_view.adapter = adapter + rotate_loading.loadingColor = accentColor + } + + private fun initSearchView() { + search_view.onActionViewExpanded() + search_view.isSubmitButtonEnabled = true + search_view.queryHint = getString(R.string.search_book_key) + search_view.clearFocus() + search_view.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + search_view.clearFocus() + startSearch(query ?: "我的") + return true + } + + override fun onQueryTextChange(newText: String?): Boolean { + return false + } + }) + } + + private fun startSearch(key: String) { + adapter.clearItems() + viewModel.startDebug(key, { + rotate_loading.show() + }, { + toast("未获取到书源") + }) + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.source_debug, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_scan -> { + startActivityForResult(qrRequestCode) + } + } + return super.onCompatOptionsItemSelected(item) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + when (requestCode) { + qrRequestCode -> { + if (resultCode == RESULT_OK) { + data?.getStringExtra("result")?.let { + startSearch(it) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugAdapter.kt b/app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugAdapter.kt new file mode 100644 index 000000000..2ec4e9518 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugAdapter.kt @@ -0,0 +1,16 @@ +package io.legado.app.ui.book.source.debug + +import android.content.Context +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.SimpleRecyclerAdapter +import kotlinx.android.synthetic.main.item_log.view.* + +class BookSourceDebugAdapter(context: Context) : + SimpleRecyclerAdapter(context, R.layout.item_log) { + override fun convert(holder: ItemViewHolder, item: String, payloads: MutableList) { + holder.itemView.apply { + text_view.text = item + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugModel.kt b/app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugModel.kt new file mode 100644 index 000000000..ced985aa9 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugModel.kt @@ -0,0 +1,46 @@ +package io.legado.app.ui.book.source.debug + +import android.app.Application +import io.legado.app.App +import io.legado.app.base.BaseViewModel +import io.legado.app.model.WebBook +import io.legado.app.model.webbook.SourceDebug + +class BookSourceDebugModel(application: Application) : BaseViewModel(application), + SourceDebug.Callback { + + private var webBook: WebBook? = null + + private var callback: ((Int, String)-> Unit)? = null + + fun init(sourceUrl: String?) { + sourceUrl?.let { + //优先使用这个,不会抛出异常 + execute { + val bookSource = App.db.bookSourceDao().getBookSource(sourceUrl) + bookSource?.let { webBook = WebBook(it) } + } + } + } + + fun observe(callback: (Int, String)-> Unit){ + this.callback = callback + } + + fun startDebug(key: String, start: (() -> Unit)? = null, error: (() -> Unit)? = null) { + webBook?.let { + start?.invoke() + SourceDebug(it, this).startDebug(key) + } ?: error?.invoke() + } + + override fun printLog(state: Int, msg: String) { + callback?.invoke(state, msg) + } + + override fun onCleared() { + super.onCleared() + SourceDebug.cancelDebug(true) + } + +} diff --git a/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt b/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt new file mode 100644 index 000000000..409b8701d --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt @@ -0,0 +1,369 @@ +package io.legado.app.ui.book.source.edit + +import android.app.Activity +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.graphics.Rect +import android.os.Bundle +import android.view.Gravity +import android.view.Menu +import android.view.MenuItem +import android.view.ViewTreeObserver +import android.widget.EditText +import android.widget.PopupWindow +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.tabs.TabLayout +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.constant.AppConst +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.EditEntity +import io.legado.app.data.entities.rule.* +import io.legado.app.lib.theme.ATH +import io.legado.app.ui.book.source.debug.BookSourceDebugActivity +import io.legado.app.ui.widget.KeyboardToolPop +import io.legado.app.utils.GSON +import io.legado.app.utils.getViewModel +import kotlinx.android.synthetic.main.activity_book_source_edit.* +import org.jetbrains.anko.displayMetrics +import org.jetbrains.anko.startActivity +import org.jetbrains.anko.toast +import kotlin.math.abs + +class BookSourceEditActivity : + VMBaseActivity(R.layout.activity_book_source_edit, false), + KeyboardToolPop.CallBack { + override val viewModel: BookSourceEditViewModel + get() = getViewModel(BookSourceEditViewModel::class.java) + + private val adapter = BookSourceEditAdapter() + private val sourceEntities: ArrayList = ArrayList() + private val searchEntities: ArrayList = ArrayList() + private val findEntities: ArrayList = ArrayList() + private val infoEntities: ArrayList = ArrayList() + private val tocEntities: ArrayList = ArrayList() + private val contentEntities: ArrayList = ArrayList() + + private var mSoftKeyboardTool: PopupWindow? = null + private var mIsSoftKeyBoardShowing = false + + override fun onActivityCreated(savedInstanceState: Bundle?) { + initView() + viewModel.sourceLiveData.observe(this, Observer { + upRecyclerView(it) + }) + viewModel.initData(intent) + } + + override fun onDestroy() { + super.onDestroy() + mSoftKeyboardTool?.dismiss() + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.source_edit, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_save -> { + getSource()?.let { + viewModel.save(it) { setResult(Activity.RESULT_OK); finish() } + } + } + R.id.menu_debug_source -> { + getSource()?.let { + viewModel.save(it) { + startActivity(Pair("key", it.bookSourceUrl)) + } + } + } + R.id.menu_copy_source -> { + GSON.toJson(getSource())?.let { sourceStr -> + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager? + clipboard?.primaryClip = ClipData.newPlainText(null, sourceStr) + } + } + R.id.menu_paste_source -> viewModel.pasteSource() + } + return super.onCompatOptionsItemSelected(item) + } + + private fun initView() { + ATH.applyEdgeEffectColor(recycler_view) + mSoftKeyboardTool = KeyboardToolPop(this, AppConst.keyboardToolChars, this) + window.decorView.viewTreeObserver.addOnGlobalLayoutListener(KeyboardOnGlobalChangeListener()) + recycler_view.layoutManager = LinearLayoutManager(this) + recycler_view.adapter = adapter + tab_layout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { + override fun onTabReselected(tab: TabLayout.Tab?) { + + } + + override fun onTabUnselected(tab: TabLayout.Tab?) { + + } + + override fun onTabSelected(tab: TabLayout.Tab?) { + setEditEntities(tab?.position) + } + }) + } + + private fun setEditEntities(tabPosition: Int?) { + when (tabPosition) { + 1 -> adapter.editEntities = searchEntities + 2 -> adapter.editEntities = findEntities + 3 -> adapter.editEntities = infoEntities + 4 -> adapter.editEntities = tocEntities + 5 -> adapter.editEntities = contentEntities + else -> adapter.editEntities = sourceEntities + } + recycler_view.scrollToPosition(0) + } + + private fun upRecyclerView(bookSource: BookSource?) { + bookSource?.let { + cb_is_enable.isChecked = it.enabled + cb_is_enable_find.isChecked = it.enabledExplore + } + //基本信息 + sourceEntities.clear() + sourceEntities.apply { + add(EditEntity("bookSourceUrl", bookSource?.bookSourceUrl, R.string.book_source_url)) + add(EditEntity("bookSourceName", bookSource?.bookSourceName, R.string.book_source_name)) + add( + EditEntity( + "bookSourceGroup", + bookSource?.bookSourceGroup, + R.string.book_source_group + ) + ) + add(EditEntity("loginUrl", bookSource?.loginUrl, R.string.book_source_login_url)) + add(EditEntity("bookUrlPattern", bookSource?.bookUrlPattern, R.string.book_url_pattern)) + add(EditEntity("header", bookSource?.header, R.string.source_http_header)) + } + //搜索 + (bookSource?.getSearchRule()).let { searchRule -> + searchEntities.clear() + searchEntities.apply { + add(EditEntity("searchUrl", bookSource?.searchUrl, R.string.rule_search_url)) + add(EditEntity("bookList", searchRule?.bookList, R.string.rule_book_list)) + add(EditEntity("name", searchRule?.name, R.string.rule_book_name)) + add(EditEntity("author", searchRule?.author, R.string.rule_book_author)) + add(EditEntity("kind", searchRule?.kind, R.string.rule_book_kind)) + add(EditEntity("wordCount", searchRule?.wordCount, R.string.rule_word_count)) + add(EditEntity("lastChapter", searchRule?.lastChapter, R.string.rule_last_chapter)) + add(EditEntity("intro", searchRule?.intro, R.string.rule_book_intro)) + add(EditEntity("coverUrl", searchRule?.coverUrl, R.string.rule_cover_url)) + add(EditEntity("bookUrl", searchRule?.bookUrl, R.string.rule_book_url)) + } + } + //详情页 + (bookSource?.getBookInfoRule()).let { infoRule -> + infoEntities.clear() + infoEntities.apply { + add(EditEntity("init", infoRule?.init, R.string.rule_book_info_init)) + add(EditEntity("name", infoRule?.name, R.string.rule_book_name)) + add(EditEntity("author", infoRule?.author, R.string.rule_book_author)) + add(EditEntity("kind", infoRule?.kind, R.string.rule_book_kind)) + add(EditEntity("wordCount", infoRule?.wordCount, R.string.rule_word_count)) + add(EditEntity("lastChapter", infoRule?.lastChapter, R.string.rule_last_chapter)) + add(EditEntity("intro", infoRule?.intro, R.string.rule_book_intro)) + add(EditEntity("coverUrl", infoRule?.coverUrl, R.string.rule_cover_url)) + add(EditEntity("tocUrl", infoRule?.tocUrl, R.string.rule_toc_url)) + } + } + //目录页 + (bookSource?.getTocRule()).let { tocRule -> + tocEntities.clear() + tocEntities.apply { + add(EditEntity("chapterList", tocRule?.chapterList, R.string.rule_chapter_list)) + add(EditEntity("chapterName", tocRule?.chapterName, R.string.rule_chapter_name)) + add(EditEntity("chapterUrl", tocRule?.chapterUrl, R.string.rule_chapter_url)) + add(EditEntity("nextTocUrl", tocRule?.nextTocUrl, R.string.rule_next_toc_url)) + } + } + //正文页 + (bookSource?.getContentRule()).let { contentRule -> + contentEntities.clear() + contentEntities.apply { + add(EditEntity("content", contentRule?.content, R.string.rule_book_content)) + add( + EditEntity( + "nextContentUrl", + contentRule?.nextContentUrl, + R.string.rule_content_url_next + ) + ) + } + } + + //发现 + (bookSource?.getExploreRule()).let { exploreRule -> + findEntities.clear() + findEntities.apply { + add(EditEntity("exploreUrl", bookSource?.exploreUrl, R.string.rule_find_url)) + add(EditEntity("bookList", exploreRule?.bookList, R.string.rule_book_list)) + add(EditEntity("name", exploreRule?.name, R.string.rule_book_name)) + add(EditEntity("author", exploreRule?.author, R.string.rule_book_author)) + add(EditEntity("kind", exploreRule?.kind, R.string.rule_book_kind)) + add(EditEntity("wordCount", exploreRule?.wordCount, R.string.rule_word_count)) + add(EditEntity("lastChapter", exploreRule?.lastChapter, R.string.rule_last_chapter)) + add(EditEntity("intro", exploreRule?.intro, R.string.rule_book_intro)) + add(EditEntity("coverUrl", exploreRule?.coverUrl, R.string.rule_cover_url)) + add(EditEntity("bookUrl", exploreRule?.bookUrl, R.string.rule_book_url)) + } + } + setEditEntities(0) + } + + private fun getSource(): BookSource? { + val source = viewModel.sourceLiveData.value ?: BookSource() + source.enabled = cb_is_enable.isChecked + source.enabledExplore = cb_is_enable_find.isChecked + viewModel.sourceLiveData.value?.let { + source.customOrder = it.customOrder + source.weight = it.weight + } + val searchRule = SearchRule() + val exploreRule = ExploreRule() + val bookInfoRule = BookInfoRule() + val tocRule = TocRule() + val contentRule = ContentRule() + sourceEntities.forEach { + when (it.key) { + "bookSourceUrl" -> source.bookSourceUrl = it.value ?: "" + "bookSourceName" -> source.bookSourceName = it.value ?: "" + "bookSourceGroup" -> source.bookSourceGroup = it.value + "loginUrl" -> source.loginUrl = it.value + "bookUrlPattern" -> source.bookUrlPattern = it.value + "header" -> source.header = it.value + } + } + if (source.bookSourceUrl.isBlank() || source.bookSourceName.isBlank()) { + toast("书源名称和URL不能为空") + return null + } + searchEntities.forEach { + when (it.key) { + "searchUrl" -> source.searchUrl = it.value + "bookList" -> searchRule.bookList = it.value + "name" -> searchRule.name = it.value + "author" -> searchRule.author = it.value + "kind" -> searchRule.kind = it.value + "intro" -> searchRule.intro = it.value + "updateTime" -> searchRule.updateTime = it.value + "wordCount" -> searchRule.wordCount = it.value + "lastChapter" -> searchRule.lastChapter = it.value + "coverUrl" -> searchRule.coverUrl = it.value + "bookUrl" -> searchRule.bookUrl = it.value + } + } + findEntities.forEach { + when (it.key) { + "exploreUrl" -> source.exploreUrl = it.value + "bookList" -> exploreRule.bookList = it.value + "name" -> exploreRule.name = it.value + "author" -> exploreRule.author = it.value + "kind" -> exploreRule.kind = it.value + "intro" -> exploreRule.intro = it.value + "updateTime" -> exploreRule.updateTime = it.value + "wordCount" -> exploreRule.wordCount = it.value + "lastChapter" -> exploreRule.lastChapter = it.value + "coverUrl" -> exploreRule.coverUrl = it.value + "bookUrl" -> exploreRule.bookUrl = it.value + } + } + infoEntities.forEach { + when (it.key) { + "init" -> bookInfoRule.init = it.value + "name" -> bookInfoRule.name = it.value + "author" -> bookInfoRule.author = it.value + "kind" -> bookInfoRule.kind = it.value + "intro" -> bookInfoRule.intro = it.value + "updateTime" -> bookInfoRule.updateTime = it.value + "wordCount" -> bookInfoRule.wordCount = it.value + "lastChapter" -> bookInfoRule.lastChapter = it.value + "coverUrl" -> bookInfoRule.coverUrl = it.value + "tocUrl" -> bookInfoRule.tocUrl = it.value + } + } + tocEntities.forEach { + when (it.key) { + "chapterList" -> tocRule.chapterList = it.value + "chapterName" -> tocRule.chapterName = it.value + "chapterUrl" -> tocRule.chapterUrl = it.value + "nextTocUrl" -> tocRule.nextTocUrl = it.value + } + } + contentEntities.forEach { + when (it.key) { + "content" -> contentRule.content = it.value + "nextContentUrl" -> contentRule.nextContentUrl = it.value + } + } + source.ruleSearch = GSON.toJson(searchRule) + source.ruleExplore = GSON.toJson(exploreRule) + source.ruleBookInfo = GSON.toJson(bookInfoRule) + source.ruleToc = GSON.toJson(tocRule) + source.ruleContent = GSON.toJson(contentRule) + return source + } + + override fun sendText(text: String) { + if (text.isBlank()) return + val view = window.decorView.findFocus() + if (view is EditText) { + val start = view.selectionStart + val end = view.selectionEnd + val edit = view.editableText//获取EditText的文字 + if (start < 0 || start >= edit.length) { + edit.append(text) + } else { + edit.replace(start, end, text)//光标所在位置插入文字 + } + } + } + + private fun showKeyboardTopPopupWindow() { + mSoftKeyboardTool?.isShowing?.let { if (it) return } + if (!isFinishing) { + mSoftKeyboardTool?.showAtLocation(ll_content, Gravity.BOTTOM, 0, 0) + } + } + + private fun closePopupWindow() { + mSoftKeyboardTool?.let { + if (it.isShowing) { + it.dismiss() + } + } + } + + private inner class KeyboardOnGlobalChangeListener : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + val rect = Rect() + // 获取当前页面窗口的显示范围 + window.decorView.getWindowVisibleDisplayFrame(rect) + val screenHeight = this@BookSourceEditActivity.displayMetrics.heightPixels + val keyboardHeight = screenHeight - rect.bottom // 输入法的高度 + val preShowing = mIsSoftKeyBoardShowing + if (abs(keyboardHeight) > screenHeight / 5) { + mIsSoftKeyBoardShowing = true // 超过屏幕五分之一则表示弹出了输入法 + recycler_view.setPadding(0, 0, 0, 100) + showKeyboardTopPopupWindow() + } else { + mIsSoftKeyBoardShowing = false + recycler_view.setPadding(0, 0, 0, 0) + if (preShowing) { + closePopupWindow() + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditAdapter.kt b/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditAdapter.kt new file mode 100644 index 000000000..37939e803 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditAdapter.kt @@ -0,0 +1,81 @@ +package io.legado.app.ui.book.source.edit + +import android.text.Editable +import android.text.TextWatcher +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.R +import io.legado.app.data.entities.EditEntity +import kotlinx.android.synthetic.main.item_source_edit.view.* + +class BookSourceEditAdapter : RecyclerView.Adapter() { + + var editEntities: ArrayList = ArrayList() + set(value) { + field = value + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder { + return MyViewHolder( + LayoutInflater.from( + parent.context + ).inflate(R.layout.item_source_edit, parent, false) + ) + } + + override fun onBindViewHolder(holder: MyViewHolder, position: Int) { + holder.bind(editEntities[position]) + } + + override fun getItemCount(): Int { + return editEntities.size + } + + class MyViewHolder(view: View) : RecyclerView.ViewHolder(view) { + fun bind(editEntity: EditEntity) = with(itemView) { + if (editText.getTag(R.id.tag1) == null) { + val listener = object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + editText.isCursorVisible = false + editText.isCursorVisible = true + editText.isFocusable = true + editText.isFocusableInTouchMode = true + } + + override fun onViewDetachedFromWindow(v: View) { + + } + } + editText.addOnAttachStateChangeListener(listener) + editText.setTag(R.id.tag1, listener) + } + editText.getTag(R.id.tag2)?.let { + if (it is TextWatcher) { + editText.removeTextChangedListener(it) + } + } + editText.setText(editEntity.value) + textInputLayout.hint = context.getString(editEntity.hint) + val textWatcher = object : TextWatcher { + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { + + } + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + + } + + override fun afterTextChanged(s: Editable?) { + editEntity.value = (s?.toString()) + } + } + editText.addTextChangedListener(textWatcher) + editText.setTag(R.id.tag2, textWatcher) + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditViewModel.kt b/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditViewModel.kt new file mode 100644 index 000000000..d50601117 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditViewModel.kt @@ -0,0 +1,66 @@ +package io.legado.app.ui.book.source.edit + +import android.app.Application +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import androidx.lifecycle.MutableLiveData +import io.legado.app.App +import io.legado.app.base.BaseViewModel +import io.legado.app.data.entities.BookSource +import io.legado.app.help.storage.OldRule + +class BookSourceEditViewModel(application: Application) : BaseViewModel(application) { + + val sourceLiveData: MutableLiveData = MutableLiveData() + private var oldSourceUrl: String? = null + + fun initData(intent: Intent) { + execute { + val key = intent.getStringExtra("data") + var source: BookSource? = null + if (key != null) { + source = App.db.bookSourceDao().getBookSource(key) + } + source?.let { + oldSourceUrl = it.bookSourceUrl + sourceLiveData.postValue(it) + } ?: let { + sourceLiveData.postValue(BookSource().apply { + customOrder = App.db.bookSourceDao().maxOrder + 1 + }) + } + } + } + + fun save(bookSource: BookSource, success: (() -> Unit)? = null) { + execute { + oldSourceUrl?.let { + if (oldSourceUrl != bookSource.bookSourceUrl) { + App.db.bookSourceDao().delete(it) + } + } + oldSourceUrl = bookSource.bookSourceUrl + App.db.bookSourceDao().insert(bookSource) + }.onSuccess { + success?.invoke() + }.onError { + toast(it.localizedMessage) + it.printStackTrace() + } + } + + fun pasteSource() { + execute { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager? + clipboard?.primaryClip?.let { + if (it.itemCount > 0) { + val json = it.getItemAt(0).text.toString() + OldRule.jsonToBookSource(json)?.let { source -> + sourceLiveData.postValue(source) + } ?: toast("格式不对") + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceActivity.kt b/app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceActivity.kt new file mode 100644 index 000000000..ec1359b63 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceActivity.kt @@ -0,0 +1,267 @@ +package io.legado.app.ui.book.source.manage + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.SubMenu +import androidx.appcompat.widget.SearchView +import androidx.core.content.ContextCompat +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.App +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.data.entities.BookSource +import io.legado.app.help.ItemTouchCallback +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.dialogs.cancelButton +import io.legado.app.lib.dialogs.customView +import io.legado.app.lib.dialogs.okButton +import io.legado.app.lib.theme.ATH +import io.legado.app.lib.theme.primaryTextColor +import io.legado.app.lib.theme.view.ATEAutoCompleteTextView +import io.legado.app.service.CheckSourceService +import io.legado.app.ui.book.source.edit.BookSourceEditActivity +import io.legado.app.ui.qrcode.QrCodeActivity +import io.legado.app.utils.* +import kotlinx.android.synthetic.main.activity_book_source.* +import kotlinx.android.synthetic.main.dialog_edit_text.view.* +import kotlinx.android.synthetic.main.view_search.* +import kotlinx.android.synthetic.main.view_title_bar.* +import org.jetbrains.anko.startActivity +import org.jetbrains.anko.startActivityForResult +import org.jetbrains.anko.startService +import org.jetbrains.anko.toast + +class BookSourceActivity : VMBaseActivity(R.layout.activity_book_source), + BookSourceAdapter.CallBack, + SearchView.OnQueryTextListener { + override val viewModel: BookSourceViewModel + get() = getViewModel(BookSourceViewModel::class.java) + + private val qrRequestCode = 101 + private val importSource = 13141 + private lateinit var adapter: BookSourceAdapter + private var bookSourceLiveDate: LiveData>? = null + private var groups = hashSetOf() + private var groupMenu: SubMenu? = null + + override fun onActivityCreated(savedInstanceState: Bundle?) { + setSupportActionBar(toolbar) + initUriScheme() + initRecyclerView() + initSearchView() + initLiveDataBookSource() + initLiveDataGroup() + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.book_source, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onPrepareOptionsMenu(menu: Menu?): Boolean { + groupMenu = menu?.findItem(R.id.menu_group)?.subMenu + upGroupMenu() + return super.onPrepareOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_add_book_source -> startActivity() + R.id.menu_import_source_qr -> startActivityForResult(qrRequestCode) + R.id.menu_group_manage -> GroupManageDialog().show( + supportFragmentManager, + "groupManage" + ) + R.id.menu_import_source_local -> selectFile() + R.id.menu_select_all -> adapter.selectAll() + R.id.menu_revert_selection -> adapter.revertSelection() + R.id.menu_enable_selection -> viewModel.enableSelection(adapter.getSelectionIds()) + R.id.menu_disable_selection -> viewModel.disableSelection(adapter.getSelectionIds()) + R.id.menu_del_selection -> viewModel.delSelection(adapter.getSelectionIds()) + R.id.menu_check_source -> + startService(Pair("data", adapter.getSelectionIds())) + R.id.menu_import_source_onLine -> showImportDialog() + } + if (item.groupId == R.id.source_group) { + search_view.setQuery(item.title, true) + } + return super.onCompatOptionsItemSelected(item) + } + + private fun initUriScheme() { + intent.data?.let{ + when(it.path) + { + "/importonline" -> { + it.getQueryParameter("src")?.let{ + viewModel.importSource(it) + } + } + else -> {toast("格式不对")} + } + } + } + + private fun initRecyclerView() { + ATH.applyEdgeEffectColor(recycler_view) + recycler_view.layoutManager = LinearLayoutManager(this) + recycler_view.addItemDecoration( + DividerItemDecoration(this, DividerItemDecoration.VERTICAL).apply { + ContextCompat.getDrawable(baseContext, R.drawable.ic_divider)?.let { + this.setDrawable(it) + } + }) + adapter = BookSourceAdapter(this, this) + recycler_view.adapter = adapter + val itemTouchCallback = ItemTouchCallback() + itemTouchCallback.onItemTouchCallbackListener = adapter + itemTouchCallback.isCanDrag = true + ItemTouchHelper(itemTouchCallback).attachToRecyclerView(recycler_view) + } + + private fun initSearchView() { + ATH.setTint(search_view, primaryTextColor) + search_view.onActionViewExpanded() + search_view.queryHint = getString(R.string.search_book_source) + search_view.clearFocus() + search_view.setOnQueryTextListener(this) + } + + private fun initLiveDataBookSource(searchKey: String? = null) { + bookSourceLiveDate?.removeObservers(this) + bookSourceLiveDate = if (searchKey.isNullOrEmpty()) { + App.db.bookSourceDao().liveDataAll() + } else { + App.db.bookSourceDao().liveDataSearch("%$searchKey%") + } + bookSourceLiveDate?.observe(this, Observer { + search_view.queryHint = getString(R.string.search_book_source_num, it.size) + val diffResult = DiffUtil + .calculateDiff(DiffCallBack(adapter.getItems(), it)) + adapter.setItems(it, false) + diffResult.dispatchUpdatesTo(adapter) + }) + } + + private fun initLiveDataGroup() { + App.db.bookSourceDao().liveGroup().observe(this, Observer { + groups.clear() + it.map { group -> + groups.addAll(group.splitNotBlank(",", ";")) + } + upGroupMenu() + }) + } + + private fun upGroupMenu() { + groupMenu?.removeGroup(R.id.source_group) + groups.map { + groupMenu?.add(R.id.source_group, Menu.NONE, Menu.NONE, it) + } + } + + @SuppressLint("InflateParams") + private fun showImportDialog() { + val aCache = ACache.get(this, cacheDir = false) + val cacheUrls: MutableList = aCache + .getAsString("sourceUrl") + ?.splitNotBlank(",") + ?.toMutableList() ?: mutableListOf() + alert(titleResource = R.string.import_book_source_on_line) { + var editText: ATEAutoCompleteTextView? = null + customView { + layoutInflater.inflate(R.layout.dialog_edit_text, null).apply { + editText = edit_view + edit_view.setFilterValues(cacheUrls) { + cacheUrls.remove(it) + aCache.put("sourceUrl", cacheUrls.joinToString(",")) + } + } + } + okButton { + val text = editText?.text?.toString() + text?.let { + if (!cacheUrls.contains(it)) { + cacheUrls.add(0, it) + aCache.put("sourceUrl", cacheUrls.joinToString(",")) + } + viewModel.importSource(it) + } + } + cancelButton() + }.show().applyTint() + } + + private fun selectFile() { + val intent = Intent(Intent.ACTION_GET_CONTENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = "text/*"//设置类型 + startActivityForResult(intent, importSource) + } + + override fun onQueryTextChange(newText: String?): Boolean { + newText?.let { + initLiveDataBookSource(it) + } + return false + } + + override fun onQueryTextSubmit(query: String?): Boolean { + return false + } + + override fun del(bookSource: BookSource) { + viewModel.del(bookSource) + } + + override fun update(vararg bookSource: BookSource) { + viewModel.update(*bookSource) + } + + override fun edit(bookSource: BookSource) { + startActivity(Pair("data", bookSource.bookSourceUrl)) + } + + override fun upOrder() { + viewModel.upOrder() + } + + override fun toTop(bookSource: BookSource) { + viewModel.topSource(bookSource) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + when (requestCode) { + qrRequestCode -> if (resultCode == RESULT_OK) { + data?.getStringExtra("result")?.let { + viewModel.importSource(it) + } + } + importSource -> if (resultCode == Activity.RESULT_OK) { + data?.data?.let { + FileUtils.getPath(this, it)?.let { path -> + viewModel.importSourceFromFilePath(path) + } + } + } + } + } + + override fun finish() { + if (search_view.query.isNullOrEmpty()) { + super.finish() + } else { + search_view.setQuery("", true) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceAdapter.kt b/app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceAdapter.kt new file mode 100644 index 000000000..c7c432e16 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceAdapter.kt @@ -0,0 +1,122 @@ +package io.legado.app.ui.book.source.manage + +import android.content.Context +import android.view.Menu +import android.widget.PopupMenu +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.SimpleRecyclerAdapter +import io.legado.app.data.entities.BookSource +import io.legado.app.help.ItemTouchCallback.OnItemTouchCallbackListener +import io.legado.app.lib.theme.backgroundColor +import kotlinx.android.synthetic.main.item_book_source.view.* +import org.jetbrains.anko.sdk27.listeners.onClick + +class BookSourceAdapter(context: Context, val callBack: CallBack) : + SimpleRecyclerAdapter(context, R.layout.item_book_source), + OnItemTouchCallbackListener { + + private val selectedIds = linkedSetOf() + + fun selectAll() { + getItems().forEach { + selectedIds.add(it.bookSourceUrl) + } + notifyItemRangeChanged(0, itemCount, 1) + } + + fun revertSelection() { + getItems().forEach { + if (selectedIds.contains(it.bookSourceUrl)) { + selectedIds.remove(it.bookSourceUrl) + } else { + selectedIds.add(it.bookSourceUrl) + } + } + notifyItemRangeChanged(0, itemCount, 1) + } + + fun getSelectionIds(): LinkedHashSet { + val selection = linkedSetOf() + getItems().map { + if (selectedIds.contains(it.bookSourceUrl)) { + selection.add(it.bookSourceUrl) + } + } + return selection + } + + override fun onSwiped(adapterPosition: Int) { + + } + + override fun onMove(srcPosition: Int, targetPosition: Int): Boolean { + val srcItem = getItem(srcPosition) + val targetItem = getItem(targetPosition) + if (srcItem != null && targetItem != null) { + if (srcItem.customOrder == targetItem.customOrder) { + callBack.upOrder() + } else { + val srcOrder = srcItem.customOrder + srcItem.customOrder = targetItem.customOrder + targetItem.customOrder = srcOrder + callBack.update(srcItem, targetItem) + } + } + return true + } + + override fun convert(holder: ItemViewHolder, item: BookSource, payloads: MutableList) { + with(holder.itemView) { + if (payloads.isEmpty()) { + this.setBackgroundColor(context.backgroundColor) + if (item.bookSourceGroup.isNullOrEmpty()) { + cb_book_source.text = item.bookSourceName + } else { + cb_book_source.text = + String.format("%s (%s)", item.bookSourceName, item.bookSourceGroup) + } + swt_enabled.isChecked = item.enabled + swt_enabled.onClick { + item.enabled = swt_enabled.isChecked + callBack.update(item) + } + cb_book_source.isChecked = selectedIds.contains(item.bookSourceUrl) + cb_book_source.setOnClickListener { + if (cb_book_source.isChecked) { + selectedIds.add(item.bookSourceUrl) + } else { + selectedIds.remove(item.bookSourceUrl) + } + } + iv_edit.onClick { callBack.edit(item) } + iv_menu_more.onClick { + val popupMenu = PopupMenu(context, it) + popupMenu.menu.add(Menu.NONE, R.id.menu_top, Menu.NONE, R.string.to_top) + popupMenu.menu.add(Menu.NONE, R.id.menu_del, Menu.NONE, R.string.delete) + popupMenu.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.menu_top -> callBack.toTop(item) + R.id.menu_del -> callBack.del(item) + } + true + } + popupMenu.show() + } + } else { + when (payloads[0]) { + 1 -> cb_book_source.isChecked = selectedIds.contains(item.bookSourceUrl) + 2 -> swt_enabled.isChecked = item.enabled + } + } + } + } + + interface CallBack { + fun del(bookSource: BookSource) + fun edit(bookSource: BookSource) + fun update(vararg bookSource: BookSource) + fun toTop(bookSource: BookSource) + fun upOrder() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceViewModel.kt b/app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceViewModel.kt new file mode 100644 index 000000000..7a3b5bb91 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceViewModel.kt @@ -0,0 +1,157 @@ +package io.legado.app.ui.book.source.manage + +import android.app.Application +import android.text.TextUtils +import com.jayway.jsonpath.JsonPath +import io.legado.app.App +import io.legado.app.base.BaseViewModel +import io.legado.app.data.api.IHttpGetApi +import io.legado.app.data.entities.BookSource +import io.legado.app.help.http.HttpHelper +import io.legado.app.help.storage.OldRule +import io.legado.app.help.storage.Restore.jsonPath +import io.legado.app.utils.* +import java.io.File + +class BookSourceViewModel(application: Application) : BaseViewModel(application) { + + fun topSource(bookSource: BookSource) { + execute { + bookSource.customOrder = App.db.bookSourceDao().minOrder - 1 + App.db.bookSourceDao().insert(bookSource) + } + } + + fun del(bookSource: BookSource) { + execute { App.db.bookSourceDao().delete(bookSource) } + } + + fun update(vararg bookSource: BookSource) { + execute { App.db.bookSourceDao().update(*bookSource) } + } + + fun upOrder() { + execute { + val sources = App.db.bookSourceDao().all + for ((index: Int, source: BookSource) in sources.withIndex()) { + source.customOrder = index + 1 + } + App.db.bookSourceDao().update(*sources.toTypedArray()) + } + } + + fun enableSelection(ids: LinkedHashSet) { + execute { + App.db.bookSourceDao().enableSection(*ids.toTypedArray()) + } + } + + fun disableSelection(ids: LinkedHashSet) { + execute { + App.db.bookSourceDao().disableSection(*ids.toTypedArray()) + } + } + + fun delSelection(ids: LinkedHashSet) { + execute { + App.db.bookSourceDao().delSection(*ids.toTypedArray()) + } + } + + fun addGroup(group: String) { + execute { + val sources = App.db.bookSourceDao().noGroup + sources.map { source -> + source.bookSourceGroup = group + } + App.db.bookSourceDao().update(*sources.toTypedArray()) + } + } + + fun upGroup(oldGroup: String, newGroup: String?) { + execute { + val sources = App.db.bookSourceDao().getByGroup(oldGroup) + sources.map { source -> + source.bookSourceGroup?.splitNotBlank(",")?.toHashSet()?.let { + it.remove(oldGroup) + if (!newGroup.isNullOrEmpty()) + it.add(newGroup) + source.bookSourceGroup = TextUtils.join(",", it) + } + } + App.db.bookSourceDao().update(*sources.toTypedArray()) + } + } + + fun delGroup(group: String) { + execute { + execute { + val sources = App.db.bookSourceDao().getByGroup(group) + sources.map { source -> + source.bookSourceGroup?.splitNotBlank(",")?.toHashSet()?.let { + it.remove(group) + source.bookSourceGroup = TextUtils.join(",", it) + } + } + App.db.bookSourceDao().update(*sources.toTypedArray()) + } + } + } + + fun importSourceFromFilePath(path: String) { + execute { + val file = File(path) + if (file.exists()) { + importSource(file.readText()) + } + } + } + + fun importSource(text: String) { + execute { + val text1 = text.trim() + if (text1.isJsonObject()) { + val json = JsonPath.parse(text1) + val urls = json.read>("$.sourceUrls") + if (!urls.isNullOrEmpty()) { + urls.forEach { importSourceUrl(it) } + } else { + OldRule.jsonToBookSource(text1)?.let { + App.db.bookSourceDao().insert(it) + } + toast("成功导入1条") + } + } else if (text1.isJsonArray()) { + val bookSources = mutableListOf() + val items: List> = jsonPath.parse(text1).read("$") + for (item in items) { + val jsonItem = jsonPath.parse(item) + OldRule.jsonToBookSource(jsonItem.jsonString())?.let { + bookSources.add(it) + } + } + App.db.bookSourceDao().insert(*bookSources.toTypedArray()) + toast("成功导入${bookSources.size}条") + } else if (text1.isAbsUrl()) { + importSourceUrl(text1) + } else { + toast("格式不对") + } + }.onError { + toast(it.localizedMessage) + } + } + + private fun importSourceUrl(url: String) { + execute { + NetworkUtils.getBaseUrl(url)?.let { + val response = HttpHelper.getApiService(it).get(url, mapOf()).execute() + response.body()?.let { body -> + importSource(body) + } + } + }.onError { + toast(it.localizedMessage) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/source/manage/DiffCallBack.kt b/app/src/main/java/io/legado/app/ui/book/source/manage/DiffCallBack.kt new file mode 100644 index 000000000..0e8d9bead --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/source/manage/DiffCallBack.kt @@ -0,0 +1,43 @@ +package io.legado.app.ui.book.source.manage + +import androidx.recyclerview.widget.DiffUtil +import io.legado.app.data.entities.BookSource + +class DiffCallBack( + private val oldItems: List, + private val newItems: List +) : DiffUtil.Callback() { + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldItems[oldItemPosition] + val newItem = newItems[newItemPosition] + return oldItem.bookSourceUrl == newItem.bookSourceUrl + } + + override fun getOldListSize(): Int { + return oldItems.size + } + + override fun getNewListSize(): Int { + return newItems.size + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldItems[oldItemPosition] + val newItem = newItems[newItemPosition] + return oldItem.bookSourceName == newItem.bookSourceName + && oldItem.bookSourceGroup == newItem.bookSourceGroup + && oldItem.enabled == newItem.enabled + } + + override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? { + val oldItem = oldItems[oldItemPosition] + val newItem = newItems[newItemPosition] + return when { + oldItem.bookSourceName == newItem.bookSourceName + && oldItem.bookSourceGroup == newItem.bookSourceGroup + && oldItem.enabled != newItem.enabled -> 2 + else -> null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/source/manage/GroupManageDialog.kt b/app/src/main/java/io/legado/app/ui/book/source/manage/GroupManageDialog.kt new file mode 100644 index 000000000..59f5e73af --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/source/manage/GroupManageDialog.kt @@ -0,0 +1,140 @@ +package io.legado.app.ui.book.source.manage + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Bundle +import android.util.DisplayMetrics +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.App +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.SimpleRecyclerAdapter +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.dialogs.customView +import io.legado.app.lib.dialogs.noButton +import io.legado.app.lib.dialogs.yesButton +import io.legado.app.utils.applyTint +import io.legado.app.utils.getViewModelOfActivity +import io.legado.app.utils.requestInputMethod +import io.legado.app.utils.splitNotBlank +import kotlinx.android.synthetic.main.dialog_edit_text.view.* +import kotlinx.android.synthetic.main.dialog_recycler_view.* +import kotlinx.android.synthetic.main.item_group_manage.view.* +import org.jetbrains.anko.sdk27.listeners.onClick + +class GroupManageDialog : DialogFragment(), Toolbar.OnMenuItemClickListener { + private lateinit var viewModel: BookSourceViewModel + private lateinit var adapter: GroupAdapter + + override fun onStart() { + super.onStart() + val dm = DisplayMetrics() + activity?.windowManager?.defaultDisplay?.getMetrics(dm) + dialog?.window?.setLayout((dm.widthPixels * 0.9).toInt(), (dm.heightPixels * 0.9).toInt()) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + viewModel = getViewModelOfActivity(BookSourceViewModel::class.java) + return inflater.inflate(R.layout.dialog_recycler_view, container) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initData() + } + + private fun initData() { + tool_bar.title = getString(R.string.group_manage) + tool_bar.inflateMenu(R.menu.group_manage) + tool_bar.menu.applyTint(requireContext(), false) + tool_bar.setOnMenuItemClickListener(this) + adapter = GroupAdapter(requireContext()) + recycler_view.layoutManager = LinearLayoutManager(requireContext()) + recycler_view.addItemDecoration( + DividerItemDecoration(requireContext(), RecyclerView.VERTICAL) + ) + recycler_view.adapter = adapter + App.db.bookSourceDao().liveGroup().observe(viewLifecycleOwner, Observer { + val groups = linkedSetOf() + it.map { group -> + groups.addAll(group.splitNotBlank(",", ";")) + } + adapter.setItems(groups.toList()) + }) + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.menu_add -> addGroup() + } + return true + } + + @SuppressLint("InflateParams") + private fun addGroup() { + alert(title = getString(R.string.add_group)) { + var editText: EditText? = null + customView { + layoutInflater.inflate(R.layout.dialog_edit_text, null).apply { + editText = edit_view.apply { + hint = "分组名称" + } + } + } + yesButton { + editText?.text?.toString()?.let { + if (it.isNotBlank()) { + viewModel.addGroup(it) + } + } + } + noButton() + }.show().applyTint().requestInputMethod() + } + + @SuppressLint("InflateParams") + private fun editGroup(group: String) { + alert(title = getString(R.string.group_edit)) { + var editText: EditText? = null + customView { + layoutInflater.inflate(R.layout.dialog_edit_text, null).apply { + editText = edit_view.apply { + hint = "分组名称" + setText(group) + } + } + } + yesButton { + viewModel.upGroup(group, editText?.text?.toString()) + } + noButton() + }.show().applyTint().requestInputMethod() + } + + private inner class GroupAdapter(context: Context) : + SimpleRecyclerAdapter(context, R.layout.item_group_manage) { + + override fun convert(holder: ItemViewHolder, item: String, payloads: MutableList) { + with(holder.itemView) { + tv_group.text = item + tv_edit.onClick { editGroup(item) } + tv_del.onClick { viewModel.delGroup(item) } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/changesource/ChangeSourceAdapter.kt b/app/src/main/java/io/legado/app/ui/changesource/ChangeSourceAdapter.kt new file mode 100644 index 000000000..763574810 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/changesource/ChangeSourceAdapter.kt @@ -0,0 +1,39 @@ +package io.legado.app.ui.changesource + +import android.content.Context +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.SimpleRecyclerAdapter +import io.legado.app.data.entities.SearchBook +import io.legado.app.utils.invisible +import io.legado.app.utils.visible +import kotlinx.android.synthetic.main.item_change_source.view.* +import org.jetbrains.anko.sdk27.listeners.onClick + + +class ChangeSourceAdapter(context: Context, val callBack: CallBack) : + SimpleRecyclerAdapter(context, R.layout.item_change_source) { + + override fun convert(holder: ItemViewHolder, item: SearchBook, payloads: MutableList) { + holder.itemView.apply { + if (payloads.isEmpty()) { + this.onClick { callBack.changeTo(item) } + tv_origin.text = item.originName + tv_last.text = item.latestChapterTitle + if (callBack.curOrigin() == item.origin) { + iv_checked.visible() + } else { + iv_checked.invisible() + } + } else { + tv_origin.text = item.originName + tv_last.text = item.latestChapterTitle + } + } + } + + interface CallBack { + fun changeTo(searchBook: SearchBook) + fun curOrigin(): String + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/changesource/ChangeSourceDialog.kt b/app/src/main/java/io/legado/app/ui/changesource/ChangeSourceDialog.kt new file mode 100644 index 000000000..674db46c1 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/changesource/ChangeSourceDialog.kt @@ -0,0 +1,149 @@ +package io.legado.app.ui.changesource + +import android.os.Bundle +import android.util.DisplayMetrics +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.appcompat.widget.SearchView +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.R +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.SearchBook +import io.legado.app.utils.getViewModel +import kotlinx.android.synthetic.main.dialog_change_source.* + + +class ChangeSourceDialog : DialogFragment(), + ChangeSourceViewModel.CallBack, + ChangeSourceAdapter.CallBack { + + companion object { + const val tag = "changeSourceDialog" + + fun newInstance(name: String, author: String): ChangeSourceDialog { + return ChangeSourceDialog().apply { + val bundle = Bundle() + bundle.putString("name", name) + bundle.putString("author", author) + arguments = bundle + } + } + } + + private var callBack: CallBack? = null + private lateinit var viewModel: ChangeSourceViewModel + private lateinit var changeSourceAdapter: ChangeSourceAdapter + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + viewModel = getViewModel(ChangeSourceViewModel::class.java) + return inflater.inflate(R.layout.dialog_change_source, container) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + callBack = activity as? CallBack + viewModel.searchStateData.observe(viewLifecycleOwner, Observer { + refresh_progress_bar.isAutoLoading = it + }) + arguments?.let { bundle -> + bundle.getString("name")?.let { + viewModel.name = it + } + bundle.getString("author")?.let { + viewModel.author = it + } + } + tool_bar.inflateMenu(R.menu.search_view) + showTitle() + initRecyclerView() + initSearchView() + viewModel.initData() + viewModel.search() + } + + override fun onStart() { + super.onStart() + val dm = DisplayMetrics() + activity?.windowManager?.defaultDisplay?.getMetrics(dm) + dialog?.window?.setLayout((dm.widthPixels * 0.9).toInt(), (dm.heightPixels * 0.9).toInt()) + } + + private fun showTitle() { + tool_bar.title = viewModel.name + tool_bar.subtitle = getString(R.string.author_show, viewModel.author) + } + + private fun initRecyclerView() { + changeSourceAdapter = ChangeSourceAdapter(requireContext(), this) + recycler_view.layoutManager = LinearLayoutManager(context) + recycler_view.addItemDecoration( + DividerItemDecoration(requireContext(), LinearLayout.VERTICAL) + ) + recycler_view.adapter = changeSourceAdapter + viewModel.callBack = this + } + + private fun initSearchView() { + val searchView = tool_bar.menu.findItem(R.id.menu_search).actionView as SearchView + searchView.setOnCloseListener { + showTitle() + false + } + searchView.setOnSearchClickListener { + tool_bar.title = "" + tool_bar.subtitle = "" + } + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + return false + } + + override fun onQueryTextChange(newText: String?): Boolean { + viewModel.screen(newText) + return false + } + + }) + } + + override fun changeTo(searchBook: SearchBook) { + val book = searchBook.toBook() + callBack?.oldBook?.let { oldBook -> + book.durChapterIndex = oldBook.durChapterIndex + book.durChapterPos = oldBook.durChapterPos + book.durChapterTitle = oldBook.durChapterTitle + book.customCoverUrl = oldBook.customCoverUrl + book.customIntro = oldBook.customIntro + book.order = oldBook.order + if (book.coverUrl.isNullOrEmpty()) { + book.coverUrl = oldBook.getDisplayCover() + } + callBack?.changeTo(book) + } + dismiss() + } + + override fun curOrigin(): String { + return callBack?.curOrigin ?: "" + } + + override fun adapter(): ChangeSourceAdapter { + return changeSourceAdapter + } + + interface CallBack { + val curOrigin: String? + val oldBook: Book? + fun changeTo(book: Book) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/changesource/ChangeSourceViewModel.kt b/app/src/main/java/io/legado/app/ui/changesource/ChangeSourceViewModel.kt new file mode 100644 index 000000000..2a81ae398 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/changesource/ChangeSourceViewModel.kt @@ -0,0 +1,139 @@ +package io.legado.app.ui.changesource + +import android.app.Application +import androidx.lifecycle.MutableLiveData +import androidx.recyclerview.widget.DiffUtil +import io.legado.app.App +import io.legado.app.R +import io.legado.app.base.BaseViewModel +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.SearchBook +import io.legado.app.help.coroutine.Coroutine +import io.legado.app.model.WebBook +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.withContext +import org.jetbrains.anko.debug +import java.util.concurrent.Executors + +class ChangeSourceViewModel(application: Application) : BaseViewModel(application) { + private var searchPool = Executors.newFixedThreadPool(16).asCoroutineDispatcher() + var callBack: CallBack? = null + val searchStateData = MutableLiveData() + var name: String = "" + var author: String = "" + private var task: Coroutine<*>? = null + private var screenKey: String = "" + private val searchBooks = linkedSetOf() + + fun initData() { + execute { + App.db.searchBookDao().getByNameAuthorEnable(name, author).let { + searchBooks.addAll(it) + upAdapter() + } + } + } + + private fun upAdapter() { + execute { + callBack?.adapter()?.let { + val books = searchBooks.toList() + books.sorted() + val diffResult = DiffUtil.calculateDiff(DiffCallBack(it.getItems(), books)) + withContext(Main) { + synchronized(this) { + it.setItems(books, false) + diffResult.dispatchUpdatesTo(it) + } + } + } + } + } + + fun search() { + task = execute { + searchStateData.postValue(true) + val bookSourceList = App.db.bookSourceDao().allEnabled + for (item in bookSourceList) { + //task取消时自动取消 by (scope = this@execute) + WebBook(item).searchBook(name, scope = this@execute, context = searchPool) + .timeout(30000L) + .onSuccess(IO) { + it?.forEach { searchBook -> + if (searchBook.name == name && searchBook.author == author) { + if (searchBook.tocUrl.isEmpty()) { + loadBookInfo(searchBook.toBook()) + } else { + loadChapter(searchBook.toBook()) + } + return@onSuccess + } + } + } + } + } + + task?.invokeOnCompletion { + searchStateData.postValue(false) + } + } + + private fun loadBookInfo(book: Book) { + execute { + App.db.bookSourceDao().getBookSource(book.origin)?.let { bookSource -> + WebBook(bookSource).getBookInfo(book, this) + .onSuccess { + it?.let { loadChapter(it) } + }.onError { + debug { context.getString(R.string.error_get_book_info) } + } + } ?: debug { context.getString(R.string.error_no_source) } + } + } + + private fun loadChapter(book: Book) { + execute { + App.db.bookSourceDao().getBookSource(book.origin)?.let { bookSource -> + WebBook(bookSource).getChapterList(book, this) + .onSuccess(IO) { + it?.let { chapters -> + if (chapters.isNotEmpty()) { + book.latestChapterTitle = chapters.last().title + val searchBook = book.toSearchBook() + App.db.searchBookDao().insert(searchBook) + searchBooks.add(searchBook) + upAdapter() + } + } + }.onError { + debug { context.getString(R.string.error_get_chapter_list) } + } + } ?: debug { R.string.error_no_source } + } + } + + /** + * 筛选 + */ + fun screen(key: String?) { + execute { + screenKey = key ?: "" + if (key.isNullOrEmpty()) { + initData() + } else { + App.db.searchBookDao() + } + } + } + + interface CallBack { + fun adapter(): ChangeSourceAdapter + } + + override fun onCleared() { + super.onCleared() + searchPool.close() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/changesource/DiffCallBack.kt b/app/src/main/java/io/legado/app/ui/changesource/DiffCallBack.kt new file mode 100644 index 000000000..2cb911204 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/changesource/DiffCallBack.kt @@ -0,0 +1,36 @@ +package io.legado.app.ui.changesource + +import androidx.recyclerview.widget.DiffUtil +import io.legado.app.data.entities.SearchBook + +class DiffCallBack(private val oldItems: List, private val newItems: List) : + DiffUtil.Callback() { + + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + return oldItems[oldItemPosition].bookUrl == newItems[newItemPosition].bookUrl + } + + override fun getOldListSize(): Int { + return oldItems.size + } + + override fun getNewListSize(): Int { + return newItems.size + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + return oldItems[oldItemPosition].originName == newItems[newItemPosition].originName + && oldItems[oldItemPosition].latestChapterTitle == newItems[newItemPosition].latestChapterTitle + } + + override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? { + val oldItem = oldItems[oldItemPosition] + val newItem = newItems[newItemPosition] + if (oldItem.originName != newItem.originName || oldItem.latestChapterTitle != newItem.latestChapterTitle) { + return true + } + return null + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/chapterlist/BookmarkAdapter.kt b/app/src/main/java/io/legado/app/ui/chapterlist/BookmarkAdapter.kt new file mode 100644 index 000000000..6eff6e223 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/chapterlist/BookmarkAdapter.kt @@ -0,0 +1,58 @@ +package io.legado.app.ui.chapterlist + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.paging.PagedListAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.R +import io.legado.app.data.entities.Bookmark +import kotlinx.android.synthetic.main.item_bookmark.view.* +import org.jetbrains.anko.sdk27.listeners.onClick + + +class BookmarkAdapter : PagedListAdapter(DIFF_CALLBACK) { + + companion object { + + @JvmField + val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean = + oldItem.time == newItem.time + + override fun areContentsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean = + oldItem.time == newItem.time + && oldItem.bookUrl == newItem.bookUrl + && oldItem.chapterName == newItem.chapterName + && oldItem.content == newItem.content + } + } + + var callback: Callback? = null + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder { + return MyViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_bookmark, parent, false)) + } + + override fun onBindViewHolder(holder: MyViewHolder, position: Int) { + getItem(position)?.let { + holder.bind(it, callback) + } + } + + class MyViewHolder(view: View) : RecyclerView.ViewHolder(view) { + + fun bind(bookmark: Bookmark, callback: Callback?) = with(itemView) { + tv_chapter_name.text = bookmark.chapterName + tv_content.text = bookmark.content + itemView.onClick { + callback?.open(bookmark) + } + } + } + + interface Callback { + fun open(bookmark: Bookmark) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/chapterlist/BookmarkFragment.kt b/app/src/main/java/io/legado/app/ui/chapterlist/BookmarkFragment.kt new file mode 100644 index 000000000..0d42b5600 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/chapterlist/BookmarkFragment.kt @@ -0,0 +1,51 @@ +package io.legado.app.ui.chapterlist + +import android.os.Bundle +import android.view.View +import android.widget.LinearLayout +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.paging.LivePagedListBuilder +import androidx.paging.PagedList +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.App +import io.legado.app.R +import io.legado.app.base.VMBaseFragment +import io.legado.app.data.entities.Bookmark +import io.legado.app.lib.theme.ATH +import io.legado.app.utils.getViewModelOfActivity +import kotlinx.android.synthetic.main.fragment_bookmark.* + +class BookmarkFragment : VMBaseFragment(R.layout.fragment_bookmark) { + override val viewModel: ChapterListViewModel + get() = getViewModelOfActivity(ChapterListViewModel::class.java) + + private lateinit var adapter: BookmarkAdapter + private var bookmarkLiveData: LiveData>? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initRecyclerView() + initData() + } + + private fun initRecyclerView() { + ATH.applyEdgeEffectColor(recycler_view) + adapter = BookmarkAdapter() + recycler_view.layoutManager = LinearLayoutManager(requireContext()) + recycler_view.addItemDecoration( + DividerItemDecoration( + requireContext(), + LinearLayout.VERTICAL + ) + ) + recycler_view.adapter = adapter + } + + private fun initData() { + bookmarkLiveData?.removeObservers(viewLifecycleOwner) + bookmarkLiveData = LivePagedListBuilder(App.db.bookmarkDao().observeByBook(viewModel.bookUrl ?: ""), 20).build() + bookmarkLiveData?.observe(viewLifecycleOwner, Observer { adapter.submitList(it) }) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/chapterlist/ChapterListActivity.kt b/app/src/main/java/io/legado/app/ui/chapterlist/ChapterListActivity.kt new file mode 100644 index 000000000..ef5be2761 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/chapterlist/ChapterListActivity.kt @@ -0,0 +1,89 @@ +package io.legado.app.ui.chapterlist + +import android.os.Bundle +import android.view.Menu +import androidx.appcompat.widget.SearchView +import androidx.core.view.isGone +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentPagerAdapter +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.lib.theme.ATH +import io.legado.app.lib.theme.primaryTextColor +import io.legado.app.utils.getViewModel +import io.legado.app.utils.gone +import io.legado.app.utils.visible +import kotlinx.android.synthetic.main.activity_chapter_list.* +import kotlinx.android.synthetic.main.view_tab_layout.* +import kotlinx.android.synthetic.main.view_title_bar.* + +class ChapterListActivity : VMBaseActivity(R.layout.activity_chapter_list) { + override val viewModel: ChapterListViewModel + get() = getViewModel(ChapterListViewModel::class.java) + + private var searchView: SearchView? = null + + override fun onActivityCreated(savedInstanceState: Bundle?) { + setSupportActionBar(toolbar) + viewModel.bookUrl = intent.getStringExtra("bookUrl") + viewModel.loadBook() + view_pager.adapter = TabFragmentPageAdapter(supportFragmentManager) + tab_layout.setupWithViewPager(view_pager) + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.search_view, menu) + val search = menu.findItem(R.id.menu_search) + searchView = search.actionView as SearchView + ATH.setTint(searchView!!, primaryTextColor) + searchView?.maxWidth = resources.displayMetrics.widthPixels + searchView?.onActionViewCollapsed() + searchView?.setOnCloseListener { + tab_layout.visible() + false + } + searchView?.setOnSearchClickListener { tab_layout.gone() } + searchView?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String): Boolean { + return false + } + + override fun onQueryTextChange(newText: String): Boolean { + return false + } + }) + return super.onCompatCreateOptionsMenu(menu) + } + + private inner class TabFragmentPageAdapter internal constructor(fm: FragmentManager) : + FragmentPagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { + override fun getItem(position: Int): Fragment { + return when (position) { + 1 -> BookmarkFragment() + else -> ChapterListFragment() + } + } + + override fun getCount(): Int { + return 2 + } + + override fun getPageTitle(position: Int): CharSequence? { + return when (position) { + 1 -> getString(R.string.bookmark) + else -> getString(R.string.chapter_list) + } + } + + } + + override fun onBackPressed() { + if (tab_layout.isGone) { + searchView?.onActionViewCollapsed() + tab_layout.visible() + } else { + super.onBackPressed() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/chapterlist/ChapterListAdapter.kt b/app/src/main/java/io/legado/app/ui/chapterlist/ChapterListAdapter.kt new file mode 100644 index 000000000..c7adcf2bb --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/chapterlist/ChapterListAdapter.kt @@ -0,0 +1,40 @@ +package io.legado.app.ui.chapterlist + +import android.content.Context +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.SimpleRecyclerAdapter +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.help.BookHelp +import io.legado.app.lib.theme.accentColor +import io.legado.app.utils.getCompatColor +import kotlinx.android.synthetic.main.item_bookmark.view.* +import org.jetbrains.anko.sdk27.listeners.onClick + +class ChapterListAdapter(context: Context, val callback: Callback) : + SimpleRecyclerAdapter(context, R.layout.item_chapter_list) { + + override fun convert(holder: ItemViewHolder, item: BookChapter, payloads: MutableList) { + with(holder.itemView) { + if (callback.durChapterIndex() == item.index) { + tv_chapter_name.setTextColor(context.accentColor) + } else { + tv_chapter_name.setTextColor(context.getCompatColor(R.color.tv_text_default)) + } + tv_chapter_name.text = item.title + this.onClick { + callback.openChapter(item) + } + callback.book()?.let { + tv_chapter_name.paint.isFakeBoldText = BookHelp.hasContent(it, item) + } + } + } + + interface Callback { + fun book(): Book? + fun openChapter(bookChapter: BookChapter) + fun durChapterIndex(): Int + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/chapterlist/ChapterListFragment.kt b/app/src/main/java/io/legado/app/ui/chapterlist/ChapterListFragment.kt new file mode 100644 index 000000000..4d3e947b6 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/chapterlist/ChapterListFragment.kt @@ -0,0 +1,93 @@ +package io.legado.app.ui.chapterlist + +import android.os.Bundle +import android.view.View +import android.widget.LinearLayout +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.App +import io.legado.app.R +import io.legado.app.base.VMBaseFragment +import io.legado.app.constant.Bus +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.utils.getViewModelOfActivity +import io.legado.app.utils.postEvent +import kotlinx.android.synthetic.main.fragment_chapter_list.* +import org.jetbrains.anko.sdk27.listeners.onClick + +class ChapterListFragment : VMBaseFragment(R.layout.fragment_chapter_list), + ChapterListAdapter.Callback { + override val viewModel: ChapterListViewModel + get() = getViewModelOfActivity(ChapterListViewModel::class.java) + + lateinit var adapter: ChapterListAdapter + private var durChapterIndex = 0 + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initRecyclerView() + initView() + initData() + } + + private fun initRecyclerView() { + adapter = ChapterListAdapter(requireContext(), this) + recycler_view.layoutManager = LinearLayoutManager(requireContext()) + recycler_view.addItemDecoration( + DividerItemDecoration( + requireContext(), + LinearLayout.VERTICAL + ) + ) + recycler_view.adapter = adapter + } + + private fun initData() { + viewModel.bookDate.observe(viewLifecycleOwner, Observer { + loadBookFinish(it) + }) + viewModel.bookUrl?.let { bookUrl -> + App.db.bookChapterDao().observeByBook(bookUrl).observe(viewLifecycleOwner, Observer { + adapter.setItems(it) + viewModel.bookDate.value?.let { book -> + loadBookFinish(book) + } + }) + } + } + + private fun initView() { + iv_chapter_top.onClick { recycler_view.scrollToPosition(0) } + iv_chapter_bottom.onClick { + if (adapter.itemCount > 0) { + recycler_view.scrollToPosition(adapter.itemCount - 1) + } + } + tv_current_chapter_info.onClick { + viewModel.bookDate.value?.let { + recycler_view.scrollToPosition(it.durChapterIndex) + } + } + } + + private fun loadBookFinish(book: Book) { + durChapterIndex = book.durChapterIndex + tv_current_chapter_info.text = book.durChapterTitle + recycler_view.scrollToPosition(durChapterIndex) + } + + override fun durChapterIndex(): Int { + return durChapterIndex + } + + override fun openChapter(bookChapter: BookChapter) { + postEvent(Bus.OPEN_CHAPTER, bookChapter) + activity?.finish() + } + + override fun book(): Book? { + return viewModel.bookDate.value + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/chapterlist/ChapterListViewModel.kt b/app/src/main/java/io/legado/app/ui/chapterlist/ChapterListViewModel.kt new file mode 100644 index 000000000..11d1b38e9 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/chapterlist/ChapterListViewModel.kt @@ -0,0 +1,24 @@ +package io.legado.app.ui.chapterlist + + +import android.app.Application +import androidx.lifecycle.MutableLiveData +import io.legado.app.App +import io.legado.app.base.BaseViewModel +import io.legado.app.data.entities.Book + +class ChapterListViewModel(application: Application) : BaseViewModel(application) { + + var bookDate = MutableLiveData() + var bookUrl: String? = null + + fun loadBook() { + execute { + bookUrl?.let { + App.db.bookDao().getBook(it)?.let { book -> + bookDate.postValue(book) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/config/ConfigActivity.kt b/app/src/main/java/io/legado/app/ui/config/ConfigActivity.kt new file mode 100644 index 000000000..6329cb2e3 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/ConfigActivity.kt @@ -0,0 +1,52 @@ +package io.legado.app.ui.config + +import android.os.Bundle +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.utils.getViewModel +import kotlinx.android.synthetic.main.activity_config.* +import kotlinx.android.synthetic.main.view_title_bar.* + +class ConfigActivity : VMBaseActivity(R.layout.activity_config) { + override val viewModel: ConfigViewModel + get() = getViewModel(ConfigViewModel::class.java) + + override fun onActivityCreated(savedInstanceState: Bundle?) { + intent.getIntExtra("configType", -1).let { + if (it != -1) viewModel.configType = it + } + this.setSupportActionBar(toolbar) + + when (viewModel.configType) { + ConfigViewModel.TYPE_CONFIG -> { + title_bar.title = "设置" + val fTag = "configFragment" + var configFragment = supportFragmentManager.findFragmentByTag(fTag) + if (configFragment == null) configFragment = ConfigFragment() + supportFragmentManager.beginTransaction() + .replace(R.id.configFrameLayout, configFragment, fTag) + .commit() + } + ConfigViewModel.TYPE_THEME_CONFIG -> { + title_bar.title = "主题设置" + val fTag = "themeConfigFragment" + var configFragment = supportFragmentManager.findFragmentByTag(fTag) + if (configFragment == null) configFragment = ThemeConfigFragment() + supportFragmentManager.beginTransaction() + .replace(R.id.configFrameLayout, configFragment, fTag) + .commit() + } + ConfigViewModel.TYPE_WEB_DAV_CONFIG -> { + title_bar.title = "WebDav设置" + val fTag = "webDavFragment" + var configFragment = supportFragmentManager.findFragmentByTag(fTag) + if (configFragment == null) configFragment = WebDavConfigFragment() + supportFragmentManager.beginTransaction() + .replace(R.id.configFrameLayout, configFragment, fTag) + .commit() + } + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/config/ConfigFragment.kt b/app/src/main/java/io/legado/app/ui/config/ConfigFragment.kt new file mode 100644 index 000000000..5269ac291 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/ConfigFragment.kt @@ -0,0 +1,111 @@ +package io.legado.app.ui.config + +import android.content.ComponentName +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.os.Bundle +import android.view.View +import androidx.preference.ListPreference +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import io.legado.app.App +import io.legado.app.R +import io.legado.app.help.BookHelp +import io.legado.app.lib.theme.ATH +import io.legado.app.receiver.SharedReceiverActivity +import io.legado.app.utils.LogUtils +import io.legado.app.utils.getPrefString +import io.legado.app.utils.putPrefBoolean + + +class ConfigFragment : PreferenceFragmentCompat(), Preference.OnPreferenceChangeListener, + SharedPreferences.OnSharedPreferenceChangeListener { + + private val packageManager = App.INSTANCE.packageManager + private val componentName = ComponentName( + App.INSTANCE, + SharedReceiverActivity::class.java.name + ) + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + putPrefBoolean("process_text", isProcessTextEnabled()) + addPreferencesFromResource(R.xml.pref_config) + bindPreferenceSummaryToValue(findPreference("downloadPath")) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + ATH.applyEdgeEffectColor(listView) + } + + override fun onResume() { + super.onResume() + preferenceManager.sharedPreferences.registerOnSharedPreferenceChangeListener(this) + } + + override fun onPause() { + preferenceManager.sharedPreferences.unregisterOnSharedPreferenceChangeListener(this) + super.onPause() + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + when (key) { + "downloadPath" -> BookHelp.upDownloadPath() + "recordLog" -> LogUtils.upLevel() + "process_text" -> sharedPreferences?.let { + setProcessTextEnable(it.getBoolean("process_text", true)) + } + } + } + + override fun onPreferenceChange(preference: Preference?, newValue: Any?): Boolean { + val stringValue = newValue.toString() + + if (preference is ListPreference) { + val index = preference.findIndexOfValue(stringValue) + // Set the summary to reflect the new value. + preference.setSummary(if (index >= 0) preference.entries[index] else null) + } else { + // For all other preferences, set the summary to the value's + preference?.summary = stringValue + } + return true + } + + private fun bindPreferenceSummaryToValue(preference: Preference?) { + preference?.let { + preference.onPreferenceChangeListener = this + onPreferenceChange( + preference, + getPreferenceString(preference.key) + ) + } + } + + private fun getPreferenceString(key: String): String { + return when (key) { + "downloadPath" -> getPrefString("downloadPath") + ?: App.INSTANCE.getExternalFilesDir(null)?.absolutePath + ?: App.INSTANCE.cacheDir.absolutePath + else -> getPrefString(key, "") + } + } + + private fun isProcessTextEnabled(): Boolean { + return packageManager.getComponentEnabledSetting(componentName) != PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } + + private fun setProcessTextEnable(enable: Boolean) { + if (enable) { + packageManager.setComponentEnabledSetting( + componentName, + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP + ) + } else { + packageManager.setComponentEnabledSetting( + componentName, + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/config/ConfigViewModel.kt b/app/src/main/java/io/legado/app/ui/config/ConfigViewModel.kt new file mode 100644 index 000000000..6f86c66d7 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/ConfigViewModel.kt @@ -0,0 +1,15 @@ +package io.legado.app.ui.config + +import android.app.Application +import androidx.lifecycle.AndroidViewModel + +class ConfigViewModel(application: Application) : AndroidViewModel(application) { + companion object { + const val TYPE_CONFIG = 0 + const val TYPE_THEME_CONFIG = 1 + const val TYPE_WEB_DAV_CONFIG = 2 + } + + var configType: Int = TYPE_CONFIG + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/config/ThemeConfigFragment.kt b/app/src/main/java/io/legado/app/ui/config/ThemeConfigFragment.kt new file mode 100644 index 000000000..642a57578 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/ThemeConfigFragment.kt @@ -0,0 +1,157 @@ +package io.legado.app.ui.config + +import android.content.SharedPreferences +import android.os.Bundle +import android.os.Handler +import android.view.View +import androidx.appcompat.app.AlertDialog +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import io.legado.app.App +import io.legado.app.R +import io.legado.app.constant.Bus +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.dialogs.noButton +import io.legado.app.lib.dialogs.yesButton +import io.legado.app.lib.theme.ATH +import io.legado.app.lib.theme.ColorUtils +import io.legado.app.utils.* + + +class ThemeConfigFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedPreferenceChangeListener { + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_config_theme) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + ATH.applyEdgeEffectColor(listView) + } + + override fun onResume() { + super.onResume() + preferenceManager.sharedPreferences.registerOnSharedPreferenceChangeListener(this) + } + + override fun onPause() { + preferenceManager.sharedPreferences.unregisterOnSharedPreferenceChangeListener(this) + super.onPause() + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + sharedPreferences ?: return + when (key) { + "transparentStatusBar" -> { + recreateActivities() + } + "colorPrimary", "colorAccent", "colorBackground" -> { + if (backgroundIsDark(sharedPreferences)) { + alert { + title = "白天背景太暗" + message = "将会恢复默认背景?" + yesButton { + putPrefInt( + "colorBackground", + getCompatColor(R.color.md_grey_100) + ) + upTheme(false) + } + + noButton { + upTheme(false) + } + }.show().applyTint() + } else { + upTheme(false) + } + } + "colorPrimaryNight", "colorAccentNight", "colorBackgroundNight" -> { + if (backgroundIsLight(sharedPreferences)) { + alert { + title = "夜间背景太亮" + message = "将会恢复默认背景?" + yesButton { + putPrefInt( + "colorBackgroundNight", + getCompatColor(R.color.md_grey_800) + ) + upTheme(true) + } + + noButton { + upTheme(true) + } + }.show().applyTint() + } else { + upTheme(true) + } + } + } + + } + + override fun onPreferenceTreeClick(preference: Preference?): Boolean { + when (preference?.key) { + "defaultTheme" -> { + activity?.let { + AlertDialog.Builder(it) + .setTitle("恢复默认主题") + .setMessage("是否确认恢复?") + .setPositiveButton(R.string.ok) { _, _ -> + preferenceManager.sharedPreferences.edit() + .putInt("colorPrimary", getCompatColor(R.color.md_light_blue_500)) + .putInt("colorAccent", getCompatColor(R.color.md_pink_800)) + .putInt("colorBackground", getCompatColor(R.color.md_grey_100)) + .putInt( + "colorPrimaryNight", + getCompatColor(R.color.md_blue_grey_600) + ) + .putInt( + "colorAccentNight", + getCompatColor(R.color.md_deep_orange_800) + ) + .putInt("colorBackgroundNight", getCompatColor(R.color.md_grey_800)) + .apply() + App.INSTANCE.applyTheme() + recreateActivities() + } + .setNegativeButton(R.string.cancel, null) + .show().applyTint() + } + } + } + return super.onPreferenceTreeClick(preference) + } + + private fun backgroundIsDark(sharedPreferences: SharedPreferences): Boolean { + return !ColorUtils.isColorLight( + sharedPreferences.getInt( + "colorBackground", + getCompatColor(R.color.md_grey_100) + ) + ) + } + + private fun backgroundIsLight(sharedPreferences: SharedPreferences): Boolean { + return ColorUtils.isColorLight( + sharedPreferences.getInt( + "colorBackgroundNight", + getCompatColor(R.color.md_grey_800) + ) + ) + } + + private fun upTheme(isNightTheme: Boolean) { + if (this.isNightTheme == isNightTheme) { + App.INSTANCE.applyTheme() + recreateActivities() + } + } + + private fun recreateActivities() { + postEvent(Bus.RECREATE, "") + Handler().postDelayed({ activity?.recreate() }, 100L) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/config/WebDavConfigFragment.kt b/app/src/main/java/io/legado/app/ui/config/WebDavConfigFragment.kt new file mode 100644 index 000000000..cbea0803b --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/WebDavConfigFragment.kt @@ -0,0 +1,93 @@ +package io.legado.app.ui.config + +import android.os.Bundle +import android.text.InputType +import android.view.View +import androidx.preference.EditTextPreference +import androidx.preference.ListPreference +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import io.legado.app.R +import io.legado.app.help.storage.Backup +import io.legado.app.help.storage.WebDavHelp +import io.legado.app.lib.theme.ATH +import io.legado.app.lib.theme.accentColor +import io.legado.app.utils.getPrefString + +class WebDavConfigFragment : PreferenceFragmentCompat(), Preference.OnPreferenceChangeListener { + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_config_web_dav) + findPreference("web_dav_url")?.let { + it.setOnBindEditTextListener { editText -> + ATH.setTint(editText, requireContext().accentColor) + } + bindPreferenceSummaryToValue(it) + } + findPreference("web_dav_account")?.let { + it.setOnBindEditTextListener { editText -> + ATH.setTint(editText, requireContext().accentColor) + } + bindPreferenceSummaryToValue(it) + } + findPreference("web_dav_password")?.let { + it.setOnBindEditTextListener { editText -> + ATH.setTint(editText, requireContext().accentColor) + editText.inputType = + InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_CLASS_TEXT + } + bindPreferenceSummaryToValue(it) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + ATH.applyEdgeEffectColor(listView) + } + + override fun onPreferenceChange(preference: Preference?, newValue: Any?): Boolean { + when { + preference?.key == "web_dav_password" -> if (newValue == null) { + preference.summary = getString(R.string.web_dav_pw_s) + } else { + preference.summary = "*".repeat(newValue.toString().length) + } + preference?.key == "web_dav_url" -> if (newValue == null) { + preference.summary = getString(R.string.web_dav_url_s) + } else { + preference.summary = newValue.toString() + } + preference?.key == "web_dav_account" -> if (newValue == null) { + preference.summary = getString(R.string.web_dav_account_s) + } else { + preference.summary = newValue.toString() + } + preference is ListPreference -> { + val index = preference.findIndexOfValue(newValue?.toString()) + // Set the summary to reflect the new value. + preference.setSummary(if (index >= 0) preference.entries[index] else null) + } + else -> preference?.summary = newValue?.toString() + } + return true + } + + private fun bindPreferenceSummaryToValue(preference: Preference?) { + preference?.apply { + onPreferenceChangeListener = this@WebDavConfigFragment + onPreferenceChange( + this, + context.getPrefString(key) + ) + } + } + + override fun onPreferenceTreeClick(preference: Preference?): Boolean { + when (preference?.key) { + "web_dav_backup" -> Backup.backup() + "web_dav_restore" -> WebDavHelp.showRestoreDialog(requireContext()) + } + return super.onPreferenceTreeClick(preference) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/explore/ExploreShowActivity.kt b/app/src/main/java/io/legado/app/ui/explore/ExploreShowActivity.kt new file mode 100644 index 000000000..1d2ba0b4e --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/explore/ExploreShowActivity.kt @@ -0,0 +1,46 @@ +package io.legado.app.ui.explore + +import android.os.Bundle +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.data.entities.SearchBook +import io.legado.app.ui.book.info.BookInfoActivity +import io.legado.app.utils.getViewModel +import kotlinx.android.synthetic.main.activity_explore_show.* +import org.jetbrains.anko.startActivity + +class ExploreShowActivity : VMBaseActivity(R.layout.activity_explore_show), + ExploreShowAdapter.CallBack { + override val viewModel: ExploreShowViewModel + get() = getViewModel(ExploreShowViewModel::class.java) + + private lateinit var adapter: ExploreShowAdapter + + override fun onActivityCreated(savedInstanceState: Bundle?) { + intent.getStringExtra("exploreName")?.let { + title_bar.title = it + } + initRecyclerView() + viewModel.booksData.observe(this, Observer { upData(it) }) + viewModel.initData(intent) + } + + private fun initRecyclerView() { + adapter = ExploreShowAdapter(this, this) + recycler_view.layoutManager = LinearLayoutManager(this) + recycler_view.addItemDecoration(DividerItemDecoration(this, RecyclerView.VERTICAL)) + recycler_view.adapter = adapter + } + + private fun upData(books: List) { + adapter.addItems(books) + } + + override fun showBookInfo(bookUrl: String) { + startActivity(Pair("searchBookUrl", bookUrl)) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/explore/ExploreShowAdapter.kt b/app/src/main/java/io/legado/app/ui/explore/ExploreShowAdapter.kt new file mode 100644 index 000000000..1a248e736 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/explore/ExploreShowAdapter.kt @@ -0,0 +1,75 @@ +package io.legado.app.ui.explore + +import android.content.Context +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.SimpleRecyclerAdapter +import io.legado.app.data.entities.SearchBook +import io.legado.app.help.ImageLoader +import io.legado.app.utils.gone +import io.legado.app.utils.visible +import kotlinx.android.synthetic.main.item_bookshelf_list.view.iv_cover +import kotlinx.android.synthetic.main.item_bookshelf_list.view.tv_name +import kotlinx.android.synthetic.main.item_search.view.* +import org.jetbrains.anko.sdk27.listeners.onClick + +class ExploreShowAdapter(context: Context, val callBack: CallBack) : + SimpleRecyclerAdapter(context, R.layout.item_search) { + + override fun convert(holder: ItemViewHolder, item: SearchBook, payloads: MutableList) = + with(holder.itemView) { + tv_name.text = item.name + tv_author.text = context.getString(R.string.author_show, item.author) + if (item.latestChapterTitle.isNullOrEmpty()) { + tv_lasted.gone() + } else { + tv_lasted.text = context.getString(R.string.lasted_show, item.latestChapterTitle) + tv_lasted.visible() + } + tv_introduce.text = context.getString(R.string.intro_show, item.intro) + val kinds = item.getKindList() + if (kinds.isEmpty()) { + ll_kind.gone() + } else { + ll_kind.visible() + for (index in 0..2) { + if (kinds.size > index) { + when (index) { + 0 -> { + tv_kind.text = kinds[index] + tv_kind.visible() + } + 1 -> { + tv_kind_1.text = kinds[index] + tv_kind_1.visible() + } + 2 -> { + tv_kind_2.text = kinds[index] + tv_kind_2.visible() + } + } + } else { + when (index) { + 0 -> tv_kind.gone() + 1 -> tv_kind_1.gone() + 2 -> tv_kind_2.gone() + } + } + } + } + item.coverUrl.let { + ImageLoader.load(context, it)//Glide自动识别http://和file:// + .placeholder(R.drawable.image_cover_default) + .error(R.drawable.image_cover_default) + .centerCrop() + .setAsDrawable(iv_cover) + } + onClick { + callBack.showBookInfo(item.bookUrl) + } + } + + interface CallBack { + fun showBookInfo(bookUrl: String) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/explore/ExploreShowViewModel.kt b/app/src/main/java/io/legado/app/ui/explore/ExploreShowViewModel.kt new file mode 100644 index 000000000..a9514c8e4 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/explore/ExploreShowViewModel.kt @@ -0,0 +1,47 @@ +package io.legado.app.ui.explore + +import android.app.Application +import android.content.Intent +import androidx.lifecycle.MutableLiveData +import io.legado.app.App +import io.legado.app.base.BaseViewModel +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.SearchBook +import io.legado.app.model.WebBook +import kotlinx.coroutines.Dispatchers.IO + +class ExploreShowViewModel(application: Application) : BaseViewModel(application) { + + val booksData = MutableLiveData>() + private var bookSource: BookSource? = null + private var exploreUrl: String? = null + private var page = 1 + + fun initData(intent: Intent) { + execute { + val sourceUrl = intent.getStringExtra("sourceUrl") + exploreUrl = intent.getStringExtra("exploreUrl") + if (bookSource == null) { + bookSource = App.db.bookSourceDao().getBookSource(sourceUrl) + } + explore() + } + } + + fun explore() { + bookSource?.let { source -> + exploreUrl?.let { url -> + WebBook(source).exploreBook(url, page, this) + .timeout(30000L) + .onSuccess(IO) { searchBooks -> + searchBooks?.let { + booksData.postValue(searchBooks) + App.db.searchBookDao().insert(*searchBooks.toTypedArray()) + page++ + } + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt index ce40a7e33..81b7888f8 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt @@ -1,69 +1,129 @@ package io.legado.app.ui.main -import android.content.Intent import android.os.Bundle -import android.view.Menu +import android.view.KeyEvent import android.view.MenuItem -import androidx.appcompat.app.ActionBarDrawerToggle -import androidx.core.view.GravityCompat -import androidx.drawerlayout.widget.DrawerLayout -import com.google.android.material.navigation.NavigationView +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentPagerAdapter +import androidx.viewpager.widget.ViewPager +import com.google.android.material.bottomnavigation.BottomNavigationView +import io.legado.app.App import io.legado.app.R -import io.legado.app.base.BaseActivity -import io.legado.app.ui.search.SearchActivity -import io.legado.app.utils.getViewModel +import io.legado.app.base.VMBaseActivity +import io.legado.app.constant.Bus +import io.legado.app.help.permission.Permissions +import io.legado.app.help.permission.PermissionsCompat +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.dialogs.noButton +import io.legado.app.lib.dialogs.yesButton +import io.legado.app.lib.theme.ATH +import io.legado.app.ui.main.bookshelf.BookshelfFragment +import io.legado.app.ui.main.explore.ExploreFragment +import io.legado.app.ui.main.my.MyFragment +import io.legado.app.ui.main.rss.RssFragment +import io.legado.app.utils.* import kotlinx.android.synthetic.main.activity_main.* -import kotlinx.android.synthetic.main.app_bar_main.* +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class MainActivity : VMBaseActivity(R.layout.activity_main), + BottomNavigationView.OnNavigationItemSelectedListener, + ViewPager.OnPageChangeListener by ViewPager.SimpleOnPageChangeListener() { -class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedListener { override val viewModel: MainViewModel get() = getViewModel(MainViewModel::class.java) - override val layoutID: Int - get() = R.layout.activity_main + private var pagePosition = 0 - override fun onViewModelCreated(viewModel: MainViewModel, savedInstanceState: Bundle?) { - fab.setOnClickListener { startActivity(Intent(this, SearchActivity::class.java)) } + override fun onActivityCreated(savedInstanceState: Bundle?) { + ATH.applyEdgeEffectColor(view_pager_main) + ATH.applyBottomNavigationColor(bottom_navigation_view) + view_pager_main.offscreenPageLimit = 3 + view_pager_main.adapter = TabFragmentPageAdapter(supportFragmentManager) + view_pager_main.addOnPageChangeListener(this) + bottom_navigation_view.setOnNavigationItemSelectedListener(this) + importYueDu() + upVersion() + } - val toggle = ActionBarDrawerToggle( - this, drawer_layout, titleBar.toolbar, - R.string.navigation_drawer_open, - R.string.navigation_drawer_close - ) - drawer_layout.addDrawerListener(toggle) - toggle.syncState() + override fun onNavigationItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_bookshelf -> view_pager_main.currentItem = 0 + R.id.menu_find_book -> view_pager_main.currentItem = 1 + R.id.menu_rss -> view_pager_main.currentItem = 2 + R.id.menu_my_config -> view_pager_main.currentItem = 3 + } + return false + } - nav_view.setNavigationItemSelectedListener(this) + private fun importYueDu() { + launch { + if (withContext(IO) { App.db.bookDao().allBookCount == 0 }) { + alert(title = "导入") { + message = "是否导入旧版本数据" + yesButton { + PermissionsCompat.Builder(this@MainActivity) + .addPermissions(*Permissions.Group.STORAGE) + .rationale(R.string.tip_perm_request_storage) + .onGranted { viewModel.restore() } + .request() + } + noButton { } + }.show().applyTint() + } + } } - override fun onBackPressed() { - val drawerLayout: DrawerLayout = findViewById(R.id.drawer_layout) - if (drawerLayout.isDrawerOpen(GravityCompat.START)) { - drawerLayout.closeDrawer(GravityCompat.START) - } else { - super.onBackPressed() + private fun upVersion() { + if (getPrefInt("versionCode") != App.INSTANCE.versionCode) { + putPrefInt("versionCode", App.INSTANCE.versionCode) } } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - // Inflate the menu; this adds items to the action bar if it is present. - menuInflater.inflate(R.menu.main, menu) - return true + override fun onPageSelected(position: Int) { + pagePosition = position + bottom_navigation_view.menu.getItem(position).isChecked = true } - override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { - return super.onCompatOptionsItemSelected(item) + override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { + event?.let { + when (keyCode) { + KeyEvent.KEYCODE_BACK -> if ( + pagePosition != 0 + && event.isTracking + && !event.isCanceled + ) { + view_pager_main.currentItem = 0 + return true + } + } + } + return super.onKeyUp(keyCode, event) } - override fun onNavigationItemSelected(item: MenuItem): Boolean { - // Handle navigation view item clicks here. - when (item.itemId) { - R.id.nav_send -> { + private inner class TabFragmentPageAdapter internal constructor(fm: FragmentManager) : + FragmentPagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { + override fun getItem(position: Int): Fragment { + return when (position) { + 0 -> BookshelfFragment() + 1 -> ExploreFragment() + 2 -> RssFragment() + else -> MyFragment() } } - val drawerLayout: DrawerLayout = findViewById(R.id.drawer_layout) - drawerLayout.closeDrawer(GravityCompat.START) - return true + + override fun getCount(): Int { + return 4 + } + + } + + override fun observeLiveBus() { + observeEvent(Bus.RECREATE) { + recreate() + } } } diff --git a/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt b/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt index 82a9146c5..d1146aae8 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt @@ -1,9 +1,60 @@ package io.legado.app.ui.main import android.app.Application -import androidx.lifecycle.AndroidViewModel +import io.legado.app.App +import io.legado.app.base.BaseViewModel +import io.legado.app.constant.BookType +import io.legado.app.constant.Bus +import io.legado.app.help.storage.Restore +import io.legado.app.model.WebBook +import io.legado.app.utils.postEvent +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch -class MainViewModel(application: Application) : AndroidViewModel(application) { +class MainViewModel(application: Application) : BaseViewModel(application) { + val updateList = arrayListOf() + fun restore() { + launch(IO) { + Restore.importYueDuData(getApplication()) + } + } + fun upChapterList() { + execute { + App.db.bookDao().allBooks.forEach { book -> + if (book.origin != BookType.local) { + if (!updateList.contains(book.bookUrl)) { + App.db.bookSourceDao().getBookSource(book.origin)?.let { bookSource -> + synchronized(this) { + updateList.add(book.bookUrl) + postEvent(Bus.UP_BOOK, book.bookUrl) + } + WebBook(bookSource).getChapterList(book) + .onSuccess(IO) { + synchronized(this) { + updateList.remove(book.bookUrl) + postEvent(Bus.UP_BOOK, book.bookUrl) + } + it?.let { + App.db.bookDao().update(book) + App.db.bookChapterDao().delByBook(book.bookUrl) + App.db.bookChapterDao().insert(*it.toTypedArray()) + } + } + .onError { + synchronized(this) { + updateList.remove(book.bookUrl) + postEvent(Bus.UP_BOOK, book.bookUrl) + } + it.printStackTrace() + } + } + } + } + delay(50) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BooksAdapter.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BooksAdapter.kt new file mode 100644 index 000000000..9b3ec6530 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BooksAdapter.kt @@ -0,0 +1,84 @@ +package io.legado.app.ui.main.bookshelf + +import android.content.Context +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.SimpleRecyclerAdapter +import io.legado.app.constant.BookType +import io.legado.app.data.entities.Book +import io.legado.app.help.ImageLoader +import io.legado.app.lib.theme.ATH +import io.legado.app.utils.LogUtils +import io.legado.app.utils.invisible +import kotlinx.android.synthetic.main.item_bookshelf_list.view.* +import org.jetbrains.anko.sdk27.listeners.onClick +import org.jetbrains.anko.sdk27.listeners.onLongClick + +class BooksAdapter(context: Context, private val callBack: CallBack) : + SimpleRecyclerAdapter(context, R.layout.item_bookshelf_list) { + + override fun convert(holder: ItemViewHolder, item: Book, payloads: MutableList) = + with(holder.itemView) { + if (payloads.isEmpty()) { + ATH.applyBackgroundTint(this) + tv_name.text = item.name + tv_author.text = item.author + tv_read.text = item.durChapterTitle + tv_last.text = item.latestChapterTitle + item.getDisplayCover()?.let { + ImageLoader.load(context, it)//Glide自动识别http://和file:// + .placeholder(R.drawable.image_cover_default) + .error(R.drawable.image_cover_default) + .centerCrop() + .setAsDrawable(iv_cover) + } + onClick { callBack.open(item) } + onLongClick { + callBack.openBookInfo(item) + true + } + if (item.origin != BookType.local && callBack.isUpdate(item.bookUrl)) { + LogUtils.d(item.name, "loading") + bv_unread.invisible() + rl_loading.show() + } else { + LogUtils.d(item.name, "loadingHide") + rl_loading.hide() + bv_unread.setBadgeCount(item.getUnreadChapterNum()) + bv_unread.setHighlight(item.lastCheckCount > 0) + } + } else { + when (payloads[0]) { + 5 -> { + if (item.origin != BookType.local && callBack.isUpdate(item.bookUrl)) { + LogUtils.d(item.name, "loading") + bv_unread.invisible() + rl_loading.show() + } else { + LogUtils.d(item.name, "loadingHide") + rl_loading.hide() + bv_unread.setBadgeCount(item.getUnreadChapterNum()) + bv_unread.setHighlight(item.lastCheckCount > 0) + } + } + } + } + } + + fun notification(bookUrl: String) { + for (i in 0 until itemCount) { + getItem(i)?.let { + if (it.bookUrl == bookUrl) { + notifyItemChanged(i, 5) + return + } + } + } + } + + interface CallBack { + fun open(book: Book) + fun openBookInfo(book: Book) + fun isUpdate(bookUrl: String): Boolean + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BooksDiffCallBack.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BooksDiffCallBack.kt new file mode 100644 index 000000000..c5b7d7c03 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BooksDiffCallBack.kt @@ -0,0 +1,33 @@ +package io.legado.app.ui.main.bookshelf + +import androidx.recyclerview.widget.DiffUtil +import io.legado.app.data.entities.Book + +class BooksDiffCallBack(private val oldItems: List, private val newItems: List) : + DiffUtil.Callback() { + + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + return oldItems[oldItemPosition].bookUrl == newItems[newItemPosition].bookUrl + } + + override fun getOldListSize(): Int { + return oldItems.size + } + + override fun getNewListSize(): Int { + return newItems.size + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldItems[oldItemPosition] + val newItem = newItems[newItemPosition] + return oldItem.name == newItem.name + && oldItem.durChapterTitle == newItem.durChapterTitle + && oldItem.latestChapterTitle == newItem.latestChapterTitle + && oldItem.getDisplayCover() == newItem.getDisplayCover() + && oldItem.getUnreadChapterNum() == newItem.getUnreadChapterNum() + && oldItem.lastCheckCount == newItem.lastCheckCount + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BooksFragment.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BooksFragment.kt new file mode 100644 index 000000000..3a4657dbf --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BooksFragment.kt @@ -0,0 +1,109 @@ +package io.legado.app.ui.main.bookshelf + +import android.os.Bundle +import android.view.View +import androidx.core.content.ContextCompat +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.App +import io.legado.app.R +import io.legado.app.base.VMBaseFragment +import io.legado.app.constant.Bus +import io.legado.app.data.entities.Book +import io.legado.app.lib.theme.ATH +import io.legado.app.lib.theme.accentColor +import io.legado.app.ui.book.info.BookInfoActivity +import io.legado.app.ui.book.read.ReadBookActivity +import io.legado.app.ui.main.MainViewModel +import io.legado.app.utils.getViewModel +import io.legado.app.utils.getViewModelOfActivity +import io.legado.app.utils.observeEvent +import kotlinx.android.synthetic.main.fragment_books.* +import org.jetbrains.anko.startActivity + + +class BooksFragment : VMBaseFragment(R.layout.fragment_books), + BooksAdapter.CallBack { + + override val viewModel: BooksViewModel + get() = getViewModel(BooksViewModel::class.java) + + companion object { + fun newInstance(position: Int): BooksFragment { + return BooksFragment().apply { + val bundle = Bundle() + bundle.putInt("groupId", position) + arguments = bundle + } + } + } + + private lateinit var activityViewModel: MainViewModel + private lateinit var booksAdapter: BooksAdapter + private var bookshelfLiveData: LiveData>? = null + private var groupId = -1 + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + activityViewModel = getViewModelOfActivity(MainViewModel::class.java) + arguments?.let { + groupId = it.getInt("groupId", -1) + } + initRecyclerView() + upRecyclerData() + observeEvent(Bus.UP_BOOK) { + booksAdapter.notification(it) + } + } + + private fun initRecyclerView() { + ATH.applyEdgeEffectColor(rv_bookshelf) + refresh_layout.setColorSchemeColors(accentColor) + refresh_layout.setOnRefreshListener { + refresh_layout.isRefreshing = false + activityViewModel.upChapterList() + } + rv_bookshelf.layoutManager = LinearLayoutManager(context) + rv_bookshelf.addItemDecoration( + DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL).apply { + ContextCompat.getDrawable(requireContext(), R.drawable.ic_divider)?.let { + this.setDrawable(it) + } + }) + booksAdapter = BooksAdapter(requireContext(), this) + rv_bookshelf.adapter = booksAdapter + } + + private fun upRecyclerData() { + bookshelfLiveData?.removeObservers(this) + bookshelfLiveData = when (groupId) { + -1 -> App.db.bookDao().observeAll() + -2 -> App.db.bookDao().observeLocal() + -3 -> App.db.bookDao().observeAudio() + else -> App.db.bookDao().observeByGroup(groupId) + } + bookshelfLiveData?.observe( + this, + Observer { + val diffResult = + DiffUtil.calculateDiff(BooksDiffCallBack(booksAdapter.getItems(), it)) + booksAdapter.setItems(it, false) + diffResult.dispatchUpdatesTo(booksAdapter) + }) + } + + override fun open(book: Book) { + context?.startActivity(Pair("bookUrl", book.bookUrl)) + } + + override fun openBookInfo(book: Book) { + context?.startActivity(Pair("bookUrl", book.bookUrl)) + } + + override fun isUpdate(bookUrl: String): Boolean { + return bookUrl in activityViewModel.updateList + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BooksViewModel.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BooksViewModel.kt new file mode 100644 index 000000000..ef6d08fbb --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BooksViewModel.kt @@ -0,0 +1,10 @@ +package io.legado.app.ui.main.bookshelf + +import android.app.Application +import io.legado.app.base.BaseViewModel + + +class BooksViewModel(application: Application) : BaseViewModel(application) { + + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfAdapter.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfAdapter.kt new file mode 100644 index 000000000..c4176f78d --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfAdapter.kt @@ -0,0 +1,24 @@ +package io.legado.app.ui.main.bookshelf + +import androidx.fragment.app.Fragment +import androidx.viewpager2.adapter.FragmentStateAdapter +import io.legado.app.data.entities.BookGroup + + +class BookshelfAdapter(fragment: Fragment, val callBack: CallBack) : + FragmentStateAdapter(fragment) { + + override fun getItemCount(): Int { + return callBack.groupSize + } + + override fun createFragment(position: Int): Fragment { + val groupId = callBack.getGroup(position).groupId + return BooksFragment.newInstance(groupId) + } + + interface CallBack { + val groupSize: Int + fun getGroup(position: Int): BookGroup + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfFragment.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfFragment.kt new file mode 100644 index 000000000..9f97521b7 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfFragment.kt @@ -0,0 +1,133 @@ +package io.legado.app.ui.main.bookshelf + +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.appcompat.widget.SearchView +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import io.legado.app.App +import io.legado.app.R +import io.legado.app.base.VMBaseFragment +import io.legado.app.constant.AppConst +import io.legado.app.data.entities.BookGroup +import io.legado.app.lib.dialogs.selector +import io.legado.app.lib.theme.ATH +import io.legado.app.ui.book.search.SearchActivity +import io.legado.app.utils.getPrefBoolean +import io.legado.app.utils.getViewModel +import io.legado.app.utils.putPrefInt +import io.legado.app.utils.startActivity +import kotlinx.android.synthetic.main.fragment_bookshelf.* +import kotlinx.android.synthetic.main.view_tab_layout.* +import kotlinx.android.synthetic.main.view_title_bar.* +import org.jetbrains.anko.startActivity + +class BookshelfFragment : VMBaseFragment(R.layout.fragment_bookshelf), + SearchView.OnQueryTextListener, + GroupManageDialog.CallBack, + BookshelfAdapter.CallBack { + + override val viewModel: BookshelfViewModel + get() = getViewModel(BookshelfViewModel::class.java) + + private var bookGroupLiveData: LiveData>? = null + private val bookGroups = mutableListOf() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setSupportToolbar(toolbar) + initRecyclerView() + initBookGroupData() + } + + override fun onCompatCreateOptionsMenu(menu: Menu) { + menuInflater.inflate(R.menu.main_bookshelf, menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem) { + super.onCompatOptionsItemSelected(item) + when (item.itemId) { + R.id.menu_search -> startActivity() + R.id.menu_bookshelf_layout -> selectBookshelfLayout() + R.id.menu_group_manage -> GroupManageDialog() + .show(childFragmentManager, "groupManageDialog") + R.id.menu_add_local -> { + } + R.id.menu_add_url -> { + } + R.id.menu_arrange_bookshelf -> { + } + } + } + + override val groupSize: Int + get() = bookGroups.size + + override fun getGroup(position: Int): BookGroup { + return bookGroups[position] + } + + private fun initRecyclerView() { + tab_layout.isTabIndicatorFullWidth = false + tab_layout.tabMode = TabLayout.MODE_SCROLLABLE + ATH.applyEdgeEffectColor(view_pager_bookshelf) + view_pager_bookshelf.adapter = BookshelfAdapter(this, this) + TabLayoutMediator(tab_layout, view_pager_bookshelf) { tab, position -> + tab.text = bookGroups[position].groupName + }.attach() + } + + private fun initBookGroupData() { + bookGroupLiveData?.removeObservers(viewLifecycleOwner) + bookGroupLiveData = App.db.bookGroupDao().liveDataAll() + bookGroupLiveData?.observe(viewLifecycleOwner, Observer { + synchronized(this) { + bookGroups.clear() + bookGroups.add(AppConst.bookGroupAll) + if (AppConst.bookGroupLocalShow) { + bookGroups.add(AppConst.bookGroupLocal) + } + if (AppConst.bookGroupAudioShow) { + bookGroups.add(AppConst.bookGroupAudio) + } + bookGroups.addAll(it) + view_pager_bookshelf.adapter?.notifyDataSetChanged() + } + }) + } + + override fun onQueryTextSubmit(query: String?): Boolean { + context?.startActivity(Pair("key", query)) + return false + } + + override fun onQueryTextChange(newText: String?): Boolean { + return false + } + + override fun upGroup() { + synchronized(this) { + bookGroups.remove(AppConst.bookGroupLocal) + bookGroups.remove(AppConst.bookGroupAudio) + if (getPrefBoolean("bookGroupAudio", true)) { + bookGroups.add(1, AppConst.bookGroupAudio) + } + if (getPrefBoolean("bookGroupLocal", true)) { + bookGroups.add(1, AppConst.bookGroupLocal) + } + view_pager_bookshelf.adapter?.notifyDataSetChanged() + } + } + + private fun selectBookshelfLayout() { + selector( + title = "选择书架布局", + items = resources.getStringArray(R.array.bookshelf_layout).toList() + ) { _, index -> + putPrefInt("bookshelf", index) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt new file mode 100644 index 000000000..0499e5e5e --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt @@ -0,0 +1,34 @@ +package io.legado.app.ui.main.bookshelf + +import android.app.Application +import io.legado.app.App +import io.legado.app.base.BaseViewModel +import io.legado.app.data.entities.BookGroup + +class BookshelfViewModel(application: Application) : BaseViewModel(application) { + + + fun addGroup(groupName: String) { + execute { + val maxId = App.db.bookGroupDao().maxId + val bookGroup = BookGroup( + groupId = maxId.plus(1), + groupName = groupName, + order = maxId.plus(1) + ) + App.db.bookGroupDao().insert(bookGroup) + } + } + + fun upGroup(vararg bookGroup: BookGroup) { + execute { + App.db.bookGroupDao().update(*bookGroup) + } + } + + fun delGroup(vararg bookGroup: BookGroup) { + execute { + App.db.bookGroupDao().delete(*bookGroup) + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/GroupManageDialog.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/GroupManageDialog.kt new file mode 100644 index 000000000..1f3423853 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/GroupManageDialog.kt @@ -0,0 +1,179 @@ +package io.legado.app.ui.main.bookshelf + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Bundle +import android.util.DisplayMetrics +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.App +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.SimpleRecyclerAdapter +import io.legado.app.constant.AppConst +import io.legado.app.data.entities.BookGroup +import io.legado.app.help.ItemTouchCallback +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.dialogs.customView +import io.legado.app.lib.dialogs.noButton +import io.legado.app.lib.dialogs.yesButton +import io.legado.app.utils.applyTint +import io.legado.app.utils.getViewModel +import io.legado.app.utils.requestInputMethod +import kotlinx.android.synthetic.main.dialog_edit_text.view.* +import kotlinx.android.synthetic.main.dialog_recycler_view.* +import kotlinx.android.synthetic.main.item_group_manage.view.* +import org.jetbrains.anko.sdk27.listeners.onClick + +class GroupManageDialog : DialogFragment(), Toolbar.OnMenuItemClickListener { + private lateinit var viewModel: BookshelfViewModel + private lateinit var adapter: GroupAdapter + private var callBack: CallBack? = null + + override fun onStart() { + super.onStart() + val dm = DisplayMetrics() + activity?.windowManager?.defaultDisplay?.getMetrics(dm) + dialog?.window?.setLayout((dm.widthPixels * 0.9).toInt(), (dm.heightPixels * 0.9).toInt()) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + viewModel = getViewModel(BookshelfViewModel::class.java) + return inflater.inflate(R.layout.dialog_recycler_view, container) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + callBack = parentFragment as? CallBack + initData() + } + + private fun initData() { + tool_bar.title = getString(R.string.group_manage) + tool_bar.inflateMenu(R.menu.book_group_manage) + tool_bar.menu.applyTint(requireContext(), false) + tool_bar.setOnMenuItemClickListener(this) + tool_bar.menu.findItem(R.id.menu_group_local) + .isChecked = AppConst.bookGroupLocalShow + tool_bar.menu.findItem(R.id.menu_group_audio) + .isChecked = AppConst.bookGroupAudioShow + adapter = GroupAdapter(requireContext()) + recycler_view.layoutManager = LinearLayoutManager(requireContext()) + recycler_view.addItemDecoration( + DividerItemDecoration(requireContext(), RecyclerView.VERTICAL) + ) + recycler_view.adapter = adapter + App.db.bookGroupDao().liveDataAll().observe(viewLifecycleOwner, Observer { + adapter.setItems(it) + }) + val itemTouchCallback = ItemTouchCallback() + itemTouchCallback.onItemTouchCallbackListener = adapter + itemTouchCallback.isCanDrag = true + ItemTouchHelper(itemTouchCallback).attachToRecyclerView(recycler_view) + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.menu_add -> addGroup() + R.id.menu_group_local -> { + item.isChecked = !item.isChecked + AppConst.bookGroupLocalShow = item.isChecked + callBack?.upGroup() + } + R.id.menu_group_audio -> { + item.isChecked = !item.isChecked + AppConst.bookGroupAudioShow = item.isChecked + callBack?.upGroup() + } + } + return true + } + + @SuppressLint("InflateParams") + private fun addGroup() { + alert(title = getString(R.string.add_group)) { + var editText: EditText? = null + customView { + layoutInflater.inflate(R.layout.dialog_edit_text, null).apply { + editText = edit_view.apply { + hint = "分组名称" + } + } + } + yesButton { + editText?.text?.toString()?.let { + if (it.isNotBlank()) { + viewModel.addGroup(it) + } + } + } + noButton() + }.show().applyTint().requestInputMethod() + } + + @SuppressLint("InflateParams") + private fun editGroup(bookGroup: BookGroup) { + alert(title = getString(R.string.group_edit)) { + var editText: EditText? = null + customView { + layoutInflater.inflate(R.layout.dialog_edit_text, null).apply { + editText = edit_view.apply { + hint = "分组名称" + setText(bookGroup.groupName) + } + } + } + yesButton { + viewModel.upGroup(bookGroup.copy(groupName = editText?.text?.toString() ?: "")) + } + noButton() + }.show().applyTint().requestInputMethod() + } + + private inner class GroupAdapter(context: Context) : + SimpleRecyclerAdapter(context, R.layout.item_group_manage), + ItemTouchCallback.OnItemTouchCallbackListener { + + override fun convert(holder: ItemViewHolder, item: BookGroup, payloads: MutableList) { + with(holder.itemView) { + tv_group.text = item.groupName + tv_edit.onClick { editGroup(item) } + tv_del.onClick { viewModel.delGroup(item) } + } + } + + override fun onMove(srcPosition: Int, targetPosition: Int): Boolean { + val srcItem = getItem(srcPosition) + val targetItem = getItem(targetPosition) + if (srcItem != null && targetItem != null) { + val order = srcItem.order + srcItem.order = targetItem.order + targetItem.order = order + viewModel.upGroup(srcItem, targetItem) + } + return true + } + + override fun onSwiped(adapterPosition: Int) { + + } + } + + interface CallBack { + fun upGroup() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/explore/ExploreAdapter.kt b/app/src/main/java/io/legado/app/ui/main/explore/ExploreAdapter.kt new file mode 100644 index 000000000..205cf4e2c --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/explore/ExploreAdapter.kt @@ -0,0 +1,106 @@ +package io.legado.app.ui.main.explore + +import android.content.Context +import android.view.LayoutInflater +import android.view.Menu +import android.widget.PopupMenu +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.SimpleRecyclerAdapter +import io.legado.app.data.entities.BookSource +import io.legado.app.help.coroutine.Coroutine +import io.legado.app.lib.theme.ATH +import io.legado.app.lib.theme.accentColor +import io.legado.app.utils.ACache +import io.legado.app.utils.gone +import io.legado.app.utils.visible +import kotlinx.android.synthetic.main.item_find_book.view.* +import kotlinx.android.synthetic.main.item_text.view.* +import kotlinx.coroutines.CoroutineScope +import org.jetbrains.anko.sdk27.listeners.onClick +import org.jetbrains.anko.sdk27.listeners.onLongClick + + +class ExploreAdapter(context: Context, private val scope: CoroutineScope, val callBack: CallBack) : + SimpleRecyclerAdapter(context, R.layout.item_find_book) { + + private var exIndex = 0 + + override fun convert(holder: ItemViewHolder, item: BookSource, payloads: MutableList) { + with(holder.itemView) { + if (payloads.isEmpty()) { + ATH.applyBackgroundTint(ll_title) + tv_name.text = item.bookSourceName + ll_title.onClick { + val oldEx = exIndex + exIndex = if (exIndex == holder.layoutPosition) -1 else holder.layoutPosition + notifyItemChanged(oldEx, false) + if (exIndex != -1) { + notifyItemChanged(holder.layoutPosition, false) + } + callBack.scrollTo(holder.layoutPosition) + } + ll_title.onLongClick { + val popupMenu = PopupMenu(context, ll_title) + popupMenu.menu.add(Menu.NONE, R.id.menu_edit, Menu.NONE, R.string.edit) + popupMenu.menu.add(Menu.NONE, R.id.menu_top, Menu.NONE, R.string.to_top) + popupMenu.menu.add(Menu.NONE, R.id.menu_refresh, Menu.NONE, R.string.refresh) + popupMenu.setOnMenuItemClickListener { + when (it.itemId) { + R.id.menu_edit -> callBack.editSource(item.bookSourceUrl) + R.id.menu_top -> callBack.toTop(item) + R.id.menu_refresh -> { + ACache.get(context, "explore").remove(item.bookSourceUrl) + notifyItemChanged(holder.layoutPosition) + } + } + true + } + popupMenu.show() + true + } + } + if (exIndex == holder.layoutPosition) { + iv_status.setImageResource(R.drawable.ic_remove) + rotate_loading.loadingColor = context.accentColor + rotate_loading.show() + Coroutine.async(scope) { + item.getExploreKinds() + }.onSuccess { kindList -> + if (!kindList.isNullOrEmpty()) { + gl_child.visible() + gl_child.removeAllViews() + kindList.map { kind -> + val tv = LayoutInflater.from(context) + .inflate(R.layout.item_text, gl_child, false) + tv.text_view.text = kind.title + tv.text_view.onClick { + kind.url?.let { kindUrl -> + callBack.openExplore( + item.bookSourceUrl, + kind.title, + kindUrl + ) + } + } + gl_child.addView(tv) + } + } + }.onFinally { + rotate_loading.hide() + } + } else { + iv_status.setImageResource(R.drawable.ic_add) + rotate_loading.hide() + gl_child.gone() + } + } + } + + interface CallBack { + fun scrollTo(pos: Int) + fun openExplore(sourceUrl: String, title: String, exploreUrl: String) + fun editSource(sourceUrl: String) + fun toTop(source: BookSource) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/explore/ExploreFragment.kt b/app/src/main/java/io/legado/app/ui/main/explore/ExploreFragment.kt new file mode 100644 index 000000000..ea23c0909 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/explore/ExploreFragment.kt @@ -0,0 +1,98 @@ +package io.legado.app.ui.main.explore + +import android.os.Bundle +import android.view.View +import androidx.appcompat.widget.SearchView +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.App +import io.legado.app.R +import io.legado.app.base.VMBaseFragment +import io.legado.app.data.entities.BookSource +import io.legado.app.lib.theme.ATH +import io.legado.app.lib.theme.primaryTextColor +import io.legado.app.ui.book.source.edit.BookSourceEditActivity +import io.legado.app.ui.explore.ExploreShowActivity +import io.legado.app.utils.getViewModel +import io.legado.app.utils.startActivity +import kotlinx.android.synthetic.main.fragment_find_book.* +import kotlinx.android.synthetic.main.view_search.* +import kotlinx.android.synthetic.main.view_title_bar.* + + +class ExploreFragment : VMBaseFragment(R.layout.fragment_find_book), + ExploreAdapter.CallBack { + override val viewModel: ExploreViewModel + get() = getViewModel(ExploreViewModel::class.java) + + private lateinit var adapter: ExploreAdapter + private lateinit var linearLayoutManager: LinearLayoutManager + private var liveExplore: LiveData>? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setSupportToolbar(toolbar) + initSearchView() + initRecyclerView() + initData() + } + + private fun initSearchView() { + ATH.setTint(search_view, primaryTextColor) + search_view.onActionViewExpanded() + search_view.isSubmitButtonEnabled = true + search_view.queryHint = getString(R.string.screen_find) + search_view.clearFocus() + search_view.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + return false + } + + override fun onQueryTextChange(newText: String?): Boolean { + initData(newText) + return false + } + }) + } + + private fun initRecyclerView() { + ATH.applyEdgeEffectColor(rv_find) + linearLayoutManager = LinearLayoutManager(context) + rv_find.layoutManager = linearLayoutManager + adapter = ExploreAdapter(requireContext(), this, this) + rv_find.adapter = adapter + } + + private fun initData(key: String? = null) { + liveExplore?.removeObservers(viewLifecycleOwner) + liveExplore = if (key.isNullOrBlank()) { + App.db.bookSourceDao().liveExplore() + } else { + App.db.bookSourceDao().liveExplore("%$key%") + } + liveExplore?.observe(viewLifecycleOwner, Observer { + adapter.setItems(it) + }) + } + + override fun scrollTo(pos: Int) { + rv_find.smoothScrollToPosition(pos) + } + + override fun openExplore(sourceUrl: String, title: String, exploreUrl: String) { + startActivity( + Pair("exploreName", title), + Pair("sourceUrl", sourceUrl), + Pair("exploreUrl", exploreUrl) + ) + } + + override fun editSource(sourceUrl: String) { + startActivity(Pair("data", sourceUrl)) + } + + override fun toTop(source: BookSource) { + viewModel.topSource(source) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/explore/ExploreViewModel.kt b/app/src/main/java/io/legado/app/ui/main/explore/ExploreViewModel.kt new file mode 100644 index 000000000..a3716b592 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/explore/ExploreViewModel.kt @@ -0,0 +1,18 @@ +package io.legado.app.ui.main.explore + +import android.app.Application +import io.legado.app.App +import io.legado.app.base.BaseViewModel +import io.legado.app.data.entities.BookSource + +class ExploreViewModel(application: Application) : BaseViewModel(application) { + + fun topSource(bookSource: BookSource) { + execute { + val minXh = App.db.bookSourceDao().minOrder + bookSource.customOrder = minXh - 1 + App.db.bookSourceDao().insert(bookSource) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/my/MyFragment.kt b/app/src/main/java/io/legado/app/ui/main/my/MyFragment.kt new file mode 100644 index 000000000..b2747f97e --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/my/MyFragment.kt @@ -0,0 +1,113 @@ +package io.legado.app.ui.main.my + +import android.content.SharedPreferences +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import io.legado.app.App +import io.legado.app.R +import io.legado.app.base.BaseFragment +import io.legado.app.help.BookHelp +import io.legado.app.help.permission.Permissions +import io.legado.app.help.permission.PermissionsCompat +import io.legado.app.help.storage.Backup +import io.legado.app.help.storage.WebDavHelp +import io.legado.app.lib.theme.ATH +import io.legado.app.ui.about.AboutActivity +import io.legado.app.ui.about.DonateActivity +import io.legado.app.ui.book.source.manage.BookSourceActivity +import io.legado.app.ui.config.ConfigActivity +import io.legado.app.ui.config.ConfigViewModel +import io.legado.app.ui.replacerule.ReplaceRuleActivity +import io.legado.app.utils.LogUtils +import io.legado.app.utils.startActivity +import kotlinx.android.synthetic.main.view_title_bar.* +import org.jetbrains.anko.startActivity + +class MyFragment : BaseFragment(R.layout.fragment_my_config) { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setSupportToolbar(toolbar) + val fragmentTag = "prefFragment" + var preferenceFragment = childFragmentManager.findFragmentByTag(fragmentTag) + if (preferenceFragment == null) preferenceFragment = PreferenceFragment() + childFragmentManager.beginTransaction().replace(R.id.pre_fragment, preferenceFragment, fragmentTag).commit() + } + + override fun onCompatCreateOptionsMenu(menu: Menu) { + menuInflater.inflate(R.menu.main_my, menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem) { + when (item.itemId) { + R.id.menu_help -> startActivity() + R.id.menu_backup -> PermissionsCompat.Builder(this) + .addPermissions(*Permissions.Group.STORAGE) + .rationale(R.string.tip_perm_request_storage) + .onGranted { Backup.backup() } + .request() + R.id.menu_restore -> PermissionsCompat.Builder(this) + .addPermissions(*Permissions.Group.STORAGE) + .rationale(R.string.tip_perm_request_storage) + .onGranted { WebDavHelp.showRestoreDialog(requireContext()) } + .request() + } + } + + class PreferenceFragment : PreferenceFragmentCompat(), + SharedPreferences.OnSharedPreferenceChangeListener { + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_main) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + ATH.applyEdgeEffectColor(listView) + } + + override fun onResume() { + super.onResume() + preferenceManager.sharedPreferences.registerOnSharedPreferenceChangeListener(this) + } + + override fun onPause() { + preferenceManager.sharedPreferences.unregisterOnSharedPreferenceChangeListener(this) + super.onPause() + } + + override fun onSharedPreferenceChanged( + sharedPreferences: SharedPreferences?, + key: String? + ) { + when (key) { + "isNightTheme" -> App.INSTANCE.applyDayNight() + "recordLog" -> LogUtils.upLevel() + "downloadPath" -> BookHelp.upDownloadPath() + } + } + + override fun onPreferenceTreeClick(preference: Preference?): Boolean { + when (preference?.key) { + "bookSourceManage" -> context?.startActivity() + "replaceManage" -> context?.startActivity() + "setting" -> context?.startActivity( + Pair("configType", ConfigViewModel.TYPE_CONFIG) + ) + "web_dav_setting" -> context?.startActivity( + Pair("configType", ConfigViewModel.TYPE_WEB_DAV_CONFIG) + ) + "theme_setting" -> context?.startActivity( + Pair("configType", ConfigViewModel.TYPE_THEME_CONFIG) + ) + "donate" -> context?.startActivity() + "about" -> context?.startActivity() + } + return super.onPreferenceTreeClick(preference) + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/rss/RssAdapter.kt b/app/src/main/java/io/legado/app/ui/main/rss/RssAdapter.kt new file mode 100644 index 000000000..b490c8ec8 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/rss/RssAdapter.kt @@ -0,0 +1,30 @@ +package io.legado.app.ui.main.rss + +import android.content.Context +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.SimpleRecyclerAdapter +import io.legado.app.data.entities.RssSource +import io.legado.app.help.ImageLoader +import kotlinx.android.synthetic.main.item_rss.view.* +import org.jetbrains.anko.sdk27.listeners.onClick + +class RssAdapter(context: Context, val callBack: CallBack) : + SimpleRecyclerAdapter(context, R.layout.item_rss) { + + override fun convert(holder: ItemViewHolder, item: RssSource, payloads: MutableList) { + with(holder.itemView) { + tv_name.text = item.sourceName + ImageLoader.load(context, item.sourceIcon) + .centerCrop() + .placeholder(R.drawable.image_rss) + .error(R.drawable.image_rss) + .setAsBitmap(iv_icon) + onClick { callBack.openRss(item) } + } + } + + interface CallBack { + fun openRss(rssSource: RssSource) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/rss/RssFragment.kt b/app/src/main/java/io/legado/app/ui/main/rss/RssFragment.kt new file mode 100644 index 000000000..13ac93c20 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/rss/RssFragment.kt @@ -0,0 +1,60 @@ +package io.legado.app.ui.main.rss + +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.GridLayoutManager +import io.legado.app.App +import io.legado.app.R +import io.legado.app.base.BaseFragment +import io.legado.app.data.entities.RssSource +import io.legado.app.lib.theme.ATH +import io.legado.app.ui.rss.article.RssArticlesActivity +import io.legado.app.ui.rss.source.manage.RssSourceActivity +import io.legado.app.utils.startActivity +import kotlinx.android.synthetic.main.fragment_rss.* +import kotlinx.android.synthetic.main.view_title_bar.* + +class RssFragment : BaseFragment(R.layout.fragment_rss), + RssAdapter.CallBack { + + private lateinit var adapter: RssAdapter + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setSupportToolbar(toolbar) + initRecyclerView() + initData() + } + + override fun onCompatCreateOptionsMenu(menu: Menu) { + menuInflater.inflate(R.menu.main_rss, menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem) { + super.onCompatOptionsItemSelected(item) + when (item.itemId) { + R.id.menu_rss_config -> startActivity() + R.id.menu_rss_star -> { + } + } + } + + private fun initRecyclerView() { + ATH.applyEdgeEffectColor(recycler_view) + adapter = RssAdapter(requireContext(), this) + recycler_view.layoutManager = GridLayoutManager(requireContext(), 4) + recycler_view.adapter = adapter + } + + private fun initData() { + App.db.rssSourceDao().liveEnabled().observe(viewLifecycleOwner, Observer { + adapter.setItems(it) + }) + } + + override fun openRss(rssSource: RssSource) { + startActivity(Pair("url", rssSource.sourceUrl)) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/qrcode/QrCodeActivity.kt b/app/src/main/java/io/legado/app/ui/qrcode/QrCodeActivity.kt new file mode 100644 index 000000000..b84cc3f6f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/qrcode/QrCodeActivity.kt @@ -0,0 +1,108 @@ +package io.legado.app.ui.qrcode + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import cn.bingoogolapple.qrcode.core.QRCodeView +import io.legado.app.R +import io.legado.app.base.BaseActivity +import io.legado.app.help.permission.Permissions +import io.legado.app.help.permission.PermissionsCompat +import io.legado.app.utils.FileUtils +import kotlinx.android.synthetic.main.activity_qrcode_capture.* +import kotlinx.android.synthetic.main.view_title_bar.* +import org.jetbrains.anko.toast + +class QrCodeActivity : BaseActivity(R.layout.activity_qrcode_capture), QRCodeView.Delegate { + + private val requestQrImage = 202 + private var flashlightIsOpen: Boolean = false + + override fun onActivityCreated(savedInstanceState: Bundle?) { + setSupportActionBar(toolbar) + zxingview.setDelegate(this) + fab_flashlight.setOnClickListener { + if (flashlightIsOpen) { + flashlightIsOpen = false + zxingview.closeFlashlight() + } else { + flashlightIsOpen = true + zxingview.openFlashlight() + } + } + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.qr_code_scan, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_choose_from_gallery -> { + val intent = Intent(Intent.ACTION_GET_CONTENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = "image/*" + startActivityForResult(intent, requestQrImage) + } + } + return super.onCompatOptionsItemSelected(item) + } + + override fun onStart() { + super.onStart() + startCamera() + } + + private fun startCamera() { + PermissionsCompat.Builder(this) + .addPermissions(*Permissions.Group.CAMERA) + .rationale(R.string.qr_per) + .onGranted { + zxingview.visibility = View.VISIBLE + zxingview.startSpotAndShowRect() // 显示扫描框,并开始识别 + }.request() + } + + override fun onStop() { + zxingview.stopCamera() // 关闭摄像头预览,并且隐藏扫描框 + super.onStop() + } + + override fun onDestroy() { + zxingview.onDestroy() // 销毁二维码扫描控件 + super.onDestroy() + } + + override fun onScanQRCodeSuccess(result: String) { + val intent = Intent() + intent.putExtra("result", result) + setResult(RESULT_OK, intent) + finish() + } + + override fun onCameraAmbientBrightnessChanged(isDark: Boolean) { + + } + + override fun onScanQRCodeOpenCameraError() { + toast("打开相机失败") + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + data?.data?.let { + zxingview.startSpotAndShowRect() // 显示扫描框,并开始识别 + + if (resultCode == Activity.RESULT_OK && requestCode == requestQrImage) { + val picturePath = FileUtils.getPath(this, it) + // 本来就用到 QRCodeView 时可直接调 QRCodeView 的方法,走通用的回调 + zxingview.decodeQRCode(picturePath) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/replacerule/DiffCallBack.kt b/app/src/main/java/io/legado/app/ui/replacerule/DiffCallBack.kt new file mode 100644 index 000000000..1824313b3 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/replacerule/DiffCallBack.kt @@ -0,0 +1,42 @@ +package io.legado.app.ui.replacerule + +import androidx.recyclerview.widget.DiffUtil +import io.legado.app.data.entities.ReplaceRule + +class DiffCallBack( + private val oldItems: List, + private val newItems: List +) : DiffUtil.Callback() { + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldItems[oldItemPosition] + val newItem = newItems[newItemPosition] + return oldItem.id == newItem.id + } + + override fun getOldListSize(): Int { + return oldItems.size + } + + override fun getNewListSize(): Int { + return newItems.size + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldItems[oldItemPosition] + val newItem = newItems[newItemPosition] + return oldItem.name == newItem.name + && oldItem.group == newItem.group + && oldItem.isEnabled == newItem.isEnabled + } + + override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? { + val oldItem = oldItems[oldItemPosition] + val newItem = newItems[newItemPosition] + return when { + oldItem.name == newItem.name + && oldItem.group == newItem.group + && oldItem.isEnabled != newItem.isEnabled -> 2 + else -> null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/replacerule/GroupManageDialog.kt b/app/src/main/java/io/legado/app/ui/replacerule/GroupManageDialog.kt new file mode 100644 index 000000000..2b2a84835 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/replacerule/GroupManageDialog.kt @@ -0,0 +1,140 @@ +package io.legado.app.ui.replacerule + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Bundle +import android.util.DisplayMetrics +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.App +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.SimpleRecyclerAdapter +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.dialogs.customView +import io.legado.app.lib.dialogs.noButton +import io.legado.app.lib.dialogs.yesButton +import io.legado.app.utils.applyTint +import io.legado.app.utils.getViewModelOfActivity +import io.legado.app.utils.requestInputMethod +import io.legado.app.utils.splitNotBlank +import kotlinx.android.synthetic.main.dialog_edit_text.view.* +import kotlinx.android.synthetic.main.dialog_recycler_view.* +import kotlinx.android.synthetic.main.item_group_manage.view.* +import org.jetbrains.anko.sdk27.listeners.onClick + +class GroupManageDialog : DialogFragment(), Toolbar.OnMenuItemClickListener { + private lateinit var viewModel: ReplaceRuleViewModel + private lateinit var adapter: GroupAdapter + + override fun onStart() { + super.onStart() + val dm = DisplayMetrics() + activity?.windowManager?.defaultDisplay?.getMetrics(dm) + dialog?.window?.setLayout((dm.widthPixels * 0.9).toInt(), (dm.heightPixels * 0.9).toInt()) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + viewModel = getViewModelOfActivity(ReplaceRuleViewModel::class.java) + return inflater.inflate(R.layout.dialog_recycler_view, container) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initData() + } + + private fun initData() { + tool_bar.title = getString(R.string.group_manage) + tool_bar.inflateMenu(R.menu.group_manage) + tool_bar.menu.applyTint(requireContext(), false) + tool_bar.setOnMenuItemClickListener(this) + adapter = GroupAdapter(requireContext()) + recycler_view.layoutManager = LinearLayoutManager(requireContext()) + recycler_view.addItemDecoration( + DividerItemDecoration(requireContext(), RecyclerView.VERTICAL) + ) + recycler_view.adapter = adapter + App.db.replaceRuleDao().liveGroup().observe(viewLifecycleOwner, Observer { + val groups = linkedSetOf() + it.map { group -> + groups.addAll(group.splitNotBlank(",", ";")) + } + adapter.setItems(groups.toList()) + }) + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.menu_add -> addGroup() + } + return true + } + + @SuppressLint("InflateParams") + private fun addGroup() { + alert(title = getString(R.string.add_group)) { + var editText: EditText? = null + customView { + layoutInflater.inflate(R.layout.dialog_edit_text, null).apply { + editText = edit_view.apply { + hint = "分组名称" + } + } + } + yesButton { + editText?.text?.toString()?.let { + if (it.isNotBlank()) { + viewModel.addGroup(it) + } + } + } + noButton() + }.show().applyTint().requestInputMethod() + } + + @SuppressLint("InflateParams") + private fun editGroup(group: String) { + alert(title = getString(R.string.group_edit)) { + var editText: EditText? = null + customView { + layoutInflater.inflate(R.layout.dialog_edit_text, null).apply { + editText = edit_view.apply { + hint = "分组名称" + setText(group) + } + } + } + yesButton { + viewModel.upGroup(group, editText?.text?.toString()) + } + noButton() + }.show().applyTint().requestInputMethod() + } + + private inner class GroupAdapter(context: Context) : + SimpleRecyclerAdapter(context, R.layout.item_group_manage) { + + override fun convert(holder: ItemViewHolder, item: String, payloads: MutableList) { + with(holder.itemView) { + tv_group.text = item + tv_edit.onClick { editGroup(item) } + tv_del.onClick { viewModel.delGroup(item) } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/replacerule/ReplaceRuleActivity.kt b/app/src/main/java/io/legado/app/ui/replacerule/ReplaceRuleActivity.kt index 304000c53..e335bfb1b 100644 --- a/app/src/main/java/io/legado/app/ui/replacerule/ReplaceRuleActivity.kt +++ b/app/src/main/java/io/legado/app/ui/replacerule/ReplaceRuleActivity.kt @@ -1,53 +1,160 @@ package io.legado.app.ui.replacerule import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity +import android.view.Menu +import android.view.MenuItem +import android.view.SubMenu +import androidx.appcompat.widget.SearchView +import androidx.core.content.ContextCompat import androidx.lifecycle.LiveData import androidx.lifecycle.Observer -import androidx.paging.LivePagedListBuilder -import androidx.paging.PagedList +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import io.legado.app.App import io.legado.app.R +import io.legado.app.base.VMBaseActivity import io.legado.app.data.entities.ReplaceRule +import io.legado.app.help.ItemTouchCallback +import io.legado.app.lib.theme.ATH +import io.legado.app.lib.theme.primaryTextColor +import io.legado.app.ui.replacerule.edit.ReplaceEditDialog +import io.legado.app.utils.getViewModel +import io.legado.app.utils.splitNotBlank import kotlinx.android.synthetic.main.activity_replace_rule.* -import org.jetbrains.anko.doAsync -import org.jetbrains.anko.toast +import kotlinx.android.synthetic.main.view_search.* -class ReplaceRuleActivity : AppCompatActivity() { +class ReplaceRuleActivity : VMBaseActivity(R.layout.activity_replace_rule), + SearchView.OnQueryTextListener, + ReplaceRuleAdapter.CallBack { + override val viewModel: ReplaceRuleViewModel + get() = getViewModel(ReplaceRuleViewModel::class.java) + private lateinit var adapter: ReplaceRuleAdapter - private var rulesLiveData: LiveData>? = null + private var groups = hashSetOf() + private var groupMenu: SubMenu? = null + private var replaceRuleLiveData: LiveData>? = null - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_replace_rule) - rv_replace_rule.layoutManager = LinearLayoutManager(this) + override fun onActivityCreated(savedInstanceState: Bundle?) { initRecyclerView() - initDataObservers() + initSearchView() + observeReplaceRuleData() + observeGroupData() + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.replace_rule, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onPrepareOptionsMenu(menu: Menu?): Boolean { + groupMenu = menu?.findItem(R.id.menu_group)?.subMenu + upGroupMenu() + return super.onPrepareOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_add_replace_rule -> + ReplaceEditDialog().show(supportFragmentManager, "replaceNew") + R.id.menu_group_manage -> + GroupManageDialog().show(supportFragmentManager, "groupManage") + R.id.menu_select_all -> adapter.selectAll() + R.id.menu_revert_selection -> adapter.revertSelection() + R.id.menu_enable_selection -> viewModel.enableSelection(adapter.getSelectionIds()) + R.id.menu_disable_selection -> viewModel.disableSelection(adapter.getSelectionIds()) + R.id.menu_del_selection -> viewModel.delSelection(adapter.getSelectionIds()) + } + return super.onCompatOptionsItemSelected(item) } private fun initRecyclerView() { - rv_replace_rule.layoutManager = LinearLayoutManager(this) - adapter = ReplaceRuleAdapter(this) - adapter.onClickListener = object: ReplaceRuleAdapter.OnClickListener { - override fun update(rule: ReplaceRule) { - doAsync { App.db.replaceRuleDao().update(rule) } - } - override fun delete(rule: ReplaceRule) { - doAsync { App.db.replaceRuleDao().delete(rule) } - } - override fun edit(rule: ReplaceRule) { - toast("Edit function not implemented!") + ATH.applyEdgeEffectColor(recycler_view) + recycler_view.layoutManager = LinearLayoutManager(this) + adapter = ReplaceRuleAdapter(this, this) + recycler_view.adapter = adapter + recycler_view.addItemDecoration( + DividerItemDecoration(this, DividerItemDecoration.VERTICAL).apply { + ContextCompat.getDrawable(baseContext, R.drawable.ic_divider)?.let { + this.setDrawable(it) + } + }) + val itemTouchCallback = ItemTouchCallback() + itemTouchCallback.onItemTouchCallbackListener = adapter + itemTouchCallback.isCanDrag = true + ItemTouchHelper(itemTouchCallback).attachToRecyclerView(recycler_view) + } + + private fun initSearchView() { + ATH.setTint(search_view, primaryTextColor) + search_view.onActionViewExpanded() + search_view.queryHint = getString(R.string.replace_purify_search) + search_view.clearFocus() + search_view.setOnQueryTextListener(this) + } + + private fun observeReplaceRuleData(key: String? = null) { + replaceRuleLiveData?.removeObservers(this) + replaceRuleLiveData = if (key.isNullOrEmpty()) { + App.db.replaceRuleDao().liveDataAll() + } else { + App.db.replaceRuleDao().liveDataSearch(key) + } + replaceRuleLiveData?.observe(this, Observer { + val diffResult = DiffUtil.calculateDiff(DiffCallBack(adapter.getItems(), it)) + adapter.setItems(it, false) + diffResult.dispatchUpdatesTo(adapter) + }) + } + + private fun observeGroupData() { + App.db.replaceRuleDao().liveGroup().observe(this, Observer { + groups.clear() + it.map { group -> + groups.addAll(group.splitNotBlank(",", ";")) } + upGroupMenu() + }) + } + + private fun upGroupMenu() { + groupMenu?.removeGroup(R.id.source_group) + groups.map { + groupMenu?.add(R.id.source_group, Menu.NONE, Menu.NONE, it) } - rv_replace_rule.adapter = adapter } - private fun initDataObservers() { - rulesLiveData?.removeObservers(this) - rulesLiveData = LivePagedListBuilder(App.db.replaceRuleDao().observeAll(), 30).build() - rulesLiveData?.observe(this, Observer> { adapter.submitList(it) }) + + override fun onQueryTextChange(newText: String?): Boolean { + observeReplaceRuleData("%$newText%") + return false + } + + override fun onQueryTextSubmit(query: String?): Boolean { + return false + } + + override fun update(vararg rule: ReplaceRule) { + viewModel.update(*rule) } + override fun delete(rule: ReplaceRule) { + viewModel.delete(rule) + } + + override fun edit(rule: ReplaceRule) { + ReplaceEditDialog + .newInstance(rule.id) + .show(supportFragmentManager, "editReplace") + } + + override fun toTop(rule: ReplaceRule) { + viewModel.toTop(rule) + } + + override fun upOrder() { + viewModel.upOrder() + } } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/replacerule/ReplaceRuleAdapter.kt b/app/src/main/java/io/legado/app/ui/replacerule/ReplaceRuleAdapter.kt index 717e2bfff..49f01a828 100644 --- a/app/src/main/java/io/legado/app/ui/replacerule/ReplaceRuleAdapter.kt +++ b/app/src/main/java/io/legado/app/ui/replacerule/ReplaceRuleAdapter.kt @@ -1,70 +1,125 @@ package io.legado.app.ui.replacerule import android.content.Context -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.isGone -import androidx.paging.PagedListAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView +import android.view.Menu +import android.widget.PopupMenu import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.SimpleRecyclerAdapter import io.legado.app.data.entities.ReplaceRule -import kotlinx.android.synthetic.main.item_relace_rule.view.* +import io.legado.app.help.ItemTouchCallback +import io.legado.app.lib.theme.backgroundColor +import kotlinx.android.synthetic.main.item_replace_rule.view.* import org.jetbrains.anko.sdk27.listeners.onClick -class ReplaceRuleAdapter(context: Context) : - PagedListAdapter(DIFF_CALLBACK) { +class ReplaceRuleAdapter(context: Context, var callBack: CallBack) : + SimpleRecyclerAdapter(context, R.layout.item_replace_rule), + ItemTouchCallback.OnItemTouchCallbackListener { - var onClickListener: OnClickListener? = null + private val selectedIds = linkedSetOf() - companion object { - - @JvmField - val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: ReplaceRule, newItem: ReplaceRule): Boolean = - oldItem.id == newItem.id - - override fun areContentsTheSame(oldItem: ReplaceRule, newItem: ReplaceRule): Boolean = - oldItem.id == newItem.id - && oldItem.pattern == newItem.pattern - && oldItem.replacement == newItem.replacement - && oldItem.isRegex == newItem.isRegex - && oldItem.isEnabled == newItem.isEnabled - && oldItem.scope == newItem.scope + fun selectAll() { + getItems().forEach { + selectedIds.add(it.id) } + notifyItemRangeChanged(0, itemCount, 1) } - init { - notifyDataSetChanged() + fun revertSelection() { + getItems().forEach { + if (selectedIds.contains(it.id)) { + selectedIds.remove(it.id) + } else { + selectedIds.add(it.id) + } + } + notifyItemRangeChanged(0, itemCount, 1) } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder { - return MyViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_relace_rule, parent, false)) + fun getSelectionIds(): LinkedHashSet { + val selection = linkedSetOf() + getItems().map { + if (selectedIds.contains(it.id)) { + selection.add(it.id) + } + } + return selection } - override fun onBindViewHolder(holder: MyViewHolder, pos: Int) { - getItem(pos)?.let { holder.bind(it, onClickListener, pos == itemCount - 1) } + override fun convert(holder: ItemViewHolder, item: ReplaceRule, payloads: MutableList) { + with(holder.itemView) { + if (payloads.isEmpty()) { + this.setBackgroundColor(context.backgroundColor) + if (item.group.isNullOrEmpty()) { + cb_name.text = item.name + } else { + cb_name.text = + String.format("%s (%s)", item.name, item.group) + } + swt_enabled.isChecked = item.isEnabled + swt_enabled.onClick { + item.isEnabled = swt_enabled.isChecked + callBack.update(item) + } + iv_edit.onClick { + callBack.edit(item) + } + cb_name.isChecked = selectedIds.contains(item.id) + cb_name.onClick { + if (cb_name.isChecked) { + selectedIds.add(item.id) + } else { + selectedIds.remove(item.id) + } + } + iv_menu_more.onClick { + val popupMenu = PopupMenu(context, it) + popupMenu.menu.add(Menu.NONE, R.id.menu_top, Menu.NONE, R.string.to_top) + popupMenu.menu.add(Menu.NONE, R.id.menu_del, Menu.NONE, R.string.delete) + popupMenu.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.menu_top -> callBack.toTop(item) + R.id.menu_del -> callBack.delete(item) + } + true + } + popupMenu.show() + } + } else { + when (payloads[0]) { + 1 -> cb_name.isChecked = selectedIds.contains(item.id) + 2 -> swt_enabled.isChecked = item.isEnabled + } + } + } } - class MyViewHolder(view: View) : RecyclerView.ViewHolder(view) { - fun bind(rule: ReplaceRule, listener: OnClickListener?, hideDivider: Boolean) = with(itemView) { - cb_enable.text = rule.name - cb_enable.isChecked = rule.isEnabled - divider.isGone = hideDivider - iv_delete.onClick { listener?.delete(rule) } - iv_edit.onClick { listener?.edit(rule) } - cb_enable.onClick { - rule.isEnabled = cb_enable.isChecked - listener?.update(rule) + override fun onMove(srcPosition: Int, targetPosition: Int): Boolean { + val srcItem = getItem(srcPosition) + val targetItem = getItem(targetPosition) + if (srcItem != null && targetItem != null) { + if (srcItem.order == targetItem.order) { + callBack.upOrder() + } else { + val srcOrder = srcItem.order + srcItem.order = targetItem.order + targetItem.order = srcOrder + callBack.update(srcItem, targetItem) } } + return true + } + + override fun onSwiped(adapterPosition: Int) { + } - interface OnClickListener { - fun update(rule: ReplaceRule) + interface CallBack { + fun update(vararg rule: ReplaceRule) fun delete(rule: ReplaceRule) fun edit(rule: ReplaceRule) + fun toTop(rule: ReplaceRule) + fun upOrder() } } diff --git a/app/src/main/java/io/legado/app/ui/replacerule/ReplaceRuleViewModel.kt b/app/src/main/java/io/legado/app/ui/replacerule/ReplaceRuleViewModel.kt new file mode 100644 index 000000000..c820749ef --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/replacerule/ReplaceRuleViewModel.kt @@ -0,0 +1,99 @@ +package io.legado.app.ui.replacerule + +import android.app.Application +import android.text.TextUtils +import io.legado.app.App +import io.legado.app.base.BaseViewModel +import io.legado.app.data.entities.ReplaceRule +import io.legado.app.utils.splitNotBlank + +class ReplaceRuleViewModel(application: Application) : BaseViewModel(application) { + + + fun update(vararg rule: ReplaceRule) { + execute { + App.db.replaceRuleDao().update(*rule) + } + } + + fun delete(rule: ReplaceRule) { + execute { + App.db.replaceRuleDao().delete(rule) + } + } + + fun toTop(rule: ReplaceRule) { + execute { + rule.order = App.db.replaceRuleDao().minOrder - 1 + App.db.replaceRuleDao().update(rule) + } + } + + fun upOrder() { + execute { + val rules = App.db.replaceRuleDao().all + for ((index: Int, rule: ReplaceRule) in rules.withIndex()) { + rule.order = index + 1 + } + App.db.replaceRuleDao().update(*rules.toTypedArray()) + } + } + + fun enableSelection(ids: LinkedHashSet) { + execute { + App.db.replaceRuleDao().enableSection(*ids.toLongArray()) + } + } + + fun disableSelection(ids: LinkedHashSet) { + execute { + App.db.replaceRuleDao().disableSection(*ids.toLongArray()) + } + } + + fun delSelection(ids: LinkedHashSet) { + execute { + App.db.replaceRuleDao().delSection(*ids.toLongArray()) + } + } + + fun addGroup(group: String) { + execute { + val sources = App.db.replaceRuleDao().noGroup + sources.map { source -> + source.group = group + } + App.db.replaceRuleDao().update(*sources.toTypedArray()) + } + } + + fun upGroup(oldGroup: String, newGroup: String?) { + execute { + val sources = App.db.replaceRuleDao().getByGroup(oldGroup) + sources.map { source -> + source.group?.splitNotBlank(",")?.toHashSet()?.let { + it.remove(oldGroup) + if (!newGroup.isNullOrEmpty()) + it.add(newGroup) + source.group = TextUtils.join(",", it) + } + } + App.db.replaceRuleDao().update(*sources.toTypedArray()) + } + } + + fun delGroup(group: String) { + execute { + execute { + val sources = App.db.replaceRuleDao().getByGroup(group) + sources.map { source -> + source.group?.splitNotBlank(",")?.toHashSet()?.let { + it.remove(group) + source.group = TextUtils.join(",", it) + } + } + App.db.replaceRuleDao().update(*sources.toTypedArray()) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/replacerule/edit/ReplaceEditDialog.kt b/app/src/main/java/io/legado/app/ui/replacerule/edit/ReplaceEditDialog.kt new file mode 100644 index 000000000..4b11ab39c --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/replacerule/edit/ReplaceEditDialog.kt @@ -0,0 +1,96 @@ +package io.legado.app.ui.replacerule.edit + +import android.os.Bundle +import android.util.DisplayMetrics +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.Observer +import io.legado.app.R +import io.legado.app.data.entities.ReplaceRule +import io.legado.app.utils.applyTint +import io.legado.app.utils.getViewModel +import kotlinx.android.synthetic.main.dialog_replace_edit.* + +class ReplaceEditDialog : DialogFragment(), + Toolbar.OnMenuItemClickListener { + + companion object { + + fun newInstance(id: Long? = null): ReplaceEditDialog { + val dialog = ReplaceEditDialog() + id?.let { + val bundle = Bundle() + bundle.putLong("id", id) + dialog.arguments = bundle + } + return dialog + } + } + + private lateinit var viewModel: ReplaceEditViewModel + + override fun onStart() { + super.onStart() + val dm = DisplayMetrics() + activity?.windowManager?.defaultDisplay?.getMetrics(dm) + dialog?.window?.setLayout((dm.widthPixels * 0.9).toInt(), WRAP_CONTENT) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + viewModel = getViewModel(ReplaceEditViewModel::class.java) + return inflater.inflate(R.layout.dialog_replace_edit, container) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + tool_bar.inflateMenu(R.menu.replace_edit) + tool_bar.menu.applyTint(requireContext(), false) + tool_bar.setOnMenuItemClickListener(this) + viewModel.replaceRuleData.observe(this, Observer { + upReplaceView(it) + }) + arguments?.let { + viewModel.initData(it) + } + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.menu_save -> { + viewModel.save(getReplaceRule()) { + dismiss() + } + } + } + return true + } + + private fun upReplaceView(replaceRule: ReplaceRule) { + et_name.setText(replaceRule.name) + et_group.setText(replaceRule.group) + et_replace_rule.setText(replaceRule.pattern) + cb_use_regex.isChecked = replaceRule.isRegex + et_replace_to.setText(replaceRule.replacement) + et_scope.setText(replaceRule.scope) + } + + private fun getReplaceRule(): ReplaceRule { + val replaceRule: ReplaceRule = viewModel.replaceRuleData.value ?: ReplaceRule() + replaceRule.name = et_name.text.toString() + replaceRule.group = et_group.text.toString() + replaceRule.pattern = et_group.text.toString() + replaceRule.isRegex = cb_use_regex.isChecked + replaceRule.replacement = et_replace_to.text.toString() + replaceRule.scope = et_scope.text.toString() + return replaceRule + } +} diff --git a/app/src/main/java/io/legado/app/ui/replacerule/edit/ReplaceEditViewModel.kt b/app/src/main/java/io/legado/app/ui/replacerule/edit/ReplaceEditViewModel.kt new file mode 100644 index 000000000..281a0ac2d --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/replacerule/edit/ReplaceEditViewModel.kt @@ -0,0 +1,38 @@ +package io.legado.app.ui.replacerule.edit + +import android.app.Application +import android.os.Bundle +import androidx.lifecycle.MutableLiveData +import io.legado.app.App +import io.legado.app.base.BaseViewModel +import io.legado.app.data.entities.ReplaceRule + +class ReplaceEditViewModel(application: Application) : BaseViewModel(application) { + + val replaceRuleData = MutableLiveData() + + fun initData(bundle: Bundle) { + execute { + replaceRuleData.value ?: let { + val id = bundle.getLong("id") + if (id > 0) { + App.db.replaceRuleDao().findById(id)?.let { + replaceRuleData.postValue(it) + } + } + } + } + } + + fun save(replaceRule: ReplaceRule, success: () -> Unit) { + execute { + if (replaceRule.order == 0) { + replaceRule.order = App.db.replaceRuleDao().maxOrder + 1 + } + App.db.replaceRuleDao().insert(replaceRule) + }.onSuccess { + success() + } + } + +} diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesActivity.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesActivity.kt new file mode 100644 index 000000000..6e3ac4318 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesActivity.kt @@ -0,0 +1,128 @@ +package io.legado.app.ui.rss.article + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.core.content.ContextCompat +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.App +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.data.entities.RssArticle +import io.legado.app.lib.theme.ATH +import io.legado.app.ui.rss.read.ReadRssActivity +import io.legado.app.ui.rss.source.edit.RssSourceEditActivity +import io.legado.app.utils.getViewModel +import kotlinx.android.synthetic.main.activity_rss_artivles.* +import kotlinx.android.synthetic.main.view_load_more.view.* +import kotlinx.android.synthetic.main.view_refresh_recycler.* +import org.jetbrains.anko.startActivity +import org.jetbrains.anko.startActivityForResult + +class RssArticlesActivity : VMBaseActivity(R.layout.activity_rss_artivles), + RssArticlesAdapter.CallBack { + + override val viewModel: RssArticlesViewModel + get() = getViewModel(RssArticlesViewModel::class.java) + + private val editSource = 12319 + private var adapter: RssArticlesAdapter? = null + private var rssArticlesData: LiveData>? = null + private var url: String? = null + private lateinit var loadMoreView: View + + override fun onActivityCreated(savedInstanceState: Bundle?) { + initView() + viewModel.titleLiveData.observe(this, Observer { + title_bar.title = it + }) + url = intent.getStringExtra("url") + url?.let { + initData(it) + } + refresh_recycler_view.startLoading() + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.rss_articles, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_edit_source -> viewModel.rssSource?.sourceUrl?.let { + startActivityForResult(editSource, Pair("data", it)) + } + R.id.menu_clear -> { + intent.getStringExtra("url")?.let { + refresh_progress_bar.isAutoLoading = true + viewModel.clear(it) { + refresh_progress_bar.isAutoLoading = false + } + } + } + } + return super.onCompatOptionsItemSelected(item) + } + + private fun initView() { + ATH.applyEdgeEffectColor(recycler_view) + recycler_view.layoutManager = LinearLayoutManager(this) + recycler_view.addItemDecoration( + DividerItemDecoration(this, DividerItemDecoration.VERTICAL).apply { + ContextCompat.getDrawable(baseContext, R.drawable.ic_divider)?.let { + this.setDrawable(it) + } + }) + adapter = RssArticlesAdapter(this, this) + recycler_view.adapter = adapter + loadMoreView = + LayoutInflater.from(this).inflate(R.layout.view_load_more, recycler_view, false) + adapter?.addFooterView(loadMoreView) + refresh_recycler_view.onRefreshStart = { + url?.let { + viewModel.loadContent(it) { + refresh_progress_bar.isAutoLoading = false + } + } + } + recycler_view.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + if (!recyclerView.canScrollVertically(1)) { + scrollToBottom() + } + } + }) + } + + private fun initData(origin: String) { + rssArticlesData?.removeObservers(this) + rssArticlesData = App.db.rssArticleDao().liveByOrigin(origin) + rssArticlesData?.observe(this, Observer { + adapter?.setItems(it) + }) + } + + private fun scrollToBottom() { + adapter?.let { + if (it.getActualItemCount() > 0) { + loadMoreView.rotate_loading.show() + } + } + } + + override fun readRss(rssArticle: RssArticle) { + viewModel.read(rssArticle) + startActivity( + Pair("origin", rssArticle.origin), + Pair("title", rssArticle.title) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter.kt new file mode 100644 index 000000000..03546d957 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter.kt @@ -0,0 +1,44 @@ +package io.legado.app.ui.rss.article + +import android.content.Context +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.SimpleRecyclerAdapter +import io.legado.app.data.entities.RssArticle +import io.legado.app.help.ImageLoader +import io.legado.app.utils.gone +import io.legado.app.utils.visible +import kotlinx.android.synthetic.main.item_rss_article.view.* +import org.jetbrains.anko.sdk27.listeners.onClick +import org.jetbrains.anko.textColorResource + + +class RssArticlesAdapter(context: Context, val callBack: CallBack) : + SimpleRecyclerAdapter(context, R.layout.item_rss_article) { + + override fun convert(holder: ItemViewHolder, item: RssArticle, payloads: MutableList) { + with(holder.itemView) { + tv_title.text = item.title + tv_pub_date.text = item.pubDate + onClick { + callBack.readRss(item) + } + if (item.image.isNullOrBlank()) { + image_view.gone() + } else { + image_view.visible() + ImageLoader.load(context, item.image) + .setAsBitmap(image_view) + } + if (item.read) { + tv_title.textColorResource = R.color.tv_text_summary + } else { + tv_title.textColorResource = R.color.tv_text_default + } + } + } + + interface CallBack { + fun readRss(rssArticle: RssArticle) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesViewModel.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesViewModel.kt new file mode 100644 index 000000000..603a24148 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesViewModel.kt @@ -0,0 +1,57 @@ +package io.legado.app.ui.rss.article + +import android.app.Application +import androidx.lifecycle.MutableLiveData +import io.legado.app.App +import io.legado.app.base.BaseViewModel +import io.legado.app.data.entities.RssArticle +import io.legado.app.data.entities.RssSource +import io.legado.app.model.Rss +import kotlinx.coroutines.Dispatchers.IO + + +class RssArticlesViewModel(application: Application) : BaseViewModel(application) { + var rssSource: RssSource? = null + val titleLiveData = MutableLiveData() + + fun loadContent(url: String, onFinally: () -> Unit) { + execute { + rssSource = App.db.rssSourceDao().getByKey(url) + rssSource?.let { + titleLiveData.postValue(it.sourceName) + } ?: let { + rssSource = RssSource(sourceUrl = url) + } + rssSource?.let { rssSource -> + Rss.getArticles(rssSource, this) + .onSuccess(IO) { + it?.let { + App.db.rssArticleDao().insert(*it.toTypedArray()) + } + }.onError { + toast(it.localizedMessage) + }.onFinally { + onFinally() + } + } + } + } + + fun read(rssArticle: RssArticle) { + execute { + rssArticle.read = true + App.db.rssArticleDao().update(rssArticle) + } + } + + fun clear(url: String, onFinally: () -> Unit) { + execute { + App.db.rssArticleDao().delete(url) + loadContent(url, onFinally) + } + } + + fun loadMore() { + + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/read/ReadRssActivity.kt b/app/src/main/java/io/legado/app/ui/rss/read/ReadRssActivity.kt new file mode 100644 index 000000000..fd493ee3b --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/read/ReadRssActivity.kt @@ -0,0 +1,98 @@ +package io.legado.app.ui.rss.read + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.webkit.WebSettings +import android.webkit.WebViewClient +import androidx.lifecycle.Observer +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.lib.theme.DrawableUtils +import io.legado.app.lib.theme.primaryTextColor +import io.legado.app.utils.NetworkUtils +import io.legado.app.utils.getViewModel +import kotlinx.android.synthetic.main.activity_rss_read.* + +class ReadRssActivity : VMBaseActivity(R.layout.activity_rss_read) { + + override val viewModel: ReadRssViewModel + get() = getViewModel(ReadRssViewModel::class.java) + + private var starMenuItem: MenuItem? = null + + override fun onActivityCreated(savedInstanceState: Bundle?) { + title = intent.getStringExtra("title") + initWebView() + initLiveData() + viewModel.initData(intent) + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.rss_read, menu) + starMenuItem = menu.findItem(R.id.menu_rss_star) + upStarMenu() + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_rss_star -> viewModel.rssArticleLiveData.value?.let { + it.star = !it.star + viewModel.upRssArticle(it) { upStarMenu() } + } + } + return super.onCompatOptionsItemSelected(item) + } + + private fun initWebView() { + webView.webViewClient = WebViewClient() + webView.settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + webView.settings.domStorageEnabled = true + } + + @SuppressLint("SetJavaScriptEnabled") + private fun initLiveData() { + viewModel.rssArticleLiveData.observe(this, Observer { upStarMenu() }) + viewModel.rssSourceLiveData.observe(this, Observer { + if (it.enableJs) { + webView.settings.javaScriptEnabled = true + } + }) + viewModel.contentLiveData.observe(this, Observer { content -> + viewModel.rssArticleLiveData.value?.let { + val url = NetworkUtils.getAbsoluteURL(it.origin, it.link ?: "") + if (viewModel.rssSourceLiveData.value?.loadWithBaseUrl == true) { + webView.loadDataWithBaseURL( + url, + "$content", + "text/html", + "utf-8", + url + ) + } else { + webView.loadData( + "$content", + "text/html", + "utf-8" + ) + } + } + }) + viewModel.urlLiveData.observe(this, Observer { + webView.loadUrl(it) + }) + } + + private fun upStarMenu() { + if (viewModel.rssArticleLiveData.value?.star == true) { + starMenuItem?.setIcon(R.drawable.ic_star) + starMenuItem?.setTitle(R.string.y_store_up) + } else { + starMenuItem?.setIcon(R.drawable.ic_star_border) + starMenuItem?.setTitle(R.string.w_store_up) + } + DrawableUtils.setTint(starMenuItem?.icon, primaryTextColor) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/read/ReadRssViewModel.kt b/app/src/main/java/io/legado/app/ui/rss/read/ReadRssViewModel.kt new file mode 100644 index 000000000..a8c0d2963 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/read/ReadRssViewModel.kt @@ -0,0 +1,77 @@ +package io.legado.app.ui.rss.read + +import android.app.Application +import android.content.Intent +import androidx.lifecycle.MutableLiveData +import io.legado.app.App +import io.legado.app.base.BaseViewModel +import io.legado.app.data.entities.RssArticle +import io.legado.app.data.entities.RssSource +import io.legado.app.model.analyzeRule.AnalyzeRule +import io.legado.app.model.analyzeRule.AnalyzeUrl +import io.legado.app.utils.NetworkUtils + +class ReadRssViewModel(application: Application) : BaseViewModel(application) { + var rssArticleLiveData = MutableLiveData() + val rssSourceLiveData = MutableLiveData() + val contentLiveData = MutableLiveData() + val urlLiveData = MutableLiveData() + + fun initData(intent: Intent) { + execute { + val origin = intent.getStringExtra("origin") + val title = intent.getStringExtra("title") + val rssSource = App.db.rssSourceDao().getByKey(origin) + rssSource?.let { + rssSourceLiveData.postValue(it) + } + if (origin != null && title != null) { + App.db.rssArticleDao().get(origin, title)?.let { rssArticle -> + rssArticleLiveData.postValue(rssArticle) + if (!rssArticle.description.isNullOrBlank()) { + contentLiveData.postValue(rssArticle.description) + } else { + rssSource?.let { + val ruleContent = rssSource.ruleContent + if (!ruleContent.isNullOrBlank()) { + loadContent(rssArticle, ruleContent) + } else { + loadUrl(rssArticle) + } + } ?: loadUrl(rssArticle) + } + } + } + } + } + + private fun loadUrl(rssArticle: RssArticle) { + rssArticle.link?.let { + urlLiveData.postValue(NetworkUtils.getAbsoluteURL(rssArticle.origin, it)) + } + } + + private fun loadContent(rssArticle: RssArticle, ruleContent: String) { + execute { + rssArticle.link?.let { + AnalyzeUrl(it, baseUrl = rssArticle.origin).getResponseAsync().await().body() + ?.let { body -> + AnalyzeRule().apply { + setContent(body, NetworkUtils.getAbsoluteURL(rssArticle.origin, it)) + getString(ruleContent)?.let { content -> + contentLiveData.postValue(content) + } + } + } + } + } + } + + fun upRssArticle(rssArticle: RssArticle, success: () -> Unit) { + execute { + App.db.rssArticleDao().update(rssArticle) + }.onSuccess { + success() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugActivity.kt b/app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugActivity.kt new file mode 100644 index 000000000..3c476c921 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugActivity.kt @@ -0,0 +1,16 @@ +package io.legado.app.ui.rss.source.debug + +import android.os.Bundle +import io.legado.app.R +import io.legado.app.base.BaseActivity + + +class RssSourceDebugActivity : BaseActivity(R.layout.activity_source_debug) { + + + override fun onActivityCreated(savedInstanceState: Bundle?) { + + } + + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditActivity.kt b/app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditActivity.kt new file mode 100644 index 000000000..a27b81025 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditActivity.kt @@ -0,0 +1,216 @@ +package io.legado.app.ui.rss.source.edit + +import android.app.Activity +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.graphics.Rect +import android.os.Bundle +import android.view.Gravity +import android.view.Menu +import android.view.MenuItem +import android.view.ViewTreeObserver +import android.widget.EditText +import android.widget.PopupWindow +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.constant.AppConst +import io.legado.app.data.entities.EditEntity +import io.legado.app.data.entities.RssSource +import io.legado.app.lib.theme.ATH +import io.legado.app.ui.rss.source.debug.RssSourceDebugActivity +import io.legado.app.ui.widget.KeyboardToolPop +import io.legado.app.utils.GSON +import io.legado.app.utils.getViewModel +import kotlinx.android.synthetic.main.activity_rss_source_edit.* +import org.jetbrains.anko.displayMetrics +import org.jetbrains.anko.startActivity +import org.jetbrains.anko.toast +import kotlin.math.abs + +class RssSourceEditActivity : + VMBaseActivity(R.layout.activity_rss_source_edit, false), + KeyboardToolPop.CallBack { + + private var mSoftKeyboardTool: PopupWindow? = null + private var mIsSoftKeyBoardShowing = false + + private val adapter = RssSourceEditAdapter() + private val sourceEntities: ArrayList = ArrayList() + + override val viewModel: RssSourceEditViewModel + get() = getViewModel(RssSourceEditViewModel::class.java) + + + override fun onActivityCreated(savedInstanceState: Bundle?) { + initView() + viewModel.sourceLiveData.observe(this, Observer { + upRecyclerView(it) + }) + viewModel.initData(intent) + } + + override fun onDestroy() { + super.onDestroy() + mSoftKeyboardTool?.dismiss() + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.source_edit, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_save -> { + getRssSource()?.let { + viewModel.save(it) { + setResult(Activity.RESULT_OK) + finish() + } + } + } + R.id.menu_debug_source -> { + getRssSource()?.let { + viewModel.save(it) { + startActivity(Pair("key", it.sourceUrl)) + } + } + } + R.id.menu_copy_source -> { + GSON.toJson(getRssSource())?.let { sourceStr -> + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager? + clipboard?.primaryClip = ClipData.newPlainText(null, sourceStr) + } + } + R.id.menu_paste_source -> viewModel.pasteSource() + } + return super.onCompatOptionsItemSelected(item) + } + + private fun initView() { + ATH.applyEdgeEffectColor(recycler_view) + mSoftKeyboardTool = KeyboardToolPop(this, AppConst.keyboardToolChars, this) + window.decorView.viewTreeObserver.addOnGlobalLayoutListener(KeyboardOnGlobalChangeListener()) + recycler_view.layoutManager = LinearLayoutManager(this) + recycler_view.adapter = adapter + } + + private fun upRecyclerView(rssSource: RssSource?) { + rssSource?.let { + cb_is_enable.isChecked = rssSource.enabled + cb_enable_js.isChecked = rssSource.enableJs + cb_enable_base_url.isChecked = rssSource.loadWithBaseUrl + } + sourceEntities.clear() + sourceEntities.apply { + add(EditEntity("sourceName", rssSource?.sourceName, R.string.rss_source_name)) + add(EditEntity("sourceUrl", rssSource?.sourceUrl, R.string.rss_source_url)) + add(EditEntity("sourceIcon", rssSource?.sourceIcon, R.string.rss_source_icon)) + add(EditEntity("sourceGroup", rssSource?.sourceGroup, R.string.rss_source_group)) + add(EditEntity("ruleArticles", rssSource?.ruleArticles, R.string.rss_rule_articles)) + add(EditEntity("ruleTitle", rssSource?.ruleTitle, R.string.rss_rule_title)) + add(EditEntity("rulePubDate", rssSource?.rulePubDate, R.string.rss_rule_date)) + add( + EditEntity( + "ruleCategories", + rssSource?.ruleCategories, + R.string.rss_rule_categories + ) + ) + add( + EditEntity( + "ruleDescription", + rssSource?.ruleDescription, + R.string.rss_rule_description + ) + ) + add(EditEntity("ruleImage", rssSource?.ruleImage, R.string.rss_rule_image)) + add(EditEntity("ruleLink", rssSource?.ruleLink, R.string.rss_rule_link)) + add(EditEntity("ruleContent", rssSource?.ruleContent, R.string.rss_rule_content)) + } + adapter.editEntities = sourceEntities + } + + private fun getRssSource(): RssSource? { + val source = viewModel.sourceLiveData.value ?: RssSource() + source.enabled = cb_is_enable.isChecked + source.enableJs = cb_enable_js.isChecked + source.loadWithBaseUrl = cb_enable_base_url.isChecked + sourceEntities.forEach { + when (it.key) { + "sourceName" -> source.sourceName = it.value ?: "" + "sourceUrl" -> source.sourceUrl = it.value ?: "" + "sourceIcon" -> source.sourceIcon = it.value ?: "" + "sourceGroup" -> source.sourceGroup = it.value + "ruleArticles" -> source.ruleArticles = it.value + "ruleTitle" -> source.ruleTitle = it.value + "rulePubDate" -> source.rulePubDate = it.value + "ruleCategories" -> source.ruleCategories = it.value + "ruleDescription" -> source.ruleDescription = it.value + "ruleImage" -> source.ruleImage = it.value + "ruleLink" -> source.ruleLink = it.value + "ruleContent" -> source.ruleContent = it.value + } + } + if (source.sourceName.isBlank() || source.sourceName.isBlank()) { + toast("名称或url不能为空") + return null + } + return source + } + + override fun sendText(text: String) { + if (text.isBlank()) return + val view = window.decorView.findFocus() + if (view is EditText) { + val start = view.selectionStart + val end = view.selectionEnd + val edit = view.editableText//获取EditText的文字 + if (start < 0 || start >= edit.length) { + edit.append(text) + } else { + edit.replace(start, end, text)//光标所在位置插入文字 + } + } + } + + private fun showKeyboardTopPopupWindow() { + mSoftKeyboardTool?.isShowing?.let { if (it) return } + if (!isFinishing) { + mSoftKeyboardTool?.showAtLocation(ll_content, Gravity.BOTTOM, 0, 0) + } + } + + private fun closePopupWindow() { + mSoftKeyboardTool?.let { + if (it.isShowing) { + it.dismiss() + } + } + } + + private inner class KeyboardOnGlobalChangeListener : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + val rect = Rect() + // 获取当前页面窗口的显示范围 + window.decorView.getWindowVisibleDisplayFrame(rect) + val screenHeight = this@RssSourceEditActivity.displayMetrics.heightPixels + val keyboardHeight = screenHeight - rect.bottom // 输入法的高度 + val preShowing = mIsSoftKeyBoardShowing + if (abs(keyboardHeight) > screenHeight / 5) { + mIsSoftKeyBoardShowing = true // 超过屏幕五分之一则表示弹出了输入法 + recycler_view.setPadding(0, 0, 0, 100) + showKeyboardTopPopupWindow() + } else { + mIsSoftKeyBoardShowing = false + recycler_view.setPadding(0, 0, 0, 0) + if (preShowing) { + closePopupWindow() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditAdapter.kt b/app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditAdapter.kt new file mode 100644 index 000000000..78e0740ef --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditAdapter.kt @@ -0,0 +1,85 @@ +package io.legado.app.ui.rss.source.edit + +import android.text.Editable +import android.text.TextWatcher +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.R +import io.legado.app.data.entities.EditEntity +import kotlinx.android.synthetic.main.item_source_edit.view.* + +class RssSourceEditAdapter : RecyclerView.Adapter() { + + var editEntities: ArrayList = ArrayList() + set(value) { + field = value + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder { + return MyViewHolder( + LayoutInflater.from( + parent.context + ).inflate(R.layout.item_source_edit, parent, false) + ) + } + + override fun onBindViewHolder(holder: MyViewHolder, position: Int) { + holder.bind(editEntities[position]) + } + + override fun getItemCount(): Int { + return editEntities.size + } + + class MyViewHolder(view: View) : RecyclerView.ViewHolder(view) { + fun bind(editEntity: EditEntity) = with(itemView) { + if (editText.getTag(R.id.tag1) == null) { + val listener = object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + editText.isCursorVisible = false + editText.isCursorVisible = true + editText.isFocusable = true + editText.isFocusableInTouchMode = true + } + + override fun onViewDetachedFromWindow(v: View) { + + } + } + editText.addOnAttachStateChangeListener(listener) + editText.setTag(R.id.tag1, listener) + } + editText.getTag(R.id.tag2)?.let { + if (it is TextWatcher) { + editText.removeTextChangedListener(it) + } + } + editText.setText(editEntity.value) + textInputLayout.hint = context.getString(editEntity.hint) + val textWatcher = object : TextWatcher { + override fun beforeTextChanged( + s: CharSequence, + start: Int, + count: Int, + after: Int + ) { + + } + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + + } + + override fun afterTextChanged(s: Editable?) { + editEntity.value = (s?.toString()) + } + } + editText.addTextChangedListener(textWatcher) + editText.setTag(R.id.tag2, textWatcher) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditViewModel.kt b/app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditViewModel.kt new file mode 100644 index 000000000..38fb18e98 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditViewModel.kt @@ -0,0 +1,67 @@ +package io.legado.app.ui.rss.source.edit + +import android.app.Application +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import androidx.lifecycle.MutableLiveData +import io.legado.app.App +import io.legado.app.base.BaseViewModel +import io.legado.app.data.entities.RssSource +import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonObject + +class RssSourceEditViewModel(application: Application) : BaseViewModel(application) { + + val sourceLiveData: MutableLiveData = MutableLiveData() + private var oldSourceUrl: String? = null + + fun initData(intent: Intent) { + execute { + val key = intent.getStringExtra("data") + var source: RssSource? = null + if (key != null) { + source = App.db.rssSourceDao().getByKey(key) + } + source?.let { + oldSourceUrl = it.sourceUrl + sourceLiveData.postValue(it) + } ?: let { + sourceLiveData.postValue(RssSource().apply { + customOrder = App.db.rssSourceDao().maxOrder + 1 + }) + } + } + } + + fun save(rssSource: RssSource, success: (() -> Unit)) { + execute { + oldSourceUrl?.let { + if (oldSourceUrl != rssSource.sourceUrl) { + App.db.rssSourceDao().delete(it) + } + } + oldSourceUrl = rssSource.sourceUrl + App.db.rssSourceDao().insert(rssSource) + }.onSuccess { + success() + }.onError { + toast(it.localizedMessage) + it.printStackTrace() + } + } + + fun pasteSource() { + execute { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager? + clipboard?.primaryClip?.let { + if (it.itemCount > 0) { + val json = it.getItemAt(0).text.toString() + GSON.fromJsonObject(json)?.let { source -> + sourceLiveData.postValue(source) + } ?: toast("格式不对") + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/source/manage/DiffCallBack.kt b/app/src/main/java/io/legado/app/ui/rss/source/manage/DiffCallBack.kt new file mode 100644 index 000000000..a623d55be --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/source/manage/DiffCallBack.kt @@ -0,0 +1,42 @@ +package io.legado.app.ui.rss.source.manage + +import androidx.recyclerview.widget.DiffUtil +import io.legado.app.data.entities.RssSource + +class DiffCallBack( + private val oldItems: List, + private val newItems: List +) : DiffUtil.Callback() { + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldItems[oldItemPosition] + val newItem = newItems[newItemPosition] + return oldItem.sourceUrl == newItem.sourceUrl + } + + override fun getOldListSize(): Int { + return oldItems.size + } + + override fun getNewListSize(): Int { + return newItems.size + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldItems[oldItemPosition] + val newItem = newItems[newItemPosition] + return oldItem.sourceName == newItem.sourceName + && oldItem.sourceGroup == newItem.sourceGroup + && oldItem.enabled == newItem.enabled + } + + override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? { + val oldItem = oldItems[oldItemPosition] + val newItem = newItems[newItemPosition] + return when { + oldItem.sourceName == newItem.sourceName + && oldItem.enabled != newItem.enabled -> 2 + else -> null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/source/manage/GroupManageDialog.kt b/app/src/main/java/io/legado/app/ui/rss/source/manage/GroupManageDialog.kt new file mode 100644 index 000000000..27c5fd45f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/source/manage/GroupManageDialog.kt @@ -0,0 +1,140 @@ +package io.legado.app.ui.rss.source.manage + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Bundle +import android.util.DisplayMetrics +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.App +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.SimpleRecyclerAdapter +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.dialogs.customView +import io.legado.app.lib.dialogs.noButton +import io.legado.app.lib.dialogs.yesButton +import io.legado.app.utils.applyTint +import io.legado.app.utils.getViewModelOfActivity +import io.legado.app.utils.requestInputMethod +import io.legado.app.utils.splitNotBlank +import kotlinx.android.synthetic.main.dialog_edit_text.view.* +import kotlinx.android.synthetic.main.dialog_recycler_view.* +import kotlinx.android.synthetic.main.item_group_manage.view.* +import org.jetbrains.anko.sdk27.listeners.onClick + +class GroupManageDialog : DialogFragment(), Toolbar.OnMenuItemClickListener { + private lateinit var viewModel: RssSourceViewModel + private lateinit var adapter: GroupAdapter + + override fun onStart() { + super.onStart() + val dm = DisplayMetrics() + activity?.windowManager?.defaultDisplay?.getMetrics(dm) + dialog?.window?.setLayout((dm.widthPixels * 0.9).toInt(), (dm.heightPixels * 0.9).toInt()) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + viewModel = getViewModelOfActivity(RssSourceViewModel::class.java) + return inflater.inflate(R.layout.dialog_recycler_view, container) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initData() + } + + private fun initData() { + tool_bar.title = getString(R.string.group_manage) + tool_bar.inflateMenu(R.menu.group_manage) + tool_bar.menu.applyTint(requireContext(), false) + tool_bar.setOnMenuItemClickListener(this) + adapter = GroupAdapter(requireContext()) + recycler_view.layoutManager = LinearLayoutManager(requireContext()) + recycler_view.addItemDecoration( + DividerItemDecoration(requireContext(), RecyclerView.VERTICAL) + ) + recycler_view.adapter = adapter + App.db.rssSourceDao().liveGroup().observe(viewLifecycleOwner, Observer { + val groups = linkedSetOf() + it.map { group -> + groups.addAll(group.splitNotBlank(",", ";")) + } + adapter.setItems(groups.toList()) + }) + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.menu_add -> addGroup() + } + return true + } + + @SuppressLint("InflateParams") + private fun addGroup() { + alert(title = getString(R.string.add_group)) { + var editText: EditText? = null + customView { + layoutInflater.inflate(R.layout.dialog_edit_text, null).apply { + editText = edit_view.apply { + hint = "分组名称" + } + } + } + yesButton { + editText?.text?.toString()?.let { + if (it.isNotBlank()) { + viewModel.addGroup(it) + } + } + } + noButton() + }.show().applyTint().requestInputMethod() + } + + @SuppressLint("InflateParams") + private fun editGroup(group: String) { + alert(title = getString(R.string.group_edit)) { + var editText: EditText? = null + customView { + layoutInflater.inflate(R.layout.dialog_edit_text, null).apply { + editText = edit_view.apply { + hint = "分组名称" + setText(group) + } + } + } + yesButton { + viewModel.upGroup(group, editText?.text?.toString()) + } + noButton() + }.show().applyTint().requestInputMethod() + } + + private inner class GroupAdapter(context: Context) : + SimpleRecyclerAdapter(context, R.layout.item_group_manage) { + + override fun convert(holder: ItemViewHolder, item: String, payloads: MutableList) { + with(holder.itemView) { + tv_group.text = item + tv_edit.onClick { editGroup(item) } + tv_del.onClick { viewModel.delGroup(item) } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceActivity.kt b/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceActivity.kt new file mode 100644 index 000000000..dce6b21cf --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceActivity.kt @@ -0,0 +1,237 @@ +package io.legado.app.ui.rss.source.manage + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.SubMenu +import androidx.appcompat.widget.SearchView +import androidx.core.content.ContextCompat +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.App +import io.legado.app.R +import io.legado.app.base.VMBaseActivity +import io.legado.app.data.entities.RssSource +import io.legado.app.help.ItemTouchCallback +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.dialogs.cancelButton +import io.legado.app.lib.dialogs.customView +import io.legado.app.lib.dialogs.okButton +import io.legado.app.lib.theme.ATH +import io.legado.app.lib.theme.primaryTextColor +import io.legado.app.lib.theme.view.ATEAutoCompleteTextView +import io.legado.app.ui.qrcode.QrCodeActivity +import io.legado.app.ui.rss.source.edit.RssSourceEditActivity +import io.legado.app.utils.* +import kotlinx.android.synthetic.main.activity_rss_source.* +import kotlinx.android.synthetic.main.dialog_edit_text.view.* +import kotlinx.android.synthetic.main.view_search.* +import kotlinx.android.synthetic.main.view_title_bar.* +import org.jetbrains.anko.startActivity +import org.jetbrains.anko.startActivityForResult + + +class RssSourceActivity : VMBaseActivity(R.layout.activity_rss_source), + RssSourceAdapter.CallBack { + + override val viewModel: RssSourceViewModel + get() = getViewModel(RssSourceViewModel::class.java) + + private val qrRequestCode = 101 + private val importSource = 13141 + private lateinit var adapter: RssSourceAdapter + private var sourceLiveData: LiveData>? = null + private var groups = hashSetOf() + private var groupMenu: SubMenu? = null + + override fun onActivityCreated(savedInstanceState: Bundle?) { + setSupportActionBar(toolbar) + initRecyclerView() + initSearchView() + initLiveDataGroup() + initLiveDataSource() + } + + override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.rss_source, menu) + return super.onCompatCreateOptionsMenu(menu) + } + + override fun onPrepareOptionsMenu(menu: Menu?): Boolean { + groupMenu = menu?.findItem(R.id.menu_group)?.subMenu + upGroupMenu() + return super.onPrepareOptionsMenu(menu) + } + + override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_add -> startActivity() + R.id.menu_select_all -> adapter.selectAll() + R.id.menu_revert_selection -> adapter.revertSelection() + R.id.menu_enable_selection -> viewModel.enableSelection(adapter.getSelectionIds()) + R.id.menu_disable_selection -> viewModel.disableSelection(adapter.getSelectionIds()) + R.id.menu_del_selection -> viewModel.delSelection(adapter.getSelectionIds()) + R.id.menu_import_source_local -> selectFile() + R.id.menu_import_source_onLine -> showImportDialog() + R.id.menu_import_source_qr -> startActivityForResult(qrRequestCode) + R.id.menu_group_manage -> GroupManageDialog() + .show(supportFragmentManager, "rssGroupManage") + } + if (item.groupId == R.id.source_group) { + search_view.setQuery(item.title, true) + } + return super.onCompatOptionsItemSelected(item) + } + + private fun initRecyclerView() { + ATH.applyEdgeEffectColor(recycler_view) + recycler_view.layoutManager = LinearLayoutManager(this) + recycler_view.addItemDecoration( + DividerItemDecoration(this, DividerItemDecoration.VERTICAL).apply { + ContextCompat.getDrawable(baseContext, R.drawable.ic_divider)?.let { + this.setDrawable(it) + } + }) + adapter = RssSourceAdapter(this, this) + recycler_view.adapter = adapter + val itemTouchCallback = ItemTouchCallback() + itemTouchCallback.onItemTouchCallbackListener = adapter + itemTouchCallback.isCanDrag = true + ItemTouchHelper(itemTouchCallback).attachToRecyclerView(recycler_view) + } + + private fun initSearchView() { + ATH.setTint(search_view, primaryTextColor) + search_view.onActionViewExpanded() + search_view.queryHint = getString(R.string.search_rss_source) + search_view.clearFocus() + search_view.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + return false + } + + override fun onQueryTextChange(newText: String?): Boolean { + initLiveDataSource(newText) + return false + } + }) + } + + private fun initLiveDataGroup() { + App.db.rssSourceDao().liveGroup().observe(this, Observer { + groups.clear() + it.map { group -> + groups.addAll(group.splitNotBlank(",", ";")) + } + upGroupMenu() + }) + } + + private fun upGroupMenu() { + groupMenu?.removeGroup(R.id.source_group) + groups.map { + groupMenu?.add(R.id.source_group, Menu.NONE, Menu.NONE, it) + } + } + + private fun initLiveDataSource(key: String? = null) { + sourceLiveData?.removeObservers(this) + sourceLiveData = + if (key.isNullOrBlank()) { + App.db.rssSourceDao().liveAll() + } else { + App.db.rssSourceDao().liveSearch("%$key%") + } + sourceLiveData?.observe(this, Observer { + val diffResult = DiffUtil + .calculateDiff(DiffCallBack(adapter.getItems(), it)) + adapter.setItems(it, false) + diffResult.dispatchUpdatesTo(adapter) + }) + } + + @SuppressLint("InflateParams") + private fun showImportDialog() { + val aCache = ACache.get(this, cacheDir = false) + val cacheUrls: MutableList = aCache + .getAsString("sourceUrl") + ?.splitNotBlank(",") + ?.toMutableList() ?: mutableListOf() + alert(titleResource = R.string.import_book_source_on_line) { + var editText: ATEAutoCompleteTextView? = null + customView { + layoutInflater.inflate(R.layout.dialog_edit_text, null).apply { + editText = edit_view + edit_view.setFilterValues(cacheUrls) { + cacheUrls.remove(it) + aCache.put("sourceUrl", cacheUrls.joinToString(",")) + } + } + } + okButton { + val text = editText?.text?.toString() + text?.let { + if (!cacheUrls.contains(it)) { + cacheUrls.add(0, it) + aCache.put("sourceUrl", cacheUrls.joinToString(",")) + } + viewModel.importSource(it) + } + } + cancelButton() + }.show().applyTint() + } + + private fun selectFile() { + val intent = Intent(Intent.ACTION_GET_CONTENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = "text/*"//设置类型 + startActivityForResult(intent, importSource) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + when (requestCode) { + importSource -> if (resultCode == Activity.RESULT_OK) { + data?.data?.let { + FileUtils.getPath(this, it)?.let { path -> + viewModel.importSourceFromFilePath(path) + } + } + } + qrRequestCode -> if (resultCode == RESULT_OK) { + data?.getStringExtra("result")?.let { + viewModel.importSource(it) + } + } + } + } + + override fun del(source: RssSource) { + viewModel.del(source) + } + + override fun edit(source: RssSource) { + startActivity(Pair("data", source.sourceUrl)) + } + + override fun update(vararg source: RssSource) { + viewModel.update(*source) + } + + override fun toTop(source: RssSource) { + viewModel.topSource(source) + } + + override fun upOrder() { + viewModel.upOrder() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceAdapter.kt b/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceAdapter.kt new file mode 100644 index 000000000..8dee3c485 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceAdapter.kt @@ -0,0 +1,123 @@ +package io.legado.app.ui.rss.source.manage + +import android.content.Context +import android.view.Menu +import android.widget.PopupMenu +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.SimpleRecyclerAdapter +import io.legado.app.data.entities.RssSource +import io.legado.app.help.ItemTouchCallback +import io.legado.app.lib.theme.backgroundColor +import kotlinx.android.synthetic.main.item_rss_source.view.* +import org.jetbrains.anko.sdk27.listeners.onClick + +class RssSourceAdapter(context: Context, val callBack: CallBack) : + SimpleRecyclerAdapter(context, R.layout.item_rss_source), + ItemTouchCallback.OnItemTouchCallbackListener { + + private val selectedIds = linkedSetOf() + + fun selectAll() { + getItems().forEach { + selectedIds.add(it.sourceUrl) + } + notifyItemRangeChanged(0, itemCount, 1) + } + + fun revertSelection() { + getItems().forEach { + if (selectedIds.contains(it.sourceUrl)) { + selectedIds.remove(it.sourceUrl) + } else { + selectedIds.add(it.sourceUrl) + } + } + notifyItemRangeChanged(0, itemCount, 1) + } + + fun getSelectionIds(): LinkedHashSet { + val selection = linkedSetOf() + getItems().map { + if (selectedIds.contains(it.sourceUrl)) { + selection.add(it.sourceUrl) + } + } + return selection + } + + override fun convert(holder: ItemViewHolder, item: RssSource, payloads: MutableList) { + with(holder.itemView) { + if (payloads.isEmpty()) { + this.setBackgroundColor(context.backgroundColor) + if (item.sourceGroup.isNullOrEmpty()) { + cb_source.text = item.sourceName + } else { + cb_source.text = + String.format("%s (%s)", item.sourceName, item.sourceGroup) + } + swt_enabled.isChecked = item.enabled + swt_enabled.onClick { + item.enabled = swt_enabled.isChecked + callBack.update(item) + } + cb_source.isChecked = selectedIds.contains(item.sourceUrl) + cb_source.setOnClickListener { + if (cb_source.isChecked) { + selectedIds.add(item.sourceUrl) + } else { + selectedIds.remove(item.sourceUrl) + } + } + iv_edit.onClick { callBack.edit(item) } + iv_menu_more.onClick { + val popupMenu = PopupMenu(context, it) + popupMenu.menu.add(Menu.NONE, R.id.menu_top, Menu.NONE, R.string.to_top) + popupMenu.menu.add(Menu.NONE, R.id.menu_del, Menu.NONE, R.string.delete) + popupMenu.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.menu_top -> callBack.toTop(item) + R.id.menu_del -> callBack.del(item) + } + true + } + popupMenu.show() + } + } else { + when (payloads[0]) { + 1 -> cb_source.isChecked = selectedIds.contains(item.sourceUrl) + 2 -> swt_enabled.isChecked = item.enabled + } + } + } + } + + + override fun onSwiped(adapterPosition: Int) { + + } + + override fun onMove(srcPosition: Int, targetPosition: Int): Boolean { + val srcItem = getItem(srcPosition) + val targetItem = getItem(targetPosition) + if (srcItem != null && targetItem != null) { + if (srcItem.customOrder == targetItem.customOrder) { + callBack.upOrder() + } else { + val srcOrder = srcItem.customOrder + srcItem.customOrder = targetItem.customOrder + targetItem.customOrder = srcOrder + callBack.update(srcItem, targetItem) + } + } + return true + } + + interface CallBack { + fun del(source: RssSource) + fun edit(source: RssSource) + fun update(vararg source: RssSource) + fun toTop(source: RssSource) + fun upOrder() + } +} diff --git a/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceViewModel.kt b/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceViewModel.kt new file mode 100644 index 000000000..79e1befe8 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceViewModel.kt @@ -0,0 +1,114 @@ +package io.legado.app.ui.rss.source.manage + +import android.app.Application +import android.text.TextUtils +import io.legado.app.App +import io.legado.app.base.BaseViewModel +import io.legado.app.data.entities.RssSource +import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonArray +import io.legado.app.utils.splitNotBlank +import java.io.File + +class RssSourceViewModel(application: Application) : BaseViewModel(application) { + + fun topSource(rssSource: RssSource) { + execute { + rssSource.customOrder = App.db.rssSourceDao().minOrder - 1 + App.db.rssSourceDao().insert(rssSource) + } + } + + fun del(rssSource: RssSource) { + execute { App.db.rssSourceDao().delete(rssSource) } + } + + fun update(vararg rssSource: RssSource) { + execute { App.db.rssSourceDao().update(*rssSource) } + } + + fun upOrder() { + execute { + val sources = App.db.rssSourceDao().all + for ((index: Int, source: RssSource) in sources.withIndex()) { + source.customOrder = index + 1 + } + App.db.rssSourceDao().update(*sources.toTypedArray()) + } + } + + fun enableSelection(ids: LinkedHashSet) { + execute { + App.db.rssSourceDao().enableSection(*ids.toTypedArray()) + } + } + + fun disableSelection(ids: LinkedHashSet) { + execute { + App.db.rssSourceDao().disableSection(*ids.toTypedArray()) + } + } + + fun delSelection(ids: LinkedHashSet) { + execute { + App.db.rssSourceDao().delSection(*ids.toTypedArray()) + } + } + + + fun addGroup(group: String) { + execute { + val sources = App.db.rssSourceDao().noGroup + sources.map { source -> + source.sourceGroup = group + } + App.db.rssSourceDao().update(*sources.toTypedArray()) + } + } + + fun upGroup(oldGroup: String, newGroup: String?) { + execute { + val sources = App.db.rssSourceDao().getByGroup(oldGroup) + sources.map { source -> + source.sourceGroup?.splitNotBlank(",")?.toHashSet()?.let { + it.remove(oldGroup) + if (!newGroup.isNullOrEmpty()) + it.add(newGroup) + source.sourceGroup = TextUtils.join(",", it) + } + } + App.db.rssSourceDao().update(*sources.toTypedArray()) + } + } + + fun delGroup(group: String) { + execute { + execute { + val sources = App.db.rssSourceDao().getByGroup(group) + sources.map { source -> + source.sourceGroup?.splitNotBlank(",")?.toHashSet()?.let { + it.remove(group) + source.sourceGroup = TextUtils.join(",", it) + } + } + App.db.rssSourceDao().update(*sources.toTypedArray()) + } + } + } + + + fun importSourceFromFilePath(path: String) { + execute { + val file = File(path) + if (file.exists()) { + GSON.fromJsonArray(file.readText())?.let { + App.db.rssSourceDao().insert(*it.toTypedArray()) + } + } + } + } + + fun importSource(sourceStr: String) { + + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/search/SearchActivity.kt b/app/src/main/java/io/legado/app/ui/search/SearchActivity.kt deleted file mode 100644 index 226c9207d..000000000 --- a/app/src/main/java/io/legado/app/ui/search/SearchActivity.kt +++ /dev/null @@ -1,19 +0,0 @@ -package io.legado.app.ui.search - -import android.os.Bundle -import io.legado.app.R -import io.legado.app.base.BaseActivity -import io.legado.app.utils.getViewModel - -class SearchActivity : BaseActivity() { - - override val viewModel: SearchViewModel - get() = getViewModel(SearchViewModel::class.java) - - override val layoutID: Int - get() = R.layout.activity_search - - override fun onViewModelCreated(viewModel: SearchViewModel, savedInstanceState: Bundle?) { - } - -} diff --git a/app/src/main/java/io/legado/app/ui/search/SearchAdapter.kt b/app/src/main/java/io/legado/app/ui/search/SearchAdapter.kt deleted file mode 100644 index a23ee7605..000000000 --- a/app/src/main/java/io/legado/app/ui/search/SearchAdapter.kt +++ /dev/null @@ -1,34 +0,0 @@ -package io.legado.app.ui.search - -import android.content.Context -import io.legado.app.R -import io.legado.app.base.adapter.ItemViewDelegate -import io.legado.app.base.adapter.ItemViewHolder -import io.legado.app.base.adapter.SimpleRecyclerAdapter -import io.legado.app.data.entities.SearchBook -import kotlinx.android.synthetic.main.item_search.view.* - -class SearchAdapter(context: Context) : SimpleRecyclerAdapter(context) { - - init { - addItemViewDelegate(TestItemDelegate(context)) - } - - override val layoutID: Int - get() = R.layout.item_search - - override fun convert(holder: ItemViewHolder, item: SearchBook, payloads: MutableList) { - holder.itemView.bookName.text = "我欲封天" - } - - internal class TestItemDelegate(context: Context) : ItemViewDelegate(context){ - override val layoutID: Int - get() = TODO("not implemented") //To change initializer of created properties use File | Settings | File Templates. - - override fun convert(holder: ItemViewHolder, item: SearchBook, payloads: MutableList) { - TODO("not implemented") //To change body of created functions use File | Settings | File Templates. - } - - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/search/SearchViewModel.kt b/app/src/main/java/io/legado/app/ui/search/SearchViewModel.kt deleted file mode 100644 index 0f9f2072b..000000000 --- a/app/src/main/java/io/legado/app/ui/search/SearchViewModel.kt +++ /dev/null @@ -1,39 +0,0 @@ -package io.legado.app.ui.search - -import android.app.Application -import android.util.Log -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import io.legado.app.base.BaseViewModel -import io.legado.app.data.api.CommonHttpApi -import io.legado.app.data.entities.SearchBook -import io.legado.app.help.http.HttpHelper -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.withContext - -class SearchViewModel(application: Application) : BaseViewModel(application) { - - val searchBooks: LiveData> = MutableLiveData() - - public fun search(start: () -> Unit, finally: () -> Unit) { - launchOnUI( - { - start() - val searchResponse = withContext(IO) { - HttpHelper.getApiService( - "http:www.baidu.com", - CommonHttpApi::class.java - ).get("", mutableMapOf()) - } - - val result = searchResponse.await() - }, - { Log.i("TAG", "${it.message}") }, - { finally() }) - -// GlobalScope.launch { -// -// } - } - -} diff --git a/app/src/main/java/io/legado/app/ui/welcome/WelcomeActivity.kt b/app/src/main/java/io/legado/app/ui/welcome/WelcomeActivity.kt new file mode 100644 index 000000000..77ef26284 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/welcome/WelcomeActivity.kt @@ -0,0 +1,50 @@ +package io.legado.app.ui.welcome + +import android.animation.Animator +import android.animation.ValueAnimator +import android.content.Intent +import android.os.Bundle +import io.legado.app.R +import io.legado.app.base.BaseActivity +import io.legado.app.lib.theme.accentColor +import io.legado.app.ui.main.MainActivity +import kotlinx.android.synthetic.main.activity_welcome.* +import org.jetbrains.anko.startActivity + +class WelcomeActivity : BaseActivity(R.layout.activity_welcome) { + + override fun onActivityCreated(savedInstanceState: Bundle?) { + iv_bg.setColorFilter(accentColor) + // 避免从桌面启动程序后,会重新实例化入口类的activity + if (intent.flags and Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT != 0) { + finish() + return + } + val welAnimator = ValueAnimator.ofFloat(1f, 0f).setDuration(800) + welAnimator.startDelay = 100 + welAnimator.addUpdateListener { animation -> + val alpha = animation.animatedValue as Float + iv_bg.alpha = alpha + } + welAnimator.addListener(object : Animator.AnimatorListener { + override fun onAnimationStart(animation: Animator) { + startActivity() + finish() + } + + override fun onAnimationEnd(animation: Animator) { + + } + + override fun onAnimationCancel(animation: Animator) { + + } + + override fun onAnimationRepeat(animation: Animator) { + + } + }) + welAnimator.start() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/BadgeView.kt b/app/src/main/java/io/legado/app/ui/widget/BadgeView.kt new file mode 100644 index 000000000..86e7e0629 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/BadgeView.kt @@ -0,0 +1,237 @@ +package io.legado.app.ui.widget + +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.RoundRectShape +import android.text.TextUtils +import android.util.AttributeSet +import android.util.TypedValue +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.FrameLayout.LayoutParams +import android.widget.TabWidget +import androidx.appcompat.widget.AppCompatTextView +import io.legado.app.R +import io.legado.app.lib.theme.accentColor +import io.legado.app.utils.gone +import io.legado.app.utils.visible + + +/** + * Created by milad heydari on 5/6/2016. + */ +class BadgeView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = android.R.attr.textViewStyle +) : AppCompatTextView(context, attrs, defStyle) { + + var isHideOnNull = true + set(hideOnNull) { + field = hideOnNull + text = text + } + private var radius: Float = 0.toFloat() + + val badgeCount: Int? + get() { + if (text == null) { + return null + } + val text = text.toString() + try { + return Integer.parseInt(text) + } catch (e: NumberFormatException) { + return null + } + + } + + var badgeGravity: Int + get() { + val params = layoutParams as LayoutParams + return params.gravity + } + set(gravity) { + val params = layoutParams as LayoutParams + params.gravity = gravity + layoutParams = params + } + + val badgeMargin: IntArray + get() { + val params = layoutParams as LayoutParams + return intArrayOf( + params.leftMargin, + params.topMargin, + params.rightMargin, + params.bottomMargin + ) + } + + init { + if (layoutParams !is LayoutParams) { + val layoutParams = LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + Gravity.CENTER + ) + setLayoutParams(layoutParams) + } + + // set default font + setTextColor(Color.WHITE) + //setTypeface(Typeface.DEFAULT_BOLD); + setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f) + setPadding(dip2Px(5f), dip2Px(1f), dip2Px(5f), dip2Px(1f)) + radius = 8f + + // set default background + setBackground(radius, context.accentColor) + + gravity = Gravity.CENTER + + // default values + isHideOnNull = true + setBadgeCount(0) + minWidth = dip2Px(16f) + minHeight = dip2Px(16f) + } + + fun setBackground(dipRadius: Float, badgeColor: Int) { + val radius = dip2Px(dipRadius) + val radiusArray = floatArrayOf( + radius.toFloat(), + radius.toFloat(), + radius.toFloat(), + radius.toFloat(), + radius.toFloat(), + radius.toFloat(), + radius.toFloat(), + radius.toFloat() + ) + + val roundRect = RoundRectShape(radiusArray, null, null) + val bgDrawable = ShapeDrawable(roundRect) + bgDrawable.paint.color = badgeColor + background = bgDrawable + } + + fun setBackground(badgeColor: Int) { + setBackground(radius, badgeColor) + } + + /** + * @see android.widget.TextView.setText + */ + override fun setText(text: CharSequence, type: BufferType) { + if (isHideOnNull && TextUtils.isEmpty(text)) { + gone() + } else { + visible() + } + super.setText(text, type) + } + + fun setBadgeCount(count: Int) { + text = count.toString() + if (count == 0) { + gone() + } else { + visible() + } + } + + fun setHighlight(highlight: Boolean) { + setBackground(resources.getColor(if (highlight) R.color.highlight else R.color.darker_gray)) + } + + fun setBadgeMargin(dipMargin: Int) { + setBadgeMargin(dipMargin, dipMargin, dipMargin, dipMargin) + } + + fun setBadgeMargin( + leftDipMargin: Int, + topDipMargin: Int, + rightDipMargin: Int, + bottomDipMargin: Int + ) { + val params = layoutParams as LayoutParams + params.leftMargin = dip2Px(leftDipMargin.toFloat()) + params.topMargin = dip2Px(topDipMargin.toFloat()) + params.rightMargin = dip2Px(rightDipMargin.toFloat()) + params.bottomMargin = dip2Px(bottomDipMargin.toFloat()) + layoutParams = params + } + + fun incrementBadgeCount(increment: Int) { + val count = badgeCount + if (count == null) { + setBadgeCount(increment) + } else { + setBadgeCount(increment + count) + } + } + + fun decrementBadgeCount(decrement: Int) { + incrementBadgeCount(-decrement) + } + + /** + * Attach the BadgeView to the TabWidget + * @param target the TabWidget to attach the BadgeView + * @param tabIndex index of the tab + */ + fun setTargetView(target: TabWidget, tabIndex: Int) { + val tabView = target.getChildTabViewAt(tabIndex) + setTargetView(tabView) + } + + /** + * Attach the BadgeView to the target view + * @param target the view to attach the BadgeView + */ + fun setTargetView(target: View?) { + if (parent != null) { + (parent as ViewGroup).removeView(this) + } + + if (target == null) { + return + } + + if (target.parent is FrameLayout) { + (target.parent as FrameLayout).addView(this) + + } else if (target.parent is ViewGroup) { + // use a new FrameLayout container for adding badge + val parentContainer = target.parent as ViewGroup + val groupIndex = parentContainer.indexOfChild(target) + parentContainer.removeView(target) + + val badgeContainer = FrameLayout(context) + val parentLayoutParams = target.layoutParams + + badgeContainer.layoutParams = parentLayoutParams + target.layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT + ) + + parentContainer.addView(badgeContainer, groupIndex, parentLayoutParams) + badgeContainer.addView(target) + + badgeContainer.addView(this) + } + + } + + /** + * converts dip to px + */ + private fun dip2Px(dip: Float): Int { + return (dip * context.resources.displayMetrics.density + 0.5f).toInt() + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/KeyboardToolPop.kt b/app/src/main/java/io/legado/app/ui/widget/KeyboardToolPop.kt new file mode 100644 index 000000000..40e321b87 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/KeyboardToolPop.kt @@ -0,0 +1,57 @@ +package io.legado.app.ui.widget + +import android.annotation.SuppressLint +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.PopupWindow +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.SimpleRecyclerAdapter +import kotlinx.android.synthetic.main.item_text.view.* +import kotlinx.android.synthetic.main.popup_keyboard_tool.view.* +import org.jetbrains.anko.sdk27.listeners.onClick + + +class KeyboardToolPop( + context: Context, + private val chars: List, + val callBack: CallBack? +) : PopupWindow(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) { + + init { + @SuppressLint("InflateParams") + this.contentView = LayoutInflater.from(context).inflate(R.layout.popup_keyboard_tool, null) + + isTouchable = true + isOutsideTouchable = false + isFocusable = false + inputMethodMode = INPUT_METHOD_NEEDED //解决遮盖输入法 + initRecyclerView() + } + + private fun initRecyclerView() = with(contentView) { + val adapter = Adapter(context) + recycler_view.layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false) + recycler_view.adapter = adapter + adapter.setItems(chars) + } + + inner class Adapter(context: Context) : + SimpleRecyclerAdapter(context, R.layout.item_text) { + + override fun convert(holder: ItemViewHolder, item: String, payloads: MutableList) { + with(holder.itemView) { + text_view.text = item + onClick { callBack?.sendText(item) } + } + } + } + + interface CallBack { + fun sendText(text: String) + } + +} diff --git a/app/src/main/java/io/legado/app/ui/widget/TitleBar.kt b/app/src/main/java/io/legado/app/ui/widget/TitleBar.kt index 548c22ec5..d516601d9 100644 --- a/app/src/main/java/io/legado/app/ui/widget/TitleBar.kt +++ b/app/src/main/java/io/legado/app/ui/widget/TitleBar.kt @@ -2,6 +2,7 @@ package io.legado.app.ui.widget import android.content.Context import android.content.res.ColorStateList +import android.graphics.Color import android.graphics.PorterDuff import android.graphics.drawable.Drawable import android.util.AttributeSet @@ -9,12 +10,19 @@ import android.view.Menu import android.view.View import androidx.annotation.ColorInt import androidx.annotation.StyleRes -import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.Toolbar import androidx.core.graphics.drawable.DrawableCompat import com.google.android.material.appbar.AppBarLayout import io.legado.app.R -import kotlinx.android.synthetic.main.view_titlebar.view.* +import io.legado.app.lib.theme.DrawableUtils +import io.legado.app.lib.theme.primaryColor +import io.legado.app.lib.theme.primaryTextColor +import io.legado.app.utils.activity +import io.legado.app.utils.getNavigationBarHeight +import io.legado.app.utils.getStatusBarHeight +import org.jetbrains.anko.backgroundColor +import org.jetbrains.anko.bottomPadding +import org.jetbrains.anko.topPadding class TitleBar(context: Context, attrs: AttributeSet?) : AppBarLayout(context, attrs) { @@ -22,32 +30,52 @@ class TitleBar(context: Context, attrs: AttributeSet?) : AppBarLayout(context, a val menu: Menu get() = toolbar.menu + var title: CharSequence? + get() = toolbar.title + set(title) { + toolbar.title = title + } + + var subtitle: CharSequence? + get() = toolbar.subtitle + set(subtitle) { + toolbar.subtitle = subtitle + } + + private val displayHomeAsUp: Boolean + private val navigationIconTint: ColorStateList? + private val navigationIconTintMode: Int + private val attachToActivity: Boolean + init { - inflate(context, R.layout.view_titlebar, this) + inflate(context, R.layout.view_title_bar, this) toolbar = findViewById(R.id.toolbar) val a = context.obtainStyledAttributes( attrs, R.styleable.TitleBar, R.attr.titleBarStyle, 0 ) + navigationIconTint = a.getColorStateList(R.styleable.TitleBar_navigationIconTint) + navigationIconTintMode = a.getInt(R.styleable.TitleBar_navigationIconTintMode, 9) + attachToActivity = a.getBoolean(R.styleable.TitleBar_attachToActivity, true) + displayHomeAsUp = a.getBoolean(R.styleable.TitleBar_displayHomeAsUp, true) + val navigationIcon = a.getDrawable(R.styleable.TitleBar_navigationIcon) - val navigationContentDescription = a.getText(R.styleable.TitleBar_navigationContentDescription) - val navigationIconTint = a.getColorStateList(R.styleable.TitleBar_navigationIconTint) - val navigationIconTintMode = a.getInt(R.styleable.TitleBar_navigationIconTintMode, 9) - val showNavigationIcon = a.getBoolean(R.styleable.TitleBar_showNavigationIcon, true) - val attachToActivity = a.getBoolean(R.styleable.TitleBar_attachToActivity, true) + val navigationContentDescription = + a.getText(R.styleable.TitleBar_navigationContentDescription) val titleText = a.getString(R.styleable.TitleBar_title) val subtitleText = a.getString(R.styleable.TitleBar_subtitle) - a.recycle() toolbar.apply { - if(showNavigationIcon){ - this.navigationIcon = navigationIcon + navigationIcon?.let { + this.navigationIcon = it this.navigationContentDescription = navigationContentDescription - wrapDrawableTint(this.navigationIcon, navigationIconTint, navigationIconTintMode) } if (a.hasValue(R.styleable.TitleBar_titleTextAppearance)) { - this.setTitleTextAppearance(context, a.getResourceId(R.styleable.TitleBar_titleTextAppearance, 0)) + this.setTitleTextAppearance( + context, + a.getResourceId(R.styleable.TitleBar_titleTextAppearance, 0) + ) } if (a.hasValue(R.styleable.TitleBar_titleTextColor)) { @@ -55,79 +83,126 @@ class TitleBar(context: Context, attrs: AttributeSet?) : AppBarLayout(context, a } if (a.hasValue(R.styleable.TitleBar_subtitleTextAppearance)) { - this.setSubtitleTextAppearance(context, a.getResourceId(R.styleable.TitleBar_subtitleTextAppearance, 0)) + this.setSubtitleTextAppearance( + context, + a.getResourceId(R.styleable.TitleBar_subtitleTextAppearance, 0) + ) } if (a.hasValue(R.styleable.TitleBar_subtitleTextColor)) { this.setSubtitleTextColor(a.getColor(R.styleable.TitleBar_subtitleTextColor, -0x1)) } - if(!titleText.isNullOrBlank()){ + + if (a.hasValue(R.styleable.TitleBar_contentInsetLeft) + || a.hasValue(R.styleable.TitleBar_contentInsetRight) + ) { + this.setContentInsetsAbsolute( + a.getDimensionPixelSize(R.styleable.TitleBar_contentInsetLeft, 0), + a.getDimensionPixelSize(R.styleable.TitleBar_contentInsetRight, 0) + ) + } + + if (a.hasValue(R.styleable.TitleBar_contentInsetStart) + || a.hasValue(R.styleable.TitleBar_contentInsetEnd) + ) { + this.setContentInsetsRelative( + a.getDimensionPixelSize(R.styleable.TitleBar_contentInsetStart, 0), + a.getDimensionPixelSize(R.styleable.TitleBar_contentInsetEnd, 0) + ) + } + + if (a.hasValue(R.styleable.TitleBar_contentInsetStartWithNavigation)) { + this.contentInsetStartWithNavigation = a.getDimensionPixelOffset( + R.styleable.TitleBar_contentInsetStartWithNavigation, 0 + ) + } + + if (a.hasValue(R.styleable.TitleBar_contentInsetEndWithActions)) { + this.contentInsetEndWithActions = a.getDimensionPixelOffset( + R.styleable.TitleBar_contentInsetEndWithActions, 0 + ) + } + + if (!titleText.isNullOrBlank()) { this.title = titleText } - if(!subtitleText.isNullOrBlank()){ + if (!subtitleText.isNullOrBlank()) { this.subtitle = subtitleText } + + if (a.hasValue(R.styleable.TitleBar_contentLayout)) { + inflate(context, a.getResourceId(R.styleable.TitleBar_contentLayout, 0), this) + } } + if (a.getBoolean(R.styleable.TitleBar_fitStatusBar, true)) { + topPadding = context.getStatusBarHeight() + } - if (attachToActivity) { - attachToActivity(context) + if (a.getBoolean(R.styleable.TitleBar_fitNavigationBar, false)) { + bottomPadding = context.getNavigationBarHeight() } + + backgroundColor = context.primaryColor + + a.recycle() } - fun setNavigationOnClickListener(clickListener: ((View) -> Unit)){ - toolbar.setNavigationOnClickListener(clickListener) + override fun onAttachedToWindow() { + super.onAttachedToWindow() + attachToActivity() } - fun setTitle(title: CharSequence?) { - toolbar.title = title + fun setNavigationOnClickListener(clickListener: ((View) -> Unit)) { + toolbar.setNavigationOnClickListener(clickListener) } fun setTitle(titleId: Int) { toolbar.setTitle(titleId) } - fun setSubTitle(subtitle: CharSequence?) { - toolbar.subtitle = subtitle - } - fun setSubTitle(subtitleId: Int) { toolbar.setSubtitle(subtitleId) } - fun setTitleTextColor(@ColorInt color: Int){ + fun setTitleTextColor(@ColorInt color: Int) { toolbar.setTitleTextColor(color) } - fun setTitleTextAppearance(@StyleRes resId: Int){ + fun setTitleTextAppearance(@StyleRes resId: Int) { toolbar.setTitleTextAppearance(context, resId) } - fun setSubTitleTextColor(@ColorInt color: Int){ + fun setSubTitleTextColor(@ColorInt color: Int) { toolbar.setSubtitleTextColor(color) } - fun setSubTitleTextAppearance(@StyleRes resId: Int){ + fun setSubTitleTextAppearance(@StyleRes resId: Int) { toolbar.setSubtitleTextAppearance(context, resId) } - private fun attachToActivity(context: Context) { - val activity = getCompatActivity(context) - activity?.let { - activity.setSupportActionBar(toolbar) - activity.supportActionBar?.setDisplayHomeAsUpEnabled(true) + private fun attachToActivity() { + if (attachToActivity) { + activity?.let { + it.setSupportActionBar(toolbar) + it.supportActionBar?.setDisplayHomeAsUpEnabled(displayHomeAsUp) + } } - } - private fun getCompatActivity(context: Context?): AppCompatActivity? { - if (context == null) return null - return when (context) { - is AppCompatActivity -> context - is androidx.appcompat.view.ContextThemeWrapper -> getCompatActivity(context.baseContext) - is android.view.ContextThemeWrapper -> getCompatActivity(context.baseContext) - else -> null + val primaryTextColor = if (isInEditMode) Color.BLACK else context.primaryTextColor + DrawableUtils.setTint(toolbar.overflowIcon, primaryTextColor) + toolbar.setTitleTextColor(primaryTextColor) + + if (navigationIconTint != null) { + wrapDrawableTint(toolbar.navigationIcon, navigationIconTint, navigationIconTintMode) + } else { + wrapDrawableTint( + toolbar.navigationIcon, + ColorStateList.valueOf(primaryTextColor), + navigationIconTintMode + ) } } diff --git a/app/src/main/java/io/legado/app/ui/widget/anima/RefreshProgressBar.kt b/app/src/main/java/io/legado/app/ui/widget/anima/RefreshProgressBar.kt new file mode 100644 index 000000000..df123bfcd --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/anima/RefreshProgressBar.kt @@ -0,0 +1,198 @@ +package io.legado.app.ui.widget.anima + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.RectF +import android.os.Looper +import android.util.AttributeSet +import android.view.View + +import io.legado.app.R + +class RefreshProgressBar @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + private var a = 1 + private var durProgress = 0 + private var secondDurProgress = 0 + var maxProgress = 100 + var secondMaxProgress = 100 + var bgColor = 0x00000000 + var secondColor = -0x3e3e3f + var fontColor = -0xc9c9ca + var speed = 2 + var secondFinalProgress = 0 + private set + private var paint: Paint = Paint() + private val bgRect = Rect() + private val secondRect = Rect() + private val fontRectF = RectF() + + var isAutoLoading: Boolean = false + set(loading) { + field = loading + if (!loading) { + secondDurProgress = 0 + secondFinalProgress = 0 + } + maxProgress = 0 + + invalidate() + } + + init { + paint.style = Paint.Style.FILL + + val a = context.obtainStyledAttributes(attrs, R.styleable.RefreshProgressBar) + speed = a.getDimensionPixelSize(R.styleable.RefreshProgressBar_speed, speed) + maxProgress = a.getInt(R.styleable.RefreshProgressBar_max_progress, maxProgress) + durProgress = a.getInt(R.styleable.RefreshProgressBar_dur_progress, durProgress) + secondDurProgress = a.getDimensionPixelSize( + R.styleable.RefreshProgressBar_second_dur_progress, + secondDurProgress + ) + secondFinalProgress = secondDurProgress + secondMaxProgress = a.getDimensionPixelSize( + R.styleable.RefreshProgressBar_second_max_progress, + secondMaxProgress + ) + bgColor = a.getColor(R.styleable.RefreshProgressBar_bg_color, bgColor) + secondColor = a.getColor(R.styleable.RefreshProgressBar_second_color, secondColor) + fontColor = a.getColor(R.styleable.RefreshProgressBar_font_color, fontColor) + a.recycle() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + paint.color = bgColor + bgRect.set(0, 0, measuredWidth, measuredHeight) + canvas.drawRect(bgRect, paint) + + if (secondDurProgress > 0 && secondMaxProgress > 0) { + var secondDur = secondDurProgress + if (secondDur < 0) { + secondDur = 0 + } + if (secondDur > secondMaxProgress) { + secondDur = secondMaxProgress + } + paint.color = secondColor + val tempW = + (measuredWidth.toFloat() * 1.0f * (secondDur * 1.0f / secondMaxProgress)).toInt() + secondRect.set( + measuredWidth / 2 - tempW / 2, + 0, + measuredWidth / 2 + tempW / 2, + measuredHeight + ) + canvas.drawRect(secondRect, paint) + } + + if (durProgress > 0 && maxProgress > 0) { + paint.color = fontColor + fontRectF.set( + 0f, + 0f, + measuredWidth.toFloat() * 1.0f * (durProgress * 1.0f / maxProgress), + measuredHeight.toFloat() + ) + canvas.drawRect(fontRectF, paint) + } + + if (this.isAutoLoading) { + if (secondDurProgress >= secondMaxProgress) { + a = -1 + } else if (secondDurProgress <= 0) { + a = 1 + } + secondDurProgress += a * speed + if (secondDurProgress < 0) + secondDurProgress = 0 + else if (secondDurProgress > secondMaxProgress) + secondDurProgress = secondMaxProgress + secondFinalProgress = secondDurProgress + invalidate() + } else { + if (secondDurProgress != secondFinalProgress) { + if (secondDurProgress > secondFinalProgress) { + secondDurProgress -= speed + if (secondDurProgress < secondFinalProgress) { + secondDurProgress = secondFinalProgress + } + } else { + secondDurProgress += speed + if (secondDurProgress > secondFinalProgress) { + secondDurProgress = secondFinalProgress + } + } + this.invalidate() + } + } + } + + fun getDurProgress(): Int { + return durProgress + } + + fun setDurProgress(durProgress: Int) { + var durProgress1 = durProgress + if (durProgress1 < 0) { + durProgress1 = 0 + } + if (durProgress1 > maxProgress) { + durProgress1 = maxProgress + } + this.durProgress = durProgress1 + if (Looper.myLooper() == Looper.getMainLooper()) { + this.invalidate() + } else { + this.postInvalidate() + } + } + + fun getSecondDurProgress(): Int { + return secondDurProgress + } + + fun setSecondDurProgress(secondDur: Int) { + this.secondDurProgress = secondDur + this.secondFinalProgress = secondDurProgress + if (Looper.myLooper() == Looper.getMainLooper()) { + this.invalidate() + } else { + this.postInvalidate() + } + } + + fun setSecondDurProgressWithAnim(secondDur: Int) { + var secondDur1 = secondDur + if (secondDur1 < 0) { + secondDur1 = 0 + } + if (secondDur1 > secondMaxProgress) { + secondDur1 = secondMaxProgress + } + this.secondFinalProgress = secondDur1 + if (Looper.myLooper() == Looper.getMainLooper()) { + this.invalidate() + } else { + this.postInvalidate() + } + } + + fun clean() { + durProgress = 0 + secondDurProgress = 0 + secondFinalProgress = 0 + if (Looper.myLooper() == Looper.getMainLooper()) { + this.invalidate() + } else { + this.postInvalidate() + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/anima/RotateLoading.kt b/app/src/main/java/io/legado/app/ui/widget/anima/RotateLoading.kt new file mode 100644 index 000000000..82df0124f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/anima/RotateLoading.kt @@ -0,0 +1,221 @@ +package io.legado.app.ui.widget.anima + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.util.AttributeSet +import android.view.View +import io.legado.app.R +import io.legado.app.utils.dp + +/** + * RotateLoading + * Created by Victor on 2015/4/28. + */ +class RotateLoading : View { + + private lateinit var mPaint: Paint + + private var loadingRectF: RectF? = null + private var shadowRectF: RectF? = null + + private var topDegree = 10 + private var bottomDegree = 190 + + private var arc: Float = 0.toFloat() + + private var thisWidth: Int = 0 + + private var changeBigger = true + + private var shadowPosition: Int = 0 + + var isStarted = false + private set + + var loadingColor: Int = 0 + set(value) { + field = value + invalidate() + } + + private var speedOfDegree: Int = 0 + + private var speedOfArc: Float = 0.toFloat() + + private val shown = Runnable { this.startInternal() } + + private val hidden = Runnable { this.stopInternal() } + + constructor(context: Context) : super(context) { + initView(context, null) + } + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { + initView(context, attrs) + } + + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { + initView(context, attrs) + } + + private fun initView(context: Context, attrs: AttributeSet?) { + loadingColor = Color.WHITE + thisWidth = DEFAULT_WIDTH.dp + shadowPosition = DEFAULT_SHADOW_POSITION.dp + speedOfDegree = DEFAULT_SPEED_OF_DEGREE + + if (null != attrs) { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.RotateLoading) + loadingColor = typedArray.getColor(R.styleable.RotateLoading_loading_color, Color.WHITE) + thisWidth = typedArray.getDimensionPixelSize( + R.styleable.RotateLoading_loading_width, + DEFAULT_WIDTH.dp + ) + shadowPosition = typedArray.getInt(R.styleable.RotateLoading_shadow_position, DEFAULT_SHADOW_POSITION) + speedOfDegree = typedArray.getInt(R.styleable.RotateLoading_loading_speed, DEFAULT_SPEED_OF_DEGREE) + typedArray.recycle() + } + speedOfArc = (speedOfDegree / 4).toFloat() + mPaint = Paint() + mPaint.color = loadingColor + mPaint.isAntiAlias = true + mPaint.style = Paint.Style.STROKE + mPaint.strokeWidth = thisWidth.toFloat() + mPaint.strokeCap = Paint.Cap.ROUND + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + + arc = 10f + + loadingRectF = + RectF( + (2 * thisWidth).toFloat(), + (2 * thisWidth).toFloat(), + (w - 2 * thisWidth).toFloat(), + (h - 2 * thisWidth).toFloat() + ) + shadowRectF = RectF( + (2 * thisWidth + shadowPosition).toFloat(), + (2 * thisWidth + shadowPosition).toFloat(), + (w - 2 * thisWidth + shadowPosition).toFloat(), + (h - 2 * thisWidth + shadowPosition).toFloat() + ) + } + + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + if (!isStarted) { + return + } + + mPaint.color = Color.parseColor("#1a000000") + shadowRectF?.let { + canvas.drawArc(it, topDegree.toFloat(), arc, false, mPaint) + canvas.drawArc(it, bottomDegree.toFloat(), arc, false, mPaint) + } + + mPaint.color = loadingColor + loadingRectF?.let { + canvas.drawArc(it, topDegree.toFloat(), arc, false, mPaint) + canvas.drawArc(it, bottomDegree.toFloat(), arc, false, mPaint) + } + + topDegree += speedOfDegree + bottomDegree += speedOfDegree + if (topDegree > 360) { + topDegree -= 360 + } + if (bottomDegree > 360) { + bottomDegree -= 360 + } + + if (changeBigger) { + if (arc < 160) { + arc += speedOfArc + invalidate() + } + } else { + if (arc > speedOfDegree) { + arc -= 2 * speedOfArc + invalidate() + } + } + if (arc >= 160 || arc <= 10) { + changeBigger = !changeBigger + invalidate() + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (visibility == VISIBLE) { + startInternal() + } + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + isStarted = false + animate().cancel() + removeCallbacks(shown) + removeCallbacks(hidden) + } + + fun show() { + removeCallbacks(shown) + removeCallbacks(hidden) + post(shown) + } + + fun hide() { + removeCallbacks(shown) + removeCallbacks(hidden) + post(hidden) + } + + private fun startInternal() { + startAnimator() + + isStarted = true + invalidate() + } + + private fun stopInternal() { + stopAnimator() + invalidate() + } + + private fun startAnimator() { + animate().cancel() + animate().scaleX(1.0f) + .scaleY(1.0f) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationStart(animation: Animator) { + visibility = VISIBLE + } + }) + .start() + } + + private fun stopAnimator() { + animate().cancel() + isStarted = false + visibility = GONE + } + + companion object { + private const val DEFAULT_WIDTH = 6 + private const val DEFAULT_SHADOW_POSITION = 2 + private const val DEFAULT_SPEED_OF_DEGREE = 10 + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/ExplosionAnimator.kt b/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/ExplosionAnimator.kt new file mode 100644 index 000000000..28bb1602a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/ExplosionAnimator.kt @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2015 tyrantgit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.legado.app.ui.widget.anima.explosion_field + +import android.animation.ValueAnimator +import android.graphics.* +import android.view.View +import android.view.animation.AccelerateInterpolator +import java.util.* + +class ExplosionAnimator(private val mContainer: View, bitmap: Bitmap, bound: Rect) : + ValueAnimator() { + private val mPaint: Paint + private val mParticles: Array + private val mBound: Rect + + init { + mPaint = Paint() + mBound = Rect(bound) + val partLen = 15 + mParticles = arrayOfNulls(partLen * partLen) + val random = Random(System.currentTimeMillis()) + val w = bitmap.width / (partLen + 2) + val h = bitmap.height / (partLen + 2) + for (i in 0 until partLen) { + for (j in 0 until partLen) { + mParticles[i * partLen + j] = + generateParticle(bitmap.getPixel((j + 1) * w, (i + 1) * h), random) + } + } + setFloatValues(0f, END_VALUE) + interpolator = DEFAULT_INTERPOLATOR + duration = DEFAULT_DURATION + } + + private fun generateParticle(color: Int, random: Random): Particle { + val particle = Particle() + particle.color = color + particle.radius = V + if (random.nextFloat() < 0.2f) { + particle.baseRadius = V + (X - V) * random.nextFloat() + } else { + particle.baseRadius = W + (V - W) * random.nextFloat() + } + val nextFloat = random.nextFloat() + particle.top = mBound.height() * (0.18f * random.nextFloat() + 0.2f) + particle.top = + if (nextFloat < 0.2f) particle.top else particle.top + particle.top * 0.2f * random.nextFloat() + particle.bottom = mBound.height() * (random.nextFloat() - 0.5f) * 1.8f + var f = + if (nextFloat < 0.2f) particle.bottom else if (nextFloat < 0.8f) particle.bottom * 0.6f else particle.bottom * 0.3f + particle.bottom = f + particle.mag = 4.0f * particle.top / particle.bottom + particle.neg = -particle.mag / particle.bottom + f = mBound.centerX() + Y * (random.nextFloat() - 0.5f) + particle.baseCx = f + particle.cx = f + f = mBound.centerY() + Y * (random.nextFloat() - 0.5f) + particle.baseCy = f + particle.cy = f + particle.life = END_VALUE / 10 * random.nextFloat() + particle.overflow = 0.4f * random.nextFloat() + particle.alpha = 1f + return particle + } + + fun draw(canvas: Canvas): Boolean { + if (!isStarted) { + return false + } + for (particle in mParticles) { + particle?.let { + particle.advance(animatedValue as Float) + if (particle.alpha > 0f) { + mPaint.color = particle.color + mPaint.alpha = (Color.alpha(particle.color) * particle.alpha).toInt() + canvas.drawCircle(particle.cx, particle.cy, particle.radius, mPaint) + } + } + } + mContainer.invalidate() + return true + } + + override fun start() { + super.start() + mContainer.invalidate(mBound) + } + + private inner class Particle { + internal var alpha: Float = 0.toFloat() + internal var color: Int = 0 + internal var cx: Float = 0.toFloat() + internal var cy: Float = 0.toFloat() + internal var radius: Float = 0.toFloat() + internal var baseCx: Float = 0.toFloat() + internal var baseCy: Float = 0.toFloat() + internal var baseRadius: Float = 0.toFloat() + internal var top: Float = 0.toFloat() + internal var bottom: Float = 0.toFloat() + internal var mag: Float = 0.toFloat() + internal var neg: Float = 0.toFloat() + internal var life: Float = 0.toFloat() + internal var overflow: Float = 0.toFloat() + + + fun advance(factor: Float) { + var f = 0f + var normalization = factor / END_VALUE + if (normalization < life || normalization > 1f - overflow) { + alpha = 0f + return + } + normalization = (normalization - life) / (1f - life - overflow) + val f2 = normalization * END_VALUE + if (normalization >= 0.7f) { + f = (normalization - 0.7f) / 0.3f + } + alpha = 1f - f + f = bottom * f2 + cx = baseCx + f + cy = (baseCy - this.neg * Math.pow(f.toDouble(), 2.0)).toFloat() - f * mag + radius = V + (baseRadius - V) * f2 + } + } + + companion object { + + internal var DEFAULT_DURATION: Long = 0x400 + private val DEFAULT_INTERPOLATOR = AccelerateInterpolator(0.6f) + private val END_VALUE = 1.4f + private val X = Utils.dp2Px(5).toFloat() + private val Y = Utils.dp2Px(20).toFloat() + private val V = Utils.dp2Px(2).toFloat() + private val W = Utils.dp2Px(1).toFloat() + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/ExplosionField.kt b/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/ExplosionField.kt new file mode 100644 index 000000000..1332a3969 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/ExplosionField.kt @@ -0,0 +1,191 @@ +/* + * Copyright (C) 2015 tyrantgit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.legado.app.ui.widget.anima.explosion_field + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator +import android.app.Activity +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Rect +import android.media.MediaPlayer +import android.util.AttributeSet +import android.util.Log +import android.view.View +import android.view.ViewGroup +import android.view.Window +import java.util.* + + +class ExplosionField : View { + + private var customDuration = ExplosionAnimator.DEFAULT_DURATION + private var idPlayAnimationEffect = 0 + private var mZAnimatorListener: OnAnimatorListener? = null + private var mOnClickListener: View.OnClickListener? = null + + private val mExplosions = ArrayList() + private val mExpandInset = IntArray(2) + + constructor(context: Context) : super(context) { + init() + } + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { + init() + } + + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super( + context, + attrs, + defStyleAttr + ) { + init() + } + + private fun init() { + + Arrays.fill(mExpandInset, Utils.dp2Px(32)) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + for (explosion in mExplosions) { + explosion.draw(canvas) + } + } + + fun playSoundAnimationEffect(id: Int) { + this.idPlayAnimationEffect = id + } + + fun setCustomDuration(customDuration: Long) { + this.customDuration = customDuration + } + + fun addActionEvent(ievents: OnAnimatorListener) { + this.mZAnimatorListener = ievents + } + + + fun expandExplosionBound(dx: Int, dy: Int) { + mExpandInset[0] = dx + mExpandInset[1] = dy + } + + @JvmOverloads + fun explode(bitmap: Bitmap?, bound: Rect, startDelay: Long, view: View? = null) { + val currentDuration = customDuration + val explosion = ExplosionAnimator(this, bitmap!!, bound) + explosion.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + mExplosions.remove(animation) + if (view != null) { + view.scaleX = 1f + view.scaleY = 1f + view.alpha = 1f + view.setOnClickListener(mOnClickListener)//set event + + } + } + }) + explosion.startDelay = startDelay + explosion.duration = currentDuration + mExplosions.add(explosion) + explosion.start() + } + + @JvmOverloads + fun explode(view: View, restartState: Boolean? = false) { + + val r = Rect() + view.getGlobalVisibleRect(r) + val location = IntArray(2) + getLocationOnScreen(location) + // getLocationInWindow(location); + // view.getLocationInWindow(location); + r.offset(-location[0], -location[1]) + r.inset(-mExpandInset[0], -mExpandInset[1]) + val startDelay = 100 + val animator = ValueAnimator.ofFloat(0f, 1f).setDuration(150) + animator.addUpdateListener(object : ValueAnimator.AnimatorUpdateListener { + + internal var random = Random() + + override fun onAnimationUpdate(animation: ValueAnimator) { + view.translationX = (random.nextFloat() - 0.5f) * view.width.toFloat() * 0.05f + view.translationY = (random.nextFloat() - 0.5f) * view.height.toFloat() * 0.05f + } + }) + + animator.addListener(object : Animator.AnimatorListener { + override fun onAnimationStart(animator: Animator) { + if (idPlayAnimationEffect != 0) + MediaPlayer.create(context, idPlayAnimationEffect).start() + } + + override fun onAnimationEnd(animator: Animator) { + if (mZAnimatorListener != null) { + mZAnimatorListener!!.onAnimationEnd(animator, this@ExplosionField) + } + } + + override fun onAnimationCancel(animator: Animator) { + Log.i("PRUEBA", "CANCEL") + } + + override fun onAnimationRepeat(animator: Animator) { + Log.i("PRUEBA", "REPEAT") + } + }) + + animator.start() + view.animate().setDuration(150).setStartDelay(startDelay.toLong()).scaleX(0f).scaleY(0f) + .alpha(0f).start() + if (restartState!!) + explode(Utils.createBitmapFromView(view), r, startDelay.toLong(), view) + else + explode(Utils.createBitmapFromView(view), r, startDelay.toLong()) + + } + + fun clear() { + mExplosions.clear() + invalidate() + } + + override fun setOnClickListener(mOnClickListener: View.OnClickListener?) { + this.mOnClickListener = mOnClickListener + } + + companion object { + + fun attach2Window(activity: Activity): ExplosionField { + val rootView = activity.findViewById(Window.ID_ANDROID_CONTENT) as ViewGroup + val explosionField = ExplosionField(activity) + rootView.addView( + explosionField, ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT + ) + ) + return explosionField + } + } + + +} diff --git a/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/OnAnimatorListener.kt b/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/OnAnimatorListener.kt new file mode 100644 index 000000000..13a04c670 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/OnAnimatorListener.kt @@ -0,0 +1,8 @@ +package io.legado.app.ui.widget.anima.explosion_field + +import android.animation.Animator +import android.view.View + +interface OnAnimatorListener { + fun onAnimationEnd(animator: Animator, view: View) +} diff --git a/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/Utils.kt b/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/Utils.kt new file mode 100644 index 000000000..6e33941fb --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/anima/explosion_field/Utils.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2015 tyrantgit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.legado.app.ui.widget.anima.explosion_field + + +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.view.View +import android.widget.ImageView +import kotlin.math.roundToInt + +object Utils { + + private val DENSITY = Resources.getSystem().displayMetrics.density + private val sCanvas = Canvas() + + fun dp2Px(dp: Int): Int { + return (dp * DENSITY).roundToInt() + } + + fun createBitmapFromView(view: View): Bitmap? { + if (view is ImageView) { + val drawable = view.drawable + if (drawable != null && drawable is BitmapDrawable) { + return drawable.bitmap + } + } + view.clearFocus() + val bitmap = createBitmapSafely( + view.width, + view.height, Bitmap.Config.ARGB_8888, 1 + ) + if (bitmap != null) { + synchronized(sCanvas) { + val canvas = sCanvas + canvas.setBitmap(bitmap) + view.draw(canvas) + canvas.setBitmap(null) + } + } + return bitmap + } + + private fun createBitmapSafely( + width: Int, + height: Int, + config: Bitmap.Config, + retryCount: Int + ): Bitmap? { + try { + return Bitmap.createBitmap(width, height, config) + } catch (e: OutOfMemoryError) { + e.printStackTrace() + if (retryCount > 0) { + System.gc() + return createBitmapSafely(width, height, config, retryCount - 1) + } + return null + } + + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/dynamiclayout/DynamicFrameLayout.kt b/app/src/main/java/io/legado/app/ui/widget/dynamiclayout/DynamicFrameLayout.kt index 2b25fe55a..99e0cd702 100644 --- a/app/src/main/java/io/legado/app/ui/widget/dynamiclayout/DynamicFrameLayout.kt +++ b/app/src/main/java/io/legado/app/ui/widget/dynamiclayout/DynamicFrameLayout.kt @@ -4,7 +4,6 @@ import android.content.Context import android.graphics.drawable.Drawable import android.util.AttributeSet import android.view.View -import android.view.ViewGroup import android.widget.FrameLayout import android.widget.ProgressBar import androidx.appcompat.widget.AppCompatButton @@ -61,38 +60,6 @@ class DynamicFrameLayout(context: Context, attrs: AttributeSet?) : FrameLayout(c } } - override fun addView(child: View) { - if (childCount > 2) { - throw IllegalStateException("DynamicFrameLayout can host only one direct child") - } - - super.addView(child) - } - - override fun addView(child: View, index: Int) { - if (childCount > 2) { - throw IllegalStateException("DynamicFrameLayout can host only one direct child") - } - - super.addView(child, index) - } - - override fun addView(child: View, params: ViewGroup.LayoutParams) { - if (childCount > 2) { - throw IllegalStateException("DynamicFrameLayout can host only one direct child") - } - - super.addView(child, params) - } - - override fun addView(child: View, index: Int, params: ViewGroup.LayoutParams) { - if (childCount > 2) { - throw IllegalStateException("DynamicFrameLayout can host only one direct child") - } - - super.addView(child, index, params) - } - override fun showErrorView(message: CharSequence) { ensureErrorView() diff --git a/app/src/main/java/io/legado/app/ui/widget/dynamiclayout/ViewSwitcher.kt b/app/src/main/java/io/legado/app/ui/widget/dynamiclayout/ViewSwitcher.kt index b985d79f8..dbcc0f132 100644 --- a/app/src/main/java/io/legado/app/ui/widget/dynamiclayout/ViewSwitcher.kt +++ b/app/src/main/java/io/legado/app/ui/widget/dynamiclayout/ViewSwitcher.kt @@ -5,12 +5,12 @@ import androidx.annotation.StringRes interface ViewSwitcher { - companion object{ - const val SHOW_CONTENT_VIEW = 0 - const val SHOW_ERROR_VIEW = 1 - const val SHOW_EMPTY_VIEW = 2 - const val SHOW_PROGRESS_VIEW = 3 - } + companion object { + const val SHOW_CONTENT_VIEW = 0 + const val SHOW_ERROR_VIEW = 1 + const val SHOW_EMPTY_VIEW = 2 + const val SHOW_PROGRESS_VIEW = 3 + } @Retention(AnnotationRetention.SOURCE) @IntDef(SHOW_CONTENT_VIEW, SHOW_ERROR_VIEW, SHOW_EMPTY_VIEW, SHOW_PROGRESS_VIEW) diff --git a/app/src/main/java/io/legado/app/ui/widget/font/FontAdapter.kt b/app/src/main/java/io/legado/app/ui/widget/font/FontAdapter.kt new file mode 100644 index 000000000..44f4f9974 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/font/FontAdapter.kt @@ -0,0 +1,34 @@ +package io.legado.app.ui.widget.font + +import android.content.Context +import android.graphics.Typeface +import io.legado.app.R +import io.legado.app.base.adapter.ItemViewHolder +import io.legado.app.base.adapter.SimpleRecyclerAdapter +import io.legado.app.utils.invisible +import io.legado.app.utils.visible +import kotlinx.android.synthetic.main.item_font.view.* +import org.jetbrains.anko.sdk27.listeners.onClick +import java.io.File + +class FontAdapter(context: Context, val callBack: CallBack) : + SimpleRecyclerAdapter(context, R.layout.item_font) { + + override fun convert(holder: ItemViewHolder, item: File, payloads: MutableList) = + with(holder.itemView) { + val typeface = Typeface.createFromFile(item) + tv_font.typeface = typeface + tv_font.text = item.name + this.onClick { callBack.onClick(item) } + if (item.absolutePath == callBack.curFilePath()) { + iv_checked.visible() + } else { + iv_checked.invisible() + } + } + + interface CallBack { + fun onClick(file: File) + fun curFilePath(): String + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/font/FontSelectDialog.kt b/app/src/main/java/io/legado/app/ui/widget/font/FontSelectDialog.kt new file mode 100644 index 000000000..b276072b4 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/font/FontSelectDialog.kt @@ -0,0 +1,86 @@ +package io.legado.app.ui.widget.font + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Environment +import android.view.LayoutInflater +import android.view.View +import androidx.appcompat.app.AlertDialog +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.R +import io.legado.app.lib.dialogs.AlertBuilder +import io.legado.app.lib.dialogs.alert +import io.legado.app.utils.applyTint +import io.legado.app.utils.invisible +import io.legado.app.utils.visible +import kotlinx.android.synthetic.main.dialog_font_select.view.* +import java.io.File + +class FontSelectDialog(context: Context) : FontAdapter.CallBack { + + private val defaultFolder = + Environment.getExternalStorageDirectory().absolutePath + File.separator + "Fonts" + private lateinit var adapter: FontAdapter + private var builder: AlertBuilder + private var dialog: AlertDialog? = null + @SuppressLint("InflateParams") + private var view: View = LayoutInflater.from(context).inflate(R.layout.dialog_font_select, null) + var curPath: String? = null + var fontFolder: String? = null + var defaultFont: (() -> Unit)? = null + var selectFile: ((path: String) -> Unit)? = null + + init { + builder = context.alert(title = context.getString(R.string.select_font)) { + customView = view + positiveButton(R.string.default_font) { defaultFont?.invoke() } + negativeButton(R.string.cancel) + } + initData() + } + + fun show() { + dialog = builder.show().applyTint() + } + + private fun initData() = with(view) { + adapter = FontAdapter(context, this@FontSelectDialog) + recycler_view.layoutManager = LinearLayoutManager(context) + recycler_view.adapter = adapter + val files = getFontFiles() + if (files.isNullOrEmpty()) { + tv_no_data.visible() + } else { + tv_no_data.invisible() + adapter.setItems(files.toList()) + } + } + + @SuppressLint("DefaultLocale") + private fun getFontFiles(): Array? { + val path = if (fontFolder.isNullOrEmpty()) { + defaultFolder + } else fontFolder + return try { + val file = File(path) + file.listFiles { pathName -> + pathName.name.toLowerCase().matches(".*\\.[ot]tf".toRegex()) + } + } catch (e: Exception) { + null + } + } + + override fun onClick(file: File) { + file.absolutePath.let { + if (it != curPath) { + selectFile?.invoke(it) + dialog?.dismiss() + } + } + } + + override fun curFilePath(): String { + return curPath ?: "" + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/image/CircleImageView.kt b/app/src/main/java/io/legado/app/ui/widget/image/CircleImageView.kt new file mode 100644 index 000000000..31a23f623 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/image/CircleImageView.kt @@ -0,0 +1,386 @@ +package io.legado.app.ui.widget.image + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.* +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Build +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import android.view.ViewOutlineProvider +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.RequiresApi +import androidx.appcompat.widget.AppCompatImageView +import io.legado.app.R +import io.legado.app.utils.getCompatColor +import kotlin.math.min +import kotlin.math.pow + +class CircleImageView : AppCompatImageView { + + private val mDrawableRect = RectF() + private val mBorderRect = RectF() + + private val mShaderMatrix = Matrix() + private val mBitmapPaint = Paint() + private val mBorderPaint = Paint() + private val mCircleBackgroundPaint = Paint() + + private var mBorderColor = DEFAULT_BORDER_COLOR + private var mBorderWidth = DEFAULT_BORDER_WIDTH + private var mCircleBackgroundColor = DEFAULT_CIRCLE_BACKGROUND_COLOR + + private var mBitmap: Bitmap? = null + private var mBitmapShader: BitmapShader? = null + private var mBitmapWidth: Int = 0 + private var mBitmapHeight: Int = 0 + + private var mDrawableRadius: Float = 0.toFloat() + private var mBorderRadius: Float = 0.toFloat() + + private var mColorFilter: ColorFilter? = null + + private var mReady: Boolean = false + private var mSetupPending: Boolean = false + private var mBorderOverlay: Boolean = false + var isDisableCircularTransformation: Boolean = false + set(disableCircularTransformation) { + if (isDisableCircularTransformation == disableCircularTransformation) { + return + } + + field = disableCircularTransformation + initializeBitmap() + } + + var borderColor: Int + get() = mBorderColor + set(@ColorInt borderColor) { + if (borderColor == mBorderColor) { + return + } + + mBorderColor = borderColor + mBorderPaint.color = mBorderColor + invalidate() + } + + var circleBackgroundColor: Int + get() = mCircleBackgroundColor + set(@ColorInt circleBackgroundColor) { + if (circleBackgroundColor == mCircleBackgroundColor) { + return + } + + mCircleBackgroundColor = circleBackgroundColor + mCircleBackgroundPaint.color = circleBackgroundColor + invalidate() + } + + var borderWidth: Int + get() = mBorderWidth + set(borderWidth) { + if (borderWidth == mBorderWidth) { + return + } + + mBorderWidth = borderWidth + setup() + } + + var isBorderOverlay: Boolean + get() = mBorderOverlay + set(borderOverlay) { + if (borderOverlay == mBorderOverlay) { + return + } + + mBorderOverlay = borderOverlay + setup() + } + + constructor(context: Context) : super(context) { + + init() + } + + @JvmOverloads + constructor(context: Context, attrs: AttributeSet, defStyle: Int = 0) : super(context, attrs, defStyle) { + + val a = context.obtainStyledAttributes(attrs, R.styleable.CircleImageView, defStyle, 0) + + mBorderWidth = a.getDimensionPixelSize(R.styleable.CircleImageView_civ_border_width, DEFAULT_BORDER_WIDTH) + mBorderColor = a.getColor(R.styleable.CircleImageView_civ_border_color, DEFAULT_BORDER_COLOR) + mBorderOverlay = a.getBoolean(R.styleable.CircleImageView_civ_border_overlay, DEFAULT_BORDER_OVERLAY) + mCircleBackgroundColor = + a.getColor(R.styleable.CircleImageView_civ_circle_background_color, DEFAULT_CIRCLE_BACKGROUND_COLOR) + + a.recycle() + + init() + } + + private fun init() { + super.setScaleType(SCALE_TYPE) + mReady = true + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + outlineProvider = OutlineProvider() + } + + if (mSetupPending) { + setup() + mSetupPending = false + } + } + + override fun getScaleType(): ScaleType { + return SCALE_TYPE + } + + override fun setScaleType(scaleType: ScaleType) { + if (scaleType != SCALE_TYPE) { + throw IllegalArgumentException(String.format("ScaleType %s not supported.", scaleType)) + } + } + + override fun setAdjustViewBounds(adjustViewBounds: Boolean) { + if (adjustViewBounds) { + throw IllegalArgumentException("adjustViewBounds not supported.") + } + } + + override fun onDraw(canvas: Canvas) { + if (isDisableCircularTransformation) { + super.onDraw(canvas) + return + } + + if (mBitmap == null) { + return + } + + if (mCircleBackgroundColor != Color.TRANSPARENT) { + canvas.drawCircle(mDrawableRect.centerX(), mDrawableRect.centerY(), mDrawableRadius, mCircleBackgroundPaint) + } + canvas.drawCircle(mDrawableRect.centerX(), mDrawableRect.centerY(), mDrawableRadius, mBitmapPaint) + if (mBorderWidth > 0) { + canvas.drawCircle(mBorderRect.centerX(), mBorderRect.centerY(), mBorderRadius, mBorderPaint) + } + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + setup() + } + + override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) { + super.setPadding(left, top, right, bottom) + setup() + } + + override fun setPaddingRelative(start: Int, top: Int, end: Int, bottom: Int) { + super.setPaddingRelative(start, top, end, bottom) + setup() + } + + fun setCircleBackgroundColorResource(@ColorRes circleBackgroundRes: Int) { + circleBackgroundColor = context.getCompatColor(circleBackgroundRes) + } + + override fun setImageBitmap(bm: Bitmap) { + super.setImageBitmap(bm) + initializeBitmap() + } + + override fun setImageDrawable(drawable: Drawable?) { + super.setImageDrawable(drawable) + initializeBitmap() + } + + override fun setImageResource(@DrawableRes resId: Int) { + super.setImageResource(resId) + initializeBitmap() + } + + override fun setImageURI(uri: Uri?) { + super.setImageURI(uri) + initializeBitmap() + } + + override fun setColorFilter(cf: ColorFilter) { + if (cf === mColorFilter) { + return + } + + mColorFilter = cf + applyColorFilter() + invalidate() + } + + override fun getColorFilter(): ColorFilter? { + return mColorFilter + } + + private fun applyColorFilter() { + mBitmapPaint.colorFilter = mColorFilter + } + + private fun getBitmapFromDrawable(drawable: Drawable?): Bitmap? { + if (drawable == null) { + return null + } + + if (drawable is BitmapDrawable) { + return drawable.bitmap + } + + return try { + val bitmap: Bitmap = if (drawable is ColorDrawable) { + Bitmap.createBitmap(COLORDRAWABLE_DIMENSION, COLORDRAWABLE_DIMENSION, BITMAP_CONFIG) + } else { + Bitmap.createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight, BITMAP_CONFIG) + } + + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + bitmap + } catch (e: Exception) { + e.printStackTrace() + null + } + + } + + private fun initializeBitmap() { + if (isDisableCircularTransformation) { + mBitmap = null + } else { + mBitmap = getBitmapFromDrawable(drawable) + } + setup() + } + + private fun setup() { + if (!mReady) { + mSetupPending = true + return + } + + if (width == 0 && height == 0) { + return + } + + if (mBitmap == null) { + invalidate() + return + } + + mBitmapShader = BitmapShader(mBitmap!!, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) + + mBitmapPaint.isAntiAlias = true + mBitmapPaint.shader = mBitmapShader + + mBorderPaint.style = Paint.Style.STROKE + mBorderPaint.isAntiAlias = true + mBorderPaint.color = mBorderColor + mBorderPaint.strokeWidth = mBorderWidth.toFloat() + + mCircleBackgroundPaint.style = Paint.Style.FILL + mCircleBackgroundPaint.isAntiAlias = true + mCircleBackgroundPaint.color = mCircleBackgroundColor + + mBitmapHeight = mBitmap!!.height + mBitmapWidth = mBitmap!!.width + + mBorderRect.set(calculateBounds()) + mBorderRadius = + min((mBorderRect.height() - mBorderWidth) / 2.0f, (mBorderRect.width() - mBorderWidth) / 2.0f) + + mDrawableRect.set(mBorderRect) + if (!mBorderOverlay && mBorderWidth > 0) { + mDrawableRect.inset(mBorderWidth - 1.0f, mBorderWidth - 1.0f) + } + mDrawableRadius = min(mDrawableRect.height() / 2.0f, mDrawableRect.width() / 2.0f) + + applyColorFilter() + updateShaderMatrix() + invalidate() + } + + private fun calculateBounds(): RectF { + val availableWidth = width - paddingLeft - paddingRight + val availableHeight = height - paddingTop - paddingBottom + + val sideLength = min(availableWidth, availableHeight) + + val left = paddingLeft + (availableWidth - sideLength) / 2f + val top = paddingTop + (availableHeight - sideLength) / 2f + + return RectF(left, top, left + sideLength, top + sideLength) + } + + private fun updateShaderMatrix() { + val scale: Float + var dx = 0f + var dy = 0f + + mShaderMatrix.set(null) + + if (mBitmapWidth * mDrawableRect.height() > mDrawableRect.width() * mBitmapHeight) { + scale = mDrawableRect.height() / mBitmapHeight.toFloat() + dx = (mDrawableRect.width() - mBitmapWidth * scale) * 0.5f + } else { + scale = mDrawableRect.width() / mBitmapWidth.toFloat() + dy = (mDrawableRect.height() - mBitmapHeight * scale) * 0.5f + } + + mShaderMatrix.setScale(scale, scale) + mShaderMatrix.postTranslate((dx + 0.5f).toInt() + mDrawableRect.left, (dy + 0.5f).toInt() + mDrawableRect.top) + + mBitmapShader!!.setLocalMatrix(mShaderMatrix) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + return inTouchableArea(event.x, event.y) && super.onTouchEvent(event) + } + + private fun inTouchableArea(x: Float, y: Float): Boolean { + return (x - mBorderRect.centerX()).toDouble() + .pow(2.0) + (y - mBorderRect.centerY()).toDouble() + .pow(2.0) <= mBorderRadius.toDouble().pow(2.0) + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + private inner class OutlineProvider : ViewOutlineProvider() { + + override fun getOutline(view: View, outline: Outline) { + val bounds = Rect() + mBorderRect.roundOut(bounds) + outline.setRoundRect(bounds, bounds.width() / 2.0f) + } + + } + + companion object { + + private val SCALE_TYPE = ScaleType.CENTER_CROP + + private val BITMAP_CONFIG = Bitmap.Config.ARGB_8888 + private const val COLORDRAWABLE_DIMENSION = 2 + + private const val DEFAULT_BORDER_WIDTH = 0 + private const val DEFAULT_BORDER_COLOR = Color.BLACK + private const val DEFAULT_CIRCLE_BACKGROUND_COLOR = Color.TRANSPARENT + private const val DEFAULT_BORDER_OVERLAY = false + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/image/CoverImageView.kt b/app/src/main/java/io/legado/app/ui/widget/image/CoverImageView.kt new file mode 100644 index 000000000..289790a1a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/image/CoverImageView.kt @@ -0,0 +1,56 @@ +package io.legado.app.ui.widget.image + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Path +import android.util.AttributeSet + + +class CoverImageView : androidx.appcompat.widget.AppCompatImageView { + internal var width: Float = 0.toFloat() + internal var height: Float = 0.toFloat() + + constructor(context: Context) : super(context) + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + width = getWidth().toFloat() + height = getHeight().toFloat() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val measuredWidth = MeasureSpec.getSize(widthMeasureSpec) + val measuredHeight = measuredWidth * 7 / 5 + super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.EXACTLY)) + } + + override fun onDraw(canvas: Canvas) { + if (width >= 10 && height > 10) { + @SuppressLint("DrawAllocation") + val path = Path() + //四个圆角 + path.moveTo(10f, 0f) + path.lineTo(width - 10, 0f) + path.quadTo(width, 0f, width, 10f) + path.lineTo(width, height - 10) + path.quadTo(width, height, width - 10, height) + path.lineTo(10f, height) + path.quadTo(0f, height, 0f, height - 10) + path.lineTo(0f, 10f) + path.quadTo(0f, 0f, 10f, 0f) + + canvas.clipPath(path) + } + super.onDraw(canvas) + } + + fun setHeight(height: Int) { + val width = height * 5 / 7 + minimumWidth = width + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/image/FilletImageView.kt b/app/src/main/java/io/legado/app/ui/widget/image/FilletImageView.kt new file mode 100644 index 000000000..bcb449329 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/image/FilletImageView.kt @@ -0,0 +1,94 @@ +package io.legado.app.ui.widget.image + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Path +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatImageView +import io.legado.app.R +import io.legado.app.utils.dp +import kotlin.math.max + +class FilletImageView : AppCompatImageView { + internal var width: Float = 0.toFloat() + internal var height: Float = 0.toFloat() + private var leftTopRadius: Int = 0 + private var rightTopRadius: Int = 0 + private var rightBottomRadius: Int = 0 + private var leftBottomRadius: Int = 0 + + constructor(context: Context) : super(context) + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { + init(context, attrs) + } + + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { + init(context, attrs) + } + + private fun init(context: Context, attrs: AttributeSet) { + // 读取配置 + val array = context.obtainStyledAttributes(attrs, R.styleable.FilletImageView) + val defaultRadius = 5.dp + val radius = array.getDimensionPixelOffset(R.styleable.FilletImageView_radius, defaultRadius) + leftTopRadius = array.getDimensionPixelOffset(R.styleable.FilletImageView_left_top_radius, defaultRadius) + rightTopRadius = array.getDimensionPixelOffset(R.styleable.FilletImageView_right_top_radius, defaultRadius) + rightBottomRadius = + array.getDimensionPixelOffset(R.styleable.FilletImageView_right_bottom_radius, defaultRadius) + leftBottomRadius = array.getDimensionPixelOffset(R.styleable.FilletImageView_left_bottom_radius, defaultRadius) + + //如果四个角的值没有设置,那么就使用通用的radius的值。 + if (defaultRadius == leftTopRadius) { + leftTopRadius = radius + } + if (defaultRadius == rightTopRadius) { + rightTopRadius = radius + } + if (defaultRadius == rightBottomRadius) { + rightBottomRadius = radius + } + if (defaultRadius == leftBottomRadius) { + leftBottomRadius = radius + } + array.recycle() + + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + width = getWidth().toFloat() + height = getHeight().toFloat() + } + + override fun onDraw(canvas: Canvas) { + //这里做下判断,只有图片的宽高大于设置的圆角距离的时候才进行裁剪 + val maxLeft = max(leftTopRadius, leftBottomRadius) + val maxRight = max(rightTopRadius, rightBottomRadius) + val minWidth = maxLeft + maxRight + val maxTop = max(leftTopRadius, rightTopRadius) + val maxBottom = max(leftBottomRadius, rightBottomRadius) + val minHeight = maxTop + maxBottom + if (width >= minWidth && height > minHeight) { + @SuppressLint("DrawAllocation") val path = Path() + //四个角:右上,右下,左下,左上 + path.moveTo(leftTopRadius.toFloat(), 0f) + path.lineTo(width - rightTopRadius, 0f) + path.quadTo(width, 0f, width, rightTopRadius.toFloat()) + + path.lineTo(width, height - rightBottomRadius) + path.quadTo(width, height, width - rightBottomRadius, height) + + path.lineTo(leftBottomRadius.toFloat(), height) + path.quadTo(0f, height, 0f, height - leftBottomRadius) + + path.lineTo(0f, leftTopRadius.toFloat()) + path.quadTo(0f, 0f, leftTopRadius.toFloat(), 0f) + + canvas.clipPath(path) + } + super.onDraw(canvas) + } + +} diff --git a/app/src/main/java/io/legado/app/ui/widget/page/ChapterProvider.kt b/app/src/main/java/io/legado/app/ui/widget/page/ChapterProvider.kt new file mode 100644 index 000000000..09fd9454a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/page/ChapterProvider.kt @@ -0,0 +1,79 @@ +package io.legado.app.ui.widget.page + +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.style.ForegroundColorSpan +import android.text.style.RelativeSizeSpan +import io.legado.app.App +import io.legado.app.data.entities.BookChapter +import io.legado.app.lib.theme.accentColor + + +object ChapterProvider { + var readAloudSpan = ForegroundColorSpan(App.INSTANCE.accentColor) + private val titleSpan = RelativeSizeSpan(1.2f) + + fun getTextChapter( + textView: ContentTextView, bookChapter: BookChapter, + content: String, chapterSize: Int + ): TextChapter { + val textPages = arrayListOf() + val pageLines = arrayListOf() + val pageLengths = arrayListOf() + var surplusText = content + var pageIndex = 0 + while (surplusText.isNotEmpty()) { + val spannableStringBuilder = SpannableStringBuilder(surplusText) + if (pageIndex == 0) { + val end = surplusText.indexOf("\n") + if (end > 0) { + spannableStringBuilder.setSpan( + titleSpan, + 0, + end, + Spannable.SPAN_INCLUSIVE_EXCLUSIVE + ) + } + } + textView.text = spannableStringBuilder + val lastLine = textView.getLineNum() + val lastCharNum = textView.getCharNum(lastLine) + if (lastCharNum == 0) { + break + } else { + pageLines.add(lastLine) + pageLengths.add(lastCharNum) + textPages.add( + TextPage( + index = pageIndex, + text = spannableStringBuilder.delete( + lastCharNum, + spannableStringBuilder.length + ), + title = bookChapter.title, + chapterSize = chapterSize, + chapterIndex = bookChapter.index + ) + ) + surplusText = surplusText.substring(lastCharNum) + pageIndex++ + } + } + for (item in textPages) { + item.pageSize = textPages.size + } + return TextChapter( + bookChapter.index, + bookChapter.title, + bookChapter.url, + textPages, + pageLines, + pageLengths, + chapterSize + ) + } + + fun upReadAloudSpan() { + readAloudSpan = ForegroundColorSpan(App.INSTANCE.accentColor) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/page/ContentSelectActionCallback.kt b/app/src/main/java/io/legado/app/ui/widget/page/ContentSelectActionCallback.kt new file mode 100644 index 000000000..e8e8af8d4 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/page/ContentSelectActionCallback.kt @@ -0,0 +1,38 @@ +package io.legado.app.ui.widget.page + +import android.view.ActionMode +import android.view.Menu +import android.view.MenuItem + +import io.legado.app.R +import io.legado.app.constant.Bus +import io.legado.app.utils.postEvent + +class ContentSelectActionCallback(private val textView: ContentTextView) : ActionMode.Callback { + + override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.menu_replace -> { + val text = textView.text.substring(textView.selectionStart, textView.selectionEnd) + postEvent(Bus.REPLACE, text) + mode?.finish() + return true + } + } + return false + } + + override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean { + mode?.menuInflater?.inflate(R.menu.content_select_action, menu) + return true + } + + override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean { + return false + } + + override fun onDestroyActionMode(mode: ActionMode?) { + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/page/ContentTextView.kt b/app/src/main/java/io/legado/app/ui/widget/page/ContentTextView.kt new file mode 100644 index 000000000..383425a7a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/page/ContentTextView.kt @@ -0,0 +1,244 @@ +package io.legado.app.ui.widget.page + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.VelocityTracker +import android.view.ViewConfiguration +import android.view.animation.Interpolator +import android.widget.OverScroller +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.view.ViewCompat +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + + +class ContentTextView : AppCompatTextView { + constructor(context: Context) : super(context) + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) + : super(context, attrs, defStyleAttr) + + private val scrollStateIdle = 0 + private val scrollStateDragging = 1 + val scrollStateSettling = 2 + + private val mViewFling: ViewFling by lazy { ViewFling() } + private var velocityTracker: VelocityTracker? = null + private var mScrollState = scrollStateIdle + private var mLastTouchY: Int = 0 + private var mTouchSlop: Int = 0 + private var mMinFlingVelocity: Int = 0 + private var mMaxFlingVelocity: Int = 0 + + //滑动距离的最大边界 + private var mOffsetHeight: Int = 0 + + //f(x) = (x-1)^5 + 1 + private val sQuinticInterpolator = Interpolator { + var t = it + t -= 1.0f + t * t * t * t * t + 1.0f + } + + init { + val vc = ViewConfiguration.get(context) + mTouchSlop = vc.scaledTouchSlop + mMinFlingVelocity = vc.scaledMinimumFlingVelocity + mMaxFlingVelocity = vc.scaledMaximumFlingVelocity + } + + fun atTop(): Boolean { + return scrollY <= 0 + } + + fun atBottom(): Boolean { + return scrollY >= mOffsetHeight + } + + /** + * 获取当前页总字数 + */ + fun getCharNum(lineNum: Int = getLineNum()): Int { + return layout?.getLineEnd(lineNum) ?: 0 + } + + /** + * 获取当前页总行数 + */ + fun getLineNum(): Int { + val topOfLastLine = height - paddingTop - paddingBottom - lineHeight + return layout?.getLineForVertical(topOfLastLine) ?: 0 + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + initOffsetHeight() + } + + override fun onTextChanged( + text: CharSequence?, + start: Int, + lengthBefore: Int, + lengthAfter: Int + ) { + super.onTextChanged(text, start, lengthBefore, lengthAfter) + initOffsetHeight() + } + + private fun initOffsetHeight() { + val mLayoutHeight: Int + + //获得内容面板 + val mLayout = layout ?: return + //获得内容面板的高度 + mLayoutHeight = mLayout.height + + //计算滑动距离的边界 + mOffsetHeight = mLayoutHeight + totalPaddingTop + totalPaddingBottom - measuredHeight + } + + override fun scrollTo(x: Int, y: Int) { + super.scrollTo(x, min(y, mOffsetHeight)) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent?): Boolean { + event?.let { + if (velocityTracker == null) { + velocityTracker = VelocityTracker.obtain() + } + velocityTracker?.addMovement(it) + when (event.action) { + MotionEvent.ACTION_DOWN -> { + setScrollState(scrollStateIdle) + mLastTouchY = (event.y + 0.5f).toInt() + } + MotionEvent.ACTION_MOVE -> { + val y = (event.y + 0.5f).toInt() + var dy = mLastTouchY - y + if (mScrollState != scrollStateDragging) { + var startScroll = false + + if (abs(dy) > mTouchSlop) { + if (dy > 0) { + dy -= mTouchSlop + } else { + dy += mTouchSlop + } + startScroll = true + } + if (startScroll) { + setScrollState(scrollStateDragging) + } + } + if (mScrollState == scrollStateDragging) { + mLastTouchY = y + } + } + MotionEvent.ACTION_UP -> { + velocityTracker?.computeCurrentVelocity(1000, mMaxFlingVelocity.toFloat()) + val yVelocity = velocityTracker?.yVelocity ?: 0f + if (abs(yVelocity) > mMinFlingVelocity) { + mViewFling.fling(-yVelocity.toInt()) + } else { + setScrollState(scrollStateIdle) + } + resetTouch() + } + MotionEvent.ACTION_CANCEL -> { + resetTouch() + } + } + } + return super.onTouchEvent(event) + } + + private fun resetTouch() { + velocityTracker?.clear() + } + + private fun setScrollState(state: Int) { + if (state == mScrollState) { + return + } + mScrollState = state + if (state != scrollStateSettling) { + mViewFling.stop() + } + } + + /** + * 惯性滚动 + */ + private inner class ViewFling : Runnable { + + private var mLastFlingY = 0 + private val mScroller: OverScroller = OverScroller(context, sQuinticInterpolator) + private var mEatRunOnAnimationRequest = false + private var mReSchedulePostAnimationCallback = false + + override fun run() { + disableRunOnAnimationRequests() + val scroller = mScroller + if (scroller.computeScrollOffset()) { + val y = scroller.currY + val dy = y - mLastFlingY + mLastFlingY = y + if (dy < 0 && scrollY > 0) { + scrollBy(0, max(dy, -scrollY)) + } else if (dy > 0 && scrollY < mOffsetHeight) { + scrollBy(0, min(dy, mOffsetHeight - scrollY)) + } + postOnAnimation() + } + enableRunOnAnimationRequests() + } + + fun fling(velocityY: Int) { + mLastFlingY = 0 + setScrollState(scrollStateSettling) + mScroller.fling( + 0, + 0, + 0, + velocityY, + Integer.MIN_VALUE, + Integer.MAX_VALUE, + Integer.MIN_VALUE, + Integer.MAX_VALUE + ) + postOnAnimation() + } + + fun stop() { + removeCallbacks(this) + mScroller.abortAnimation() + } + + private fun disableRunOnAnimationRequests() { + mReSchedulePostAnimationCallback = false + mEatRunOnAnimationRequest = true + } + + private fun enableRunOnAnimationRequests() { + mEatRunOnAnimationRequest = false + if (mReSchedulePostAnimationCallback) { + postOnAnimation() + } + } + + internal fun postOnAnimation() { + if (mEatRunOnAnimationRequest) { + mReSchedulePostAnimationCallback = true + } else { + removeCallbacks(this) + ViewCompat.postOnAnimation(this@ContentTextView, this) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/page/ContentView.kt b/app/src/main/java/io/legado/app/ui/widget/page/ContentView.kt new file mode 100644 index 000000000..228e6f2e9 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/page/ContentView.kt @@ -0,0 +1,146 @@ +package io.legado.app.ui.widget.page + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.widget.FrameLayout +import android.widget.ImageView +import androidx.appcompat.widget.AppCompatImageView +import io.legado.app.R +import io.legado.app.constant.AppConst.TIME_FORMAT +import io.legado.app.help.ReadBookConfig +import io.legado.app.utils.* +import kotlinx.android.synthetic.main.view_book_page.view.* +import org.jetbrains.anko.matchParent +import org.jetbrains.anko.sdk27.listeners.onScrollChange +import java.util.* + + +class ContentView : FrameLayout { + var callBack: CallBack? = null + private var isScroll: Boolean = false + private var pageSize: Int = 0 + private val bgImage: AppCompatImageView = AppCompatImageView(context) + .apply { + scaleType = ImageView.ScaleType.CENTER_CROP + } + + constructor(context: Context) : super(context) { + this.isScroll = true + init() + } + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { + init() + } + + fun init() { + //设置背景防止切换背景时文字重叠 + setBackgroundColor(context.getCompatColor(R.color.background)) + addView(bgImage, LayoutParams(matchParent, matchParent)) + inflate(context, R.layout.view_book_page, this) + top_bar.layoutParams.height = context.getStatusBarHeight() + upStyle() + upTime() + content_text_view.customSelectionActionModeCallback = + ContentSelectActionCallback(content_text_view) + content_text_view.onScrollChange { _, _, scrollY, _, _ -> + content_text_view.layout?.getLineForVertical(scrollY)?.let { line -> + callBack?.scrollToLine(line) + } + if (content_text_view.atBottom()) { + callBack?.scrollToLast() + } + } + } + + fun upStyle() { + ReadBookConfig.getConfig().apply { + val pt = if (context.getPrefBoolean("hideStatusBar", false)) { + top_bar.visible() + 0 + } else { + top_bar.gone() + context.getStatusBarHeight() + } + page_panel.setPadding(paddingLeft.dp, pt, paddingRight.dp, 0) + content_text_view.setPadding(0, paddingTop.dp, 0, paddingBottom.dp) + content_text_view.textSize = textSize.toFloat() + content_text_view.setLineSpacing(lineSpacingExtra.toFloat(), lineSpacingMultiplier) + content_text_view.letterSpacing = letterSpacing + content_text_view.paint.isFakeBoldText = textBold + textColor().let { + content_text_view.setTextColor(it) + tv_top_left.setTextColor(it) + tv_top_right.setTextColor(it) + tv_bottom_left.setTextColor(it) + tv_bottom_right.setTextColor(it) + } + } + context.getPrefString("readBookFont")?.let { + if (it.isNotEmpty()) { + content_text_view.typeface = Typeface.createFromFile(it) + } else { + content_text_view.typeface = Typeface.DEFAULT + } + } + } + + fun setBg(bg: Drawable?) { + bgImage.background = bg + } + + fun upTime() { + tv_top_left.text = TIME_FORMAT.format(Date(System.currentTimeMillis())) + } + + fun upBattery(battery: Int) { + tv_top_right.text = context.getString(R.string.battery_show, battery) + } + + fun setContent(textPage: TextPage?) { + content_text_view.text = textPage?.text + tv_bottom_left.text = textPage?.title + pageSize = textPage?.pageSize ?: 0 + setPageIndex(textPage?.index) + } + + @SuppressLint("SetTextI18n") + fun setPageIndex(pageIndex: Int?) { + pageIndex?.let { + tv_bottom_right.text = "${pageIndex.plus(1)}/${pageSize}" + } + } + + fun isTextSelected(): Boolean { + return content_text_view.selectionEnd - content_text_view.selectionStart != 0 + } + + fun contentTextView(): ContentTextView? { + return content_text_view + } + + fun scrollTo(pos: Int?) { + if (pos != null) { + content_text_view.post { + content_text_view.scrollTo(0, content_text_view.layout.getLineTop(pos)) + } + } + } + + fun scrollToBottom() { + content_text_view.post { + content_text_view.scrollTo( + 0, + content_text_view.layout.getLineTop(content_text_view.lineCount) + ) + } + } + + interface CallBack { + fun scrollToLine(line: Int) + fun scrollToLast() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/page/DataSource.kt b/app/src/main/java/io/legado/app/ui/widget/page/DataSource.kt new file mode 100644 index 000000000..01c84da91 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/page/DataSource.kt @@ -0,0 +1,28 @@ +package io.legado.app.ui.widget.page + +interface DataSource { + val isScrollDelegate: Boolean + + val pageIndex: Int + + fun setPageIndex(pageIndex: Int) + + fun getChapterPosition(): Int + + fun getChapter(position: Int): TextChapter? + + fun getCurrentChapter(): TextChapter? + + fun getNextChapter(): TextChapter? + + fun getPreviousChapter(): TextChapter? + + fun hasNextChapter(): Boolean + + fun hasPrevChapter(): Boolean + + fun moveToNextChapter() + + fun moveToPrevChapter() + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/page/PageFactory.kt b/app/src/main/java/io/legado/app/ui/widget/page/PageFactory.kt new file mode 100644 index 000000000..f9b9ca4cb --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/page/PageFactory.kt @@ -0,0 +1,25 @@ +package io.legado.app.ui.widget.page + +abstract class PageFactory(protected val dataSource: DataSource) { + + abstract fun pageAt(index: Int): DATA? + + abstract fun moveToFirst() + + abstract fun moveToLast() + + abstract fun moveToNext():Boolean + + abstract fun moveToPrevious(): Boolean + + abstract fun nextPage(): DATA? + + abstract fun previousPage(): DATA? + + abstract fun currentPage(): DATA? + + abstract fun hasNext(): Boolean + + abstract fun hasPrev(): Boolean + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/page/PageView.kt b/app/src/main/java/io/legado/app/ui/widget/page/PageView.kt new file mode 100644 index 000000000..e1e865830 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/page/PageView.kt @@ -0,0 +1,285 @@ +package io.legado.app.ui.widget.page + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.util.AttributeSet +import android.view.MotionEvent +import android.widget.FrameLayout +import io.legado.app.help.ReadBookConfig +import io.legado.app.ui.widget.page.curl.CurlView +import io.legado.app.ui.widget.page.delegate.* +import io.legado.app.utils.activity +import io.legado.app.utils.getPrefInt + +class PageView(context: Context, attrs: AttributeSet) : + FrameLayout(context, attrs), + ContentView.CallBack, + DataSource { + + var callback: CallBack? = null + var pageFactory: TextPageFactory? = null + private var pageDelegate: PageDelegate? = null + + var prevPage: ContentView? = null + var curPage: ContentView? = null + var nextPage: ContentView? = null + var curlView: CurlView? = null + + init { + callback = activity as? CallBack + prevPage = ContentView(context) + addView(prevPage) + nextPage = ContentView(context) + addView(nextPage) + curPage = ContentView(context) + addView(curPage) + upBg() + setWillNotDraw(false) + pageFactory = TextPageFactory(this) + upPageAnim() + curPage?.callBack = this + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + + pageDelegate?.setViewSize(w, h) + } + + override fun dispatchDraw(canvas: Canvas) { + super.dispatchDraw(canvas) + + pageDelegate?.onDraw(canvas) + } + + override fun computeScroll() { + pageDelegate?.scroll() + } + + override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean { + return true + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + return pageDelegate?.onTouch(event) ?: super.onTouchEvent(event) + } + + fun fillPage(direction: PageDelegate.Direction) { + when (direction) { + PageDelegate.Direction.PREV -> { + pageFactory?.moveToPrevious() + upContent() + if (isScrollDelegate) { + curPage?.scrollToBottom() + } + } + PageDelegate.Direction.NEXT -> { + pageFactory?.moveToNext() + upContent() + if (isScrollDelegate) { + curPage?.scrollTo(0) + } + } + else -> Unit + } + } + + fun upPageAnim() { + if (curlView != null) { + removeView(curlView) + curlView = null + } + pageDelegate = null + pageDelegate = when (context.getPrefInt("pageAnim")) { + 0 -> CoverPageDelegate(this) + 1 -> SlidePageDelegate(this) + 2 -> SimulationPageDelegate(this) + 3 -> ScrollPageDelegate(this) + else -> NoAnimPageDelegate(this) + } + upContent() + } + + fun upContent(position: Int = 0) { + pageFactory?.let { + when (position) { + -1 -> prevPage?.setContent(it.previousPage()) + 1 -> nextPage?.setContent(it.nextPage()) + else -> { + curPage?.setContent(it.currentPage()) + nextPage?.setContent(it.nextPage()) + prevPage?.setContent(it.previousPage()) + callback?.let { callback -> + if (isScrollDelegate) { + curPage?.scrollTo(callback.textChapter()?.getStartLine(callback.durChapterPos())) + } + } + } + } + if (isScrollDelegate) { + prevPage?.scrollToBottom() + } + } + pageDelegate?.onPageUp() + } + + fun moveToPrevPage(noAnim: Boolean = true) { + if (noAnim) { + if (isScrollDelegate) { + callback?.textChapter()?.let { + curPage?.scrollTo(it.getStartLine(pageIndex - 1)) + } + } else { + fillPage(PageDelegate.Direction.PREV) + } + } + } + + fun moveToNextPage(noAnim: Boolean = true) { + if (noAnim) { + if (isScrollDelegate) { + callback?.textChapter()?.let { + curPage?.scrollTo(it.getStartLine(pageIndex + 1)) + } + } else { + fillPage(PageDelegate.Direction.NEXT) + } + } + } + + fun upStyle() { + curPage?.upStyle() + prevPage?.upStyle() + nextPage?.upStyle() + } + + fun upBg() { + ReadBookConfig.bg ?: let { + ReadBookConfig.upBg() + } + curPage?.setBg(ReadBookConfig.bg) + prevPage?.setBg(ReadBookConfig.bg) + nextPage?.setBg(ReadBookConfig.bg) + } + + fun upTime() { + curPage?.upTime() + prevPage?.upTime() + nextPage?.upTime() + } + + fun upBattery(battery: Int) { + curPage?.upBattery(battery) + prevPage?.upBattery(battery) + nextPage?.upBattery(battery) + } + + override val isScrollDelegate: Boolean + get() = pageDelegate is ScrollPageDelegate + + override val pageIndex: Int + get() = callback?.durChapterPos() ?: 0 + + override fun setPageIndex(pageIndex: Int) { + callback?.setPageIndex(pageIndex) + } + + override fun getChapterPosition(): Int { + return callback?.durChapterIndex() ?: 0 + } + + override fun getChapter(position: Int): TextChapter? { + return callback?.textChapter(position) + } + + override fun getCurrentChapter(): TextChapter? { + return callback?.textChapter(0) + } + + override fun getNextChapter(): TextChapter? { + return callback?.textChapter(1) + } + + override fun getPreviousChapter(): TextChapter? { + return callback?.textChapter(-1) + } + + override fun hasNextChapter(): Boolean { + callback?.let { + return it.durChapterIndex() < it.chapterSize() - 1 + } + return false + } + + override fun hasPrevChapter(): Boolean { + callback?.let { + return it.durChapterIndex() > 0 + } + return false + } + + override fun moveToNextChapter() { + callback?.moveToNextChapter(false) + } + + override fun moveToPrevChapter() { + callback?.moveToPrevChapter(false) + } + + override fun scrollToLine(line: Int) { + if (isScrollDelegate) { + callback?.textChapter()?.let { + val pageIndex = it.getPageIndex(line) + curPage?.setPageIndex(pageIndex) + callback?.setPageIndex(pageIndex) + } + } + } + + override fun scrollToLast() { + if (isScrollDelegate) { + callback?.textChapter()?.let { + callback?.setPageIndex(it.lastIndex()) + curPage?.setPageIndex(it.lastIndex()) + } + } + } + + interface CallBack { + fun chapterSize(): Int + fun durChapterIndex(): Int + fun durChapterPos(): Int + + /** + * chapterOnDur: 0为当前页,1为下一页,-1为上一页 + */ + fun textChapter(chapterOnDur: Int = 0): TextChapter? + + /** + * 加载章节内容, index章节序号 + */ + fun loadContent(index: Int) + + /** + * 下一章 + */ + fun moveToNextChapter(upContent: Boolean): Boolean + + /** + * 上一章 + */ + fun moveToPrevChapter(upContent: Boolean, last: Boolean = true): Boolean + + /** + * 保存页数 + */ + fun setPageIndex(pageIndex: Int) + + /** + * 点击屏幕中间 + */ + fun clickCenter() + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/page/TextChapter.kt b/app/src/main/java/io/legado/app/ui/widget/page/TextChapter.kt new file mode 100644 index 000000000..0ab136b42 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/page/TextChapter.kt @@ -0,0 +1,94 @@ +package io.legado.app.ui.widget.page + +import android.text.SpannableStringBuilder + +data class TextChapter( + val position: Int, + val title: String, + val url: String, + val pages: List, + val pageLines: List, + val pageLengths: List, + val chaptersSize: Int +) { + fun page(index: Int): TextPage? { + if (index >= 0 && index < pages.size) { + return pages[index] + } + return null + } + + fun lastPage(): TextPage? { + if (pages.isNotEmpty()) { + return pages[pages.lastIndex] + } + return null + } + + fun scrollPage(): TextPage? { + if (pages.isNotEmpty()) { + val spannableStringBuilder = SpannableStringBuilder() + pages.forEach { + spannableStringBuilder.append(it.text) + } + return TextPage( + index = 0, text = spannableStringBuilder, title = title, + pageSize = pages.size, chapterSize = chaptersSize, chapterIndex = position + ) + } + return null + } + + fun lastIndex(): Int { + return pages.size - 1 + } + + fun isLastIndex(index: Int): Boolean { + return index >= pages.size - 1 + } + + fun pageSize(): Int { + return pages.size + } + + fun getReadLength(pageIndex: Int): Int { + var length = 0 + for (index in 0 until pageIndex) { + length += pageLengths[index] + } + return length + } + + fun getUnRead(pageIndex: Int): String { + val stringBuilder = StringBuilder() + if (pageIndex < pages.size && pages.isNotEmpty()) { + for (index in pageIndex..lastIndex()) { + stringBuilder.append(pages[index].text) + } + } + return stringBuilder.toString() + } + + fun getStartLine(pageIndex: Int): Int { + if (pageLines.size > pageIndex) { + var lines = 0 + for (index: Int in 0 until pageIndex) { + lines += pageLines[index] + 1 + } + return lines + } + return 0 + } + + fun getPageIndex(line: Int): Int { + var lines = 0 + for (pageIndex in pageLines.indices) { + lines += pageLines[pageIndex] + 1 + if (line < lines) { + return pageIndex + } + } + return 0 + } +} + diff --git a/app/src/main/java/io/legado/app/ui/widget/page/TextPage.kt b/app/src/main/java/io/legado/app/ui/widget/page/TextPage.kt new file mode 100644 index 000000000..44f47d098 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/page/TextPage.kt @@ -0,0 +1,39 @@ +package io.legado.app.ui.widget.page + +import android.text.Spannable +import android.text.SpannableStringBuilder +import io.legado.app.App +import io.legado.app.R + +data class TextPage( + val index: Int, + val text: CharSequence = App.INSTANCE.getString(R.string.data_loading), + val title: String, + var pageSize: Int = 0, + var chapterSize: Int = 0, + var chapterIndex: Int = 0 +) { + + fun removePageAloudSpan(): TextPage { + if (text is SpannableStringBuilder) { + text.removeSpan(ChapterProvider.readAloudSpan) + } + return this + } + + fun upPageAloudSpan(pageStart: Int) { + if (text is SpannableStringBuilder) { + text.removeSpan(ChapterProvider.readAloudSpan) + var end = text.indexOf("\n", pageStart) + if (end == -1) end = text.length + var start = text.lastIndexOf("\n", pageStart) + if (start == -1) start = 0 + text.setSpan( + ChapterProvider.readAloudSpan, + start, + end, + Spannable.SPAN_INCLUSIVE_EXCLUSIVE + ) + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/page/TextPageFactory.kt b/app/src/main/java/io/legado/app/ui/widget/page/TextPageFactory.kt new file mode 100644 index 000000000..ee237dc02 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/page/TextPageFactory.kt @@ -0,0 +1,98 @@ +package io.legado.app.ui.widget.page + +class TextPageFactory(dataSource: DataSource) : PageFactory(dataSource) { + + override fun hasPrev(): Boolean = with(dataSource) { + return if (isScrollDelegate) { + hasPrevChapter() + } else { + hasPrevChapter() || pageIndex > 0 + } + } + + override fun hasNext(): Boolean = with(dataSource) { + return if (isScrollDelegate) { + hasNextChapter() + } else { + hasNextChapter() + || getCurrentChapter()?.isLastIndex(pageIndex) != true + } + } + + override fun pageAt(index: Int): TextPage { + return dataSource.getCurrentChapter()?.page(index) + ?: TextPage(index = index, title = "index:$index") + } + + override fun moveToFirst() { + dataSource.setPageIndex(0) + } + + override fun moveToLast() = with(dataSource) { + getCurrentChapter()?.let { + if (it.pageSize() == 0) { + setPageIndex(0) + } else { + setPageIndex(it.pageSize().minus(1)) + } + } ?: setPageIndex(0) + } + + override fun moveToNext(): Boolean = with(dataSource) { + return if (hasNext()) { + if (getCurrentChapter()?.isLastIndex(pageIndex) == true + || isScrollDelegate + ) { + moveToNextChapter() + } else { + setPageIndex(pageIndex.plus(1)) + } + true + } else + false + } + + override fun moveToPrevious(): Boolean = with(dataSource) { + return if (hasPrev()) { + if (pageIndex <= 0 || isScrollDelegate) { + moveToPrevChapter() + } else { + setPageIndex(pageIndex.minus(1)) + } + true + } else + false + } + + override fun currentPage(): TextPage? = with(dataSource) { + return if (isScrollDelegate) { + getCurrentChapter()?.scrollPage() + } else { + getCurrentChapter()?.page(pageIndex) + } + } + + override fun nextPage(): TextPage? = with(dataSource) { + if (isScrollDelegate) { + return getNextChapter()?.scrollPage() + } + getCurrentChapter()?.let { + if (pageIndex < it.pageSize() - 1) { + return getCurrentChapter()?.page(pageIndex + 1)?.removePageAloudSpan() + } + } + return getNextChapter()?.page(0)?.removePageAloudSpan() + } + + override fun previousPage(): TextPage? = with(dataSource) { + if (isScrollDelegate) { + return getPreviousChapter()?.scrollPage() + } + if (pageIndex > 0) { + return getCurrentChapter()?.page(pageIndex - 1)?.removePageAloudSpan() + } + return getPreviousChapter()?.lastPage()?.removePageAloudSpan() + } + + +} diff --git a/app/src/main/java/io/legado/app/ui/widget/page/curl/CurlMesh.java b/app/src/main/java/io/legado/app/ui/widget/page/curl/CurlMesh.java new file mode 100644 index 000000000..f0e5a3fff --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/page/curl/CurlMesh.java @@ -0,0 +1,954 @@ +package io.legado.app.ui.widget.page.curl; + +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.PointF; +import android.graphics.RectF; +import android.opengl.GLUtils; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.FloatBuffer; + +import javax.microedition.khronos.opengles.GL10; + +/** + * Class implementing actual curl/page rendering. + * + * @author harism + */ +public class CurlMesh { + + // Flag for rendering some lines used for developing. Shows + // curl position and one for the direction from the + // position given. Comes handy once playing around with different + // ways for following pointer. + private static final boolean DRAW_CURL_POSITION = false; + // Flag for drawing polygon outlines. Using this flag crashes on emulator + // due to reason unknown to me. Leaving it here anyway as seeing polygon + // outlines gives good insight how original rectangle is divided. + private static final boolean DRAW_POLYGON_OUTLINES = false; + // Flag for enabling shadow rendering. + private static final boolean DRAW_SHADOW = true; + // Flag for texture rendering. While this is likely something you + // don't want to do it's been used for development purposes as texture + // rendering is rather slow on emulator. + private static final boolean DRAW_TEXTURE = true; + + // Colors for shadow. Inner one is the color drawn next to surface where + // shadowed area starts and outer one is color shadow ends to. + private static final float[] SHADOW_INNER_COLOR = {0f, 0f, 0f, .5f}; + private static final float[] SHADOW_OUTER_COLOR = {0f, 0f, 0f, .0f}; + + // Let's avoid using 'new' as much as possible. Meaning we introduce arrays + // once here and reuse them on runtime. Doesn't really have very much effect + // but avoids some garbage collections from happening. + private Array mArrDropShadowVertices; + private Array mArrIntersections; + private Array mArrOutputVertices; + private Array mArrRotatedVertices; + private Array mArrScanLines; + private Array mArrSelfShadowVertices; + private Array mArrTempShadowVertices; + private Array mArrTempVertices; + + // Buffers for feeding rasterizer. + private FloatBuffer mBufColors; + private FloatBuffer mBufCurlPositionLines; + private FloatBuffer mBufShadowColors; + private FloatBuffer mBufShadowVertices; + private FloatBuffer mBufTexCoords; + private FloatBuffer mBufVertices; + + private int mCurlPositionLinesCount; + private int mDropShadowCount; + + // Boolean for 'flipping' texture sideways. + private boolean mFlipTexture = false; + // Maximum number of split lines used for creating a curl. + private int mMaxCurlSplits; + + // Bounding rectangle for this mesh. mRectagle[0] = top-left corner, + // mRectangle[1] = bottom-left, mRectangle[2] = top-right and mRectangle[3] + // bottom-right. + private final Vertex[] mRectangle = new Vertex[4]; + private int mSelfShadowCount; + + private boolean mTextureBack = false; + // Texture ids and other variables. + private int[] mTextureIds = null; + private final CurlPage mTexturePage = new CurlPage(); + private final RectF mTextureRectBack = new RectF(); + private final RectF mTextureRectFront = new RectF(); + + private int mVerticesCountBack; + private int mVerticesCountFront; + + /** + * Constructor for mesh object. + * + * @param maxCurlSplits Maximum number curl can be divided into. The bigger the value + * the smoother curl will be. With the cost of having more + * polygons for drawing. + */ + public CurlMesh(int maxCurlSplits) { + // There really is no use for 0 splits. + mMaxCurlSplits = maxCurlSplits < 1 ? 1 : maxCurlSplits; + + mArrScanLines = new Array<>(maxCurlSplits + 2); + mArrOutputVertices = new Array<>(7); + mArrRotatedVertices = new Array<>(4); + mArrIntersections = new Array<>(2); + mArrTempVertices = new Array<>(7 + 4); + for (int i = 0; i < 7 + 4; ++i) { + mArrTempVertices.add(new Vertex()); + } + + if (DRAW_SHADOW) { + mArrSelfShadowVertices = new Array<>( + (mMaxCurlSplits + 2) * 2); + mArrDropShadowVertices = new Array<>( + (mMaxCurlSplits + 2) * 2); + mArrTempShadowVertices = new Array<>( + (mMaxCurlSplits + 2) * 2); + for (int i = 0; i < (mMaxCurlSplits + 2) * 2; ++i) { + mArrTempShadowVertices.add(new ShadowVertex()); + } + } + + // Rectangle consists of 4 vertices. Index 0 = top-left, index 1 = + // bottom-left, index 2 = top-right and index 3 = bottom-right. + for (int i = 0; i < 4; ++i) { + mRectangle[i] = new Vertex(); + } + // Set up shadow penumbra direction to each vertex. We do fake 'self + // shadow' calculations based on this information. + mRectangle[0].mPenumbraX = mRectangle[1].mPenumbraX = mRectangle[1].mPenumbraY = mRectangle[3].mPenumbraY = -1; + mRectangle[0].mPenumbraY = mRectangle[2].mPenumbraX = mRectangle[2].mPenumbraY = mRectangle[3].mPenumbraX = 1; + + if (DRAW_CURL_POSITION) { + mCurlPositionLinesCount = 3; + ByteBuffer hvbb = ByteBuffer + .allocateDirect(mCurlPositionLinesCount * 2 * 2 * 4); + hvbb.order(ByteOrder.nativeOrder()); + mBufCurlPositionLines = hvbb.asFloatBuffer(); + mBufCurlPositionLines.position(0); + } + + // There are 4 vertices from bounding rect, max 2 from adding split line + // to two corners and curl consists of max mMaxCurlSplits lines each + // outputting 2 vertices. + int maxVerticesCount = 4 + 2 + (2 * mMaxCurlSplits); + ByteBuffer vbb = ByteBuffer.allocateDirect(maxVerticesCount * 3 * 4); + vbb.order(ByteOrder.nativeOrder()); + mBufVertices = vbb.asFloatBuffer(); + mBufVertices.position(0); + + if (DRAW_TEXTURE) { + ByteBuffer tbb = ByteBuffer + .allocateDirect(maxVerticesCount * 2 * 4); + tbb.order(ByteOrder.nativeOrder()); + mBufTexCoords = tbb.asFloatBuffer(); + mBufTexCoords.position(0); + } + + ByteBuffer cbb = ByteBuffer.allocateDirect(maxVerticesCount * 4 * 4); + cbb.order(ByteOrder.nativeOrder()); + mBufColors = cbb.asFloatBuffer(); + mBufColors.position(0); + + if (DRAW_SHADOW) { + int maxShadowVerticesCount = (mMaxCurlSplits + 2) * 2 * 2; + ByteBuffer scbb = ByteBuffer + .allocateDirect(maxShadowVerticesCount * 4 * 4); + scbb.order(ByteOrder.nativeOrder()); + mBufShadowColors = scbb.asFloatBuffer(); + mBufShadowColors.position(0); + + ByteBuffer sibb = ByteBuffer + .allocateDirect(maxShadowVerticesCount * 3 * 4); + sibb.order(ByteOrder.nativeOrder()); + mBufShadowVertices = sibb.asFloatBuffer(); + mBufShadowVertices.position(0); + + mDropShadowCount = mSelfShadowCount = 0; + } + } + + /** + * Adds vertex to buffers. + */ + private void addVertex(Vertex vertex) { + mBufVertices.put((float) vertex.mPosX); + mBufVertices.put((float) vertex.mPosY); + mBufVertices.put((float) vertex.mPosZ); + mBufColors.put(vertex.mColorFactor * Color.red(vertex.mColor) / 255f); + mBufColors.put(vertex.mColorFactor * Color.green(vertex.mColor) / 255f); + mBufColors.put(vertex.mColorFactor * Color.blue(vertex.mColor) / 255f); + mBufColors.put(Color.alpha(vertex.mColor) / 255f); + if (DRAW_TEXTURE) { + mBufTexCoords.put((float) vertex.mTexX); + mBufTexCoords.put((float) vertex.mTexY); + } + } + + /** + * Sets curl for this mesh. + * + * @param curlPos Position for curl 'center'. Can be any point on line collinear + * to curl. + * @param curlDir Curl direction, should be normalized. + * @param radius Radius of curl. + */ + public synchronized void curl(PointF curlPos, PointF curlDir, double radius) { + + // First add some 'helper' lines used for development. + if (DRAW_CURL_POSITION) { + mBufCurlPositionLines.position(0); + + mBufCurlPositionLines.put(curlPos.x); + mBufCurlPositionLines.put(curlPos.y - 1.0f); + mBufCurlPositionLines.put(curlPos.x); + mBufCurlPositionLines.put(curlPos.y + 1.0f); + mBufCurlPositionLines.put(curlPos.x - 1.0f); + mBufCurlPositionLines.put(curlPos.y); + mBufCurlPositionLines.put(curlPos.x + 1.0f); + mBufCurlPositionLines.put(curlPos.y); + + mBufCurlPositionLines.put(curlPos.x); + mBufCurlPositionLines.put(curlPos.y); + mBufCurlPositionLines.put(curlPos.x + curlDir.x * 2); + mBufCurlPositionLines.put(curlPos.y + curlDir.y * 2); + + mBufCurlPositionLines.position(0); + } + + // Actual 'curl' implementation starts here. + mBufVertices.position(0); + mBufColors.position(0); + if (DRAW_TEXTURE) { + mBufTexCoords.position(0); + } + + // Calculate curl angle from direction. + double curlAngle = Math.acos(curlDir.x); + curlAngle = curlDir.y > 0 ? -curlAngle : curlAngle; + + // Initiate rotated rectangle which's is translated to curlPos and + // rotated so that curl direction heads to right (1,0). Vertices are + // ordered in ascending order based on x -coordinate at the same time. + // And using y -coordinate in very rare case in which two vertices have + // same x -coordinate. + mArrTempVertices.addAll(mArrRotatedVertices); + mArrRotatedVertices.clear(); + for (int i = 0; i < 4; ++i) { + Vertex v = mArrTempVertices.remove(0); + v.set(mRectangle[i]); + v.translate(-curlPos.x, -curlPos.y); + v.rotateZ(-curlAngle); + int j = 0; + for (; j < mArrRotatedVertices.size(); ++j) { + Vertex v2 = mArrRotatedVertices.get(j); + if (v.mPosX > v2.mPosX) { + break; + } + if (v.mPosX == v2.mPosX && v.mPosY > v2.mPosY) { + break; + } + } + mArrRotatedVertices.add(j, v); + } + + // Rotated rectangle lines/vertex indices. We need to find bounding + // lines for rotated rectangle. After sorting vertices according to + // their x -coordinate we don't have to worry about vertices at indices + // 0 and 1. But due to inaccuracy it's possible vertex 3 is not the + // opposing corner from vertex 0. So we are calculating distance from + // vertex 0 to vertices 2 and 3 - and altering line indices if needed. + // Also vertices/lines are given in an order first one has x -coordinate + // at least the latter one. This property is used in getIntersections to + // see if there is an intersection. + int[][] lines = {{0, 1}, {0, 2}, {1, 3}, {2, 3}}; + { + // TODO: There really has to be more 'easier' way of doing this - + // not including extensive use of sqrt. + Vertex v0 = mArrRotatedVertices.get(0); + Vertex v2 = mArrRotatedVertices.get(2); + Vertex v3 = mArrRotatedVertices.get(3); + double dist2 = Math.sqrt((v0.mPosX - v2.mPosX) + * (v0.mPosX - v2.mPosX) + (v0.mPosY - v2.mPosY) + * (v0.mPosY - v2.mPosY)); + double dist3 = Math.sqrt((v0.mPosX - v3.mPosX) + * (v0.mPosX - v3.mPosX) + (v0.mPosY - v3.mPosY) + * (v0.mPosY - v3.mPosY)); + if (dist2 > dist3) { + lines[1][1] = 3; + lines[2][1] = 2; + } + } + + mVerticesCountFront = mVerticesCountBack = 0; + + if (DRAW_SHADOW) { + mArrTempShadowVertices.addAll(mArrDropShadowVertices); + mArrTempShadowVertices.addAll(mArrSelfShadowVertices); + mArrDropShadowVertices.clear(); + mArrSelfShadowVertices.clear(); + } + + // Length of 'curl' curve. + double curlLength = Math.PI * radius; + // Calculate scan lines. + // TODO: Revisit this code one day. There is room for optimization here. + mArrScanLines.clear(); + if (mMaxCurlSplits > 0) { + mArrScanLines.add((double) 0); + } + for (int i = 1; i < mMaxCurlSplits; ++i) { + mArrScanLines.add((-curlLength * i) / (mMaxCurlSplits - 1)); + } + // As mRotatedVertices is ordered regarding x -coordinate, adding + // this scan line produces scan area picking up vertices which are + // rotated completely. One could say 'until infinity'. + mArrScanLines.add(mArrRotatedVertices.get(3).mPosX - 1); + + // Start from right most vertex. Pretty much the same as first scan area + // is starting from 'infinity'. + double scanXmax = mArrRotatedVertices.get(0).mPosX + 1; + + for (int i = 0; i < mArrScanLines.size(); ++i) { + // Once we have scanXmin and scanXmax we have a scan area to start + // working with. + double scanXmin = mArrScanLines.get(i); + // First iterate 'original' rectangle vertices within scan area. + for (int j = 0; j < mArrRotatedVertices.size(); ++j) { + Vertex v = mArrRotatedVertices.get(j); + // Test if vertex lies within this scan area. + // TODO: Frankly speaking, can't remember why equality check was + // added to both ends. Guessing it was somehow related to case + // where radius=0f, which, given current implementation, could + // be handled much more effectively anyway. + if (v.mPosX >= scanXmin && v.mPosX <= scanXmax) { + // Pop out a vertex from temp vertices. + Vertex n = mArrTempVertices.remove(0); + n.set(v); + // This is done solely for triangulation reasons. Given a + // rotated rectangle it has max 2 vertices having + // intersection. + Array intersections = getIntersections( + mArrRotatedVertices, lines, n.mPosX); + // In a sense one could say we're adding vertices always in + // two, positioned at the ends of intersecting line. And for + // triangulation to work properly they are added based on y + // -coordinate. And this if-else is doing it for us. + if (intersections.size() == 1 + && intersections.get(0).mPosY > v.mPosY) { + // In case intersecting vertex is higher add it first. + mArrOutputVertices.addAll(intersections); + mArrOutputVertices.add(n); + } else if (intersections.size() <= 1) { + // Otherwise add original vertex first. + mArrOutputVertices.add(n); + mArrOutputVertices.addAll(intersections); + } else { + // There should never be more than 1 intersecting + // vertex. But if it happens as a fallback simply skip + // everything. + mArrTempVertices.add(n); + mArrTempVertices.addAll(intersections); + } + } + } + + // Search for scan line intersections. + Array intersections = getIntersections(mArrRotatedVertices, + lines, scanXmin); + + // We expect to get 0 or 2 vertices. In rare cases there's only one + // but in general given a scan line intersecting rectangle there + // should be 2 intersecting vertices. + if (intersections.size() == 2) { + // There were two intersections, add them based on y + // -coordinate, higher first, lower last. + Vertex v1 = intersections.get(0); + Vertex v2 = intersections.get(1); + if (v1.mPosY < v2.mPosY) { + mArrOutputVertices.add(v2); + mArrOutputVertices.add(v1); + } else { + mArrOutputVertices.addAll(intersections); + } + } else if (intersections.size() != 0) { + // This happens in a case in which there is a original vertex + // exactly at scan line or something went very much wrong if + // there are 3+ vertices. What ever the reason just return the + // vertices to temp vertices for later use. In former case it + // was handled already earlier once iterating through + // mRotatedVertices, in latter case it's better to avoid doing + // anything with them. + mArrTempVertices.addAll(intersections); + } + + // Add vertices found during this iteration to vertex etc buffers. + while (mArrOutputVertices.size() > 0) { + Vertex v = mArrOutputVertices.remove(0); + mArrTempVertices.add(v); + + // Local texture front-facing flag. + boolean textureFront; + + // Untouched vertices. + if (i == 0) { + textureFront = true; + mVerticesCountFront++; + } + // 'Completely' rotated vertices. + else if (i == mArrScanLines.size() - 1 || curlLength == 0) { + v.mPosX = -(curlLength + v.mPosX); + v.mPosZ = 2 * radius; + v.mPenumbraX = -v.mPenumbraX; + + textureFront = false; + mVerticesCountBack++; + } + // Vertex lies within 'curl'. + else { + // Even though it's not obvious from the if-else clause, + // here v.mPosX is between [-curlLength, 0]. And we can do + // calculations around a half cylinder. + double rotY = Math.PI * (v.mPosX / curlLength); + v.mPosX = radius * Math.sin(rotY); + v.mPosZ = radius - (radius * Math.cos(rotY)); + v.mPenumbraX *= Math.cos(rotY); + // Map color multiplier to [.1f, 1f] range. + v.mColorFactor = (float) (.1f + .9f * Math.sqrt(Math + .sin(rotY) + 1)); + + if (v.mPosZ >= radius) { + textureFront = false; + mVerticesCountBack++; + } else { + textureFront = true; + mVerticesCountFront++; + } + } + + // We use local textureFront for flipping backside texture + // locally. Plus additionally if mesh is in flip texture mode, + // we'll make the procedure "backwards". Also, until this point, + // texture coordinates are within [0, 1] range so we'll adjust + // them to final texture coordinates too. + if (textureFront != mFlipTexture) { + v.mTexX *= mTextureRectFront.right; + v.mTexY *= mTextureRectFront.bottom; + v.mColor = mTexturePage.getColor(CurlPage.SIDE_FRONT); + } else { + v.mTexX *= mTextureRectBack.right; + v.mTexY *= mTextureRectBack.bottom; + v.mColor = mTexturePage.getColor(CurlPage.SIDE_BACK); + } + + // Move vertex back to 'world' coordinates. + v.rotateZ(curlAngle); + v.translate(curlPos.x, curlPos.y); + addVertex(v); + + // Drop shadow is cast 'behind' the curl. + if (DRAW_SHADOW && v.mPosZ > 0 && v.mPosZ <= radius) { + ShadowVertex sv = mArrTempShadowVertices.remove(0); + sv.mPosX = v.mPosX; + sv.mPosY = v.mPosY; + sv.mPosZ = v.mPosZ; + sv.mPenumbraX = (v.mPosZ / 2) * -curlDir.x; + sv.mPenumbraY = (v.mPosZ / 2) * -curlDir.y; + sv.mPenumbraColor = v.mPosZ / radius; + int idx = (mArrDropShadowVertices.size() + 1) / 2; + mArrDropShadowVertices.add(idx, sv); + } + // Self shadow is cast partly over mesh. + if (DRAW_SHADOW && v.mPosZ > radius) { + ShadowVertex sv = mArrTempShadowVertices.remove(0); + sv.mPosX = v.mPosX; + sv.mPosY = v.mPosY; + sv.mPosZ = v.mPosZ; + sv.mPenumbraX = ((v.mPosZ - radius) / 3) * v.mPenumbraX; + sv.mPenumbraY = ((v.mPosZ - radius) / 3) * v.mPenumbraY; + sv.mPenumbraColor = (v.mPosZ - radius) / (2 * radius); + int idx = (mArrSelfShadowVertices.size() + 1) / 2; + mArrSelfShadowVertices.add(idx, sv); + } + } + + // Switch scanXmin as scanXmax for next iteration. + scanXmax = scanXmin; + } + + mBufVertices.position(0); + mBufColors.position(0); + if (DRAW_TEXTURE) { + mBufTexCoords.position(0); + } + + // Add shadow Vertices. + if (DRAW_SHADOW) { + mBufShadowColors.position(0); + mBufShadowVertices.position(0); + mDropShadowCount = 0; + + for (int i = 0; i < mArrDropShadowVertices.size(); ++i) { + ShadowVertex sv = mArrDropShadowVertices.get(i); + mBufShadowVertices.put((float) sv.mPosX); + mBufShadowVertices.put((float) sv.mPosY); + mBufShadowVertices.put((float) sv.mPosZ); + mBufShadowVertices.put((float) (sv.mPosX + sv.mPenumbraX)); + mBufShadowVertices.put((float) (sv.mPosY + sv.mPenumbraY)); + mBufShadowVertices.put((float) sv.mPosZ); + for (int j = 0; j < 4; ++j) { + double color = SHADOW_OUTER_COLOR[j] + + (SHADOW_INNER_COLOR[j] - SHADOW_OUTER_COLOR[j]) + * sv.mPenumbraColor; + mBufShadowColors.put((float) color); + } + mBufShadowColors.put(SHADOW_OUTER_COLOR); + mDropShadowCount += 2; + } + mSelfShadowCount = 0; + for (int i = 0; i < mArrSelfShadowVertices.size(); ++i) { + ShadowVertex sv = mArrSelfShadowVertices.get(i); + mBufShadowVertices.put((float) sv.mPosX); + mBufShadowVertices.put((float) sv.mPosY); + mBufShadowVertices.put((float) sv.mPosZ); + mBufShadowVertices.put((float) (sv.mPosX + sv.mPenumbraX)); + mBufShadowVertices.put((float) (sv.mPosY + sv.mPenumbraY)); + mBufShadowVertices.put((float) sv.mPosZ); + for (int j = 0; j < 4; ++j) { + double color = SHADOW_OUTER_COLOR[j] + + (SHADOW_INNER_COLOR[j] - SHADOW_OUTER_COLOR[j]) + * sv.mPenumbraColor; + mBufShadowColors.put((float) color); + } + mBufShadowColors.put(SHADOW_OUTER_COLOR); + mSelfShadowCount += 2; + } + mBufShadowColors.position(0); + mBufShadowVertices.position(0); + } + } + + /** + * Calculates intersections for given scan line. + */ + private Array getIntersections(Array vertices, + int[][] lineIndices, double scanX) { + mArrIntersections.clear(); + // Iterate through rectangle lines each re-presented as a pair of + // vertices. + for (int[] lineIndex : lineIndices) { + Vertex v1 = vertices.get(lineIndex[0]); + Vertex v2 = vertices.get(lineIndex[1]); + // Here we expect that v1.mPosX >= v2.mPosX and wont do intersection + // test the opposite way. + if (v1.mPosX > scanX && v2.mPosX < scanX) { + // There is an intersection, calculate coefficient telling 'how + // far' scanX is from v2. + double c = (scanX - v2.mPosX) / (v1.mPosX - v2.mPosX); + Vertex n = mArrTempVertices.remove(0); + n.set(v2); + n.mPosX = scanX; + n.mPosY += (v1.mPosY - v2.mPosY) * c; + if (DRAW_TEXTURE) { + n.mTexX += (v1.mTexX - v2.mTexX) * c; + n.mTexY += (v1.mTexY - v2.mTexY) * c; + } + if (DRAW_SHADOW) { + n.mPenumbraX += (v1.mPenumbraX - v2.mPenumbraX) * c; + n.mPenumbraY += (v1.mPenumbraY - v2.mPenumbraY) * c; + } + mArrIntersections.add(n); + } + } + return mArrIntersections; + } + + /** + * Getter for textures page for this mesh. + */ + public synchronized CurlPage getTexturePage() { + return mTexturePage; + } + + /** + * Renders our page curl mesh. + */ + public synchronized void onDrawFrame(GL10 gl) { + // First allocate texture if there is not one yet. + if (DRAW_TEXTURE && mTextureIds == null) { + // Generate texture. + mTextureIds = new int[2]; + gl.glGenTextures(2, mTextureIds, 0); + for (int textureId : mTextureIds) { + // Set texture attributes. + gl.glBindTexture(GL10.GL_TEXTURE_2D, textureId); + gl.glTexParameterf(GL10.GL_TEXTURE_2D, + GL10.GL_TEXTURE_MIN_FILTER, GL10.GL_NEAREST); + gl.glTexParameterf(GL10.GL_TEXTURE_2D, + GL10.GL_TEXTURE_MAG_FILTER, GL10.GL_NEAREST); + gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_WRAP_S, + GL10.GL_CLAMP_TO_EDGE); + gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_WRAP_T, + GL10.GL_CLAMP_TO_EDGE); + } + } + + if (DRAW_TEXTURE && mTexturePage.getTexturesChanged()) { + gl.glBindTexture(GL10.GL_TEXTURE_2D, mTextureIds[0]); + Bitmap texture = mTexturePage.getTexture(mTextureRectFront, + CurlPage.SIDE_FRONT); + GLUtils.texImage2D(GL10.GL_TEXTURE_2D, 0, texture, 0); + texture.recycle(); + + mTextureBack = mTexturePage.hasBackTexture(); + if (mTextureBack) { + gl.glBindTexture(GL10.GL_TEXTURE_2D, mTextureIds[1]); + texture = mTexturePage.getTexture(mTextureRectBack, + CurlPage.SIDE_BACK); + GLUtils.texImage2D(GL10.GL_TEXTURE_2D, 0, texture, 0); + texture.recycle(); + } else { + mTextureRectBack.set(mTextureRectFront); + } + + mTexturePage.recycle(); + reset(); + } + + // Some 'global' settings. + gl.glEnableClientState(GL10.GL_VERTEX_ARRAY); + + // TODO: Drop shadow drawing is done temporarily here to hide some + // problems with its calculation. + if (DRAW_SHADOW) { + gl.glDisable(GL10.GL_TEXTURE_2D); + gl.glEnable(GL10.GL_BLEND); + gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA); + gl.glEnableClientState(GL10.GL_COLOR_ARRAY); + gl.glColorPointer(4, GL10.GL_FLOAT, 0, mBufShadowColors); + gl.glVertexPointer(3, GL10.GL_FLOAT, 0, mBufShadowVertices); + gl.glDrawArrays(GL10.GL_TRIANGLE_STRIP, 0, mDropShadowCount); + gl.glDisableClientState(GL10.GL_COLOR_ARRAY); + gl.glDisable(GL10.GL_BLEND); + } + + if (DRAW_TEXTURE) { + gl.glEnableClientState(GL10.GL_TEXTURE_COORD_ARRAY); + gl.glTexCoordPointer(2, GL10.GL_FLOAT, 0, mBufTexCoords); + } + gl.glVertexPointer(3, GL10.GL_FLOAT, 0, mBufVertices); + // Enable color array. + gl.glEnableClientState(GL10.GL_COLOR_ARRAY); + gl.glColorPointer(4, GL10.GL_FLOAT, 0, mBufColors); + + // Draw front facing blank vertices. + gl.glDisable(GL10.GL_TEXTURE_2D); + gl.glDrawArrays(GL10.GL_TRIANGLE_STRIP, 0, mVerticesCountFront); + + // Draw front facing texture. + if (DRAW_TEXTURE) { + gl.glEnable(GL10.GL_BLEND); + gl.glEnable(GL10.GL_TEXTURE_2D); + + if (!mFlipTexture || !mTextureBack) { + gl.glBindTexture(GL10.GL_TEXTURE_2D, mTextureIds[0]); + } else { + gl.glBindTexture(GL10.GL_TEXTURE_2D, mTextureIds[1]); + } + + gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA); + gl.glDrawArrays(GL10.GL_TRIANGLE_STRIP, 0, mVerticesCountFront); + + gl.glDisable(GL10.GL_BLEND); + gl.glDisable(GL10.GL_TEXTURE_2D); + } + + int backStartIdx = Math.max(0, mVerticesCountFront - 2); + int backCount = mVerticesCountFront + mVerticesCountBack - backStartIdx; + + // Draw back facing blank vertices. + gl.glDrawArrays(GL10.GL_TRIANGLE_STRIP, backStartIdx, backCount); + + // Draw back facing texture. + if (DRAW_TEXTURE) { + gl.glEnable(GL10.GL_BLEND); + gl.glEnable(GL10.GL_TEXTURE_2D); + + if (mFlipTexture || !mTextureBack) { + gl.glBindTexture(GL10.GL_TEXTURE_2D, mTextureIds[0]); + } else { + gl.glBindTexture(GL10.GL_TEXTURE_2D, mTextureIds[1]); + } + + gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA); + gl.glDrawArrays(GL10.GL_TRIANGLE_STRIP, backStartIdx, backCount); + + gl.glDisable(GL10.GL_BLEND); + gl.glDisable(GL10.GL_TEXTURE_2D); + } + + // Disable textures and color array. + gl.glDisableClientState(GL10.GL_TEXTURE_COORD_ARRAY); + gl.glDisableClientState(GL10.GL_COLOR_ARRAY); + + if (DRAW_POLYGON_OUTLINES) { + gl.glEnable(GL10.GL_BLEND); + gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA); + gl.glLineWidth(1.0f); + gl.glColor4f(0.5f, 0.5f, 1.0f, 1.0f); + gl.glVertexPointer(3, GL10.GL_FLOAT, 0, mBufVertices); + gl.glDrawArrays(GL10.GL_LINE_STRIP, 0, mVerticesCountFront); + gl.glDisable(GL10.GL_BLEND); + } + + if (DRAW_CURL_POSITION) { + gl.glEnable(GL10.GL_BLEND); + gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA); + gl.glLineWidth(1.0f); + gl.glColor4f(1.0f, 0.5f, 0.5f, 1.0f); + gl.glVertexPointer(2, GL10.GL_FLOAT, 0, mBufCurlPositionLines); + gl.glDrawArrays(GL10.GL_LINES, 0, mCurlPositionLinesCount * 2); + gl.glDisable(GL10.GL_BLEND); + } + + if (DRAW_SHADOW) { + gl.glEnable(GL10.GL_BLEND); + gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA); + gl.glEnableClientState(GL10.GL_COLOR_ARRAY); + gl.glColorPointer(4, GL10.GL_FLOAT, 0, mBufShadowColors); + gl.glVertexPointer(3, GL10.GL_FLOAT, 0, mBufShadowVertices); + gl.glDrawArrays(GL10.GL_TRIANGLE_STRIP, mDropShadowCount, + mSelfShadowCount); + gl.glDisableClientState(GL10.GL_COLOR_ARRAY); + gl.glDisable(GL10.GL_BLEND); + } + + gl.glDisableClientState(GL10.GL_VERTEX_ARRAY); + } + + /** + * Resets mesh to 'initial' state. Meaning this mesh will draw a plain + * textured rectangle after call to this method. + */ + public synchronized void reset() { + mBufVertices.position(0); + mBufColors.position(0); + if (DRAW_TEXTURE) { + mBufTexCoords.position(0); + } + for (int i = 0; i < 4; ++i) { + Vertex tmp = mArrTempVertices.get(0); + tmp.set(mRectangle[i]); + + if (mFlipTexture) { + tmp.mTexX *= mTextureRectBack.right; + tmp.mTexY *= mTextureRectBack.bottom; + tmp.mColor = mTexturePage.getColor(CurlPage.SIDE_BACK); + } else { + tmp.mTexX *= mTextureRectFront.right; + tmp.mTexY *= mTextureRectFront.bottom; + tmp.mColor = mTexturePage.getColor(CurlPage.SIDE_FRONT); + } + + addVertex(tmp); + } + mVerticesCountFront = 4; + mVerticesCountBack = 0; + mBufVertices.position(0); + mBufColors.position(0); + if (DRAW_TEXTURE) { + mBufTexCoords.position(0); + } + + mDropShadowCount = mSelfShadowCount = 0; + } + + /** + * Resets allocated texture id forcing creation of new one. After calling + * this method you most likely want to set bitmap too as it's lost. This + * method should be called only once e.g GL context is re-created as this + * method does not release previous texture id, only makes sure new one is + * requested on next render. + */ + public synchronized void resetTexture() { + mTextureIds = null; + } + + /** + * If true, flips texture sideways. + */ + public synchronized void setFlipTexture(boolean flipTexture) { + mFlipTexture = flipTexture; + if (flipTexture) { + setTexCoords(1f, 0f, 0f, 1f); + } else { + setTexCoords(0f, 0f, 1f, 1f); + } + } + + /** + * Update mesh bounds. + */ + public void setRect(RectF r) { + mRectangle[0].mPosX = r.left; + mRectangle[0].mPosY = r.top; + mRectangle[1].mPosX = r.left; + mRectangle[1].mPosY = r.bottom; + mRectangle[2].mPosX = r.right; + mRectangle[2].mPosY = r.top; + mRectangle[3].mPosX = r.right; + mRectangle[3].mPosY = r.bottom; + } + + /** + * Sets texture coordinates to mRectangle vertices. + */ + private synchronized void setTexCoords(float left, float top, float right, + float bottom) { + mRectangle[0].mTexX = left; + mRectangle[0].mTexY = top; + mRectangle[1].mTexX = left; + mRectangle[1].mTexY = bottom; + mRectangle[2].mTexX = right; + mRectangle[2].mTexY = top; + mRectangle[3].mTexX = right; + mRectangle[3].mTexY = bottom; + } + + /** + * Simple fixed size array implementation. + */ + private class Array { + private Object[] mArray; + private int mCapacity; + private int mSize; + + Array(int capacity) { + mCapacity = capacity; + mArray = new Object[capacity]; + } + + public void add(int index, T item) { + if (index < 0 || index > mSize || mSize >= mCapacity) { + throw new IndexOutOfBoundsException(); + } + System.arraycopy(mArray, index, mArray, index + 1, mSize - index); + mArray[index] = item; + ++mSize; + } + + public void add(T item) { + if (mSize >= mCapacity) { + throw new IndexOutOfBoundsException(); + } + mArray[mSize++] = item; + } + + public void addAll(Array array) { + if (mSize + array.size() > mCapacity) { + throw new IndexOutOfBoundsException(); + } + for (int i = 0; i < array.size(); ++i) { + mArray[mSize++] = array.get(i); + } + } + + public void clear() { + mSize = 0; + } + + @SuppressWarnings("unchecked") + public T get(int index) { + if (index < 0 || index >= mSize) { + throw new IndexOutOfBoundsException(); + } + return (T) mArray[index]; + } + + @SuppressWarnings("unchecked") + public T remove(int index) { + if (index < 0 || index >= mSize) { + throw new IndexOutOfBoundsException(); + } + T item = (T) mArray[index]; + if (mSize - 1 - index >= 0) + System.arraycopy(mArray, index + 1, mArray, index, mSize - 1 - index); + --mSize; + return item; + } + + public int size() { + return mSize; + } + + } + + /** + * Holder for shadow vertex information. + */ + private class ShadowVertex { + double mPenumbraColor; + double mPenumbraX; + double mPenumbraY; + double mPosX; + double mPosY; + double mPosZ; + } + + /** + * Holder for vertex information. + */ + private class Vertex { + int mColor; + float mColorFactor; + double mPenumbraX; + double mPenumbraY; + double mPosX; + double mPosY; + double mPosZ; + double mTexX; + double mTexY; + + Vertex() { + mPosX = mPosY = mPosZ = mTexX = mTexY = 0; + mColorFactor = 1.0f; + } + + void rotateZ(double theta) { + double cos = Math.cos(theta); + double sin = Math.sin(theta); + double x = mPosX * cos + mPosY * sin; + double y = mPosX * -sin + mPosY * cos; + mPosX = x; + mPosY = y; + double px = mPenumbraX * cos + mPenumbraY * sin; + double py = mPenumbraX * -sin + mPenumbraY * cos; + mPenumbraX = px; + mPenumbraY = py; + } + + public void set(Vertex vertex) { + mPosX = vertex.mPosX; + mPosY = vertex.mPosY; + mPosZ = vertex.mPosZ; + mTexX = vertex.mTexX; + mTexY = vertex.mTexY; + mPenumbraX = vertex.mPenumbraX; + mPenumbraY = vertex.mPenumbraY; + mColor = vertex.mColor; + mColorFactor = vertex.mColorFactor; + } + + public void translate(double dx, double dy) { + mPosX += dx; + mPosY += dy; + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/page/curl/CurlPage.kt b/app/src/main/java/io/legado/app/ui/widget/page/curl/CurlPage.kt new file mode 100644 index 000000000..5f6ad5bb1 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/page/curl/CurlPage.kt @@ -0,0 +1,191 @@ +package io.legado.app.ui.widget.page.curl + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.RectF + +/** + * Storage class for page textures, blend colors and possibly some other values + * in the future. + * + * @author harism + */ +class CurlPage { + + private var mColorBack: Int = 0 + private var mColorFront: Int = 0 + private var mTextureBack: Bitmap? = null + private var mTextureFront: Bitmap? = null + /** + * Returns true if textures have changed. + */ + var texturesChanged: Boolean = false + private set + + /** + * Default constructor. + */ + init { + reset() + } + + /** + * Getter for color. + */ + fun getColor(side: Int): Int { + return when (side) { + SIDE_FRONT -> mColorFront + else -> mColorBack + } + } + + /** + * Calculates the next highest power of two for a given integer. + */ + private fun getNextHighestPO2(n: Int): Int { + var n1 = n + n1 -= 1 + n1 = n1 or (n1 shr 1) + n1 = n1 or (n1 shr 2) + n1 = n1 or (n1 shr 4) + n1 = n1 or (n1 shr 8) + n1 = n1 or (n1 shr 16) + n1 = n1 or (n1 shr 32) + return n1 + 1 + } + + /** + * Generates nearest power of two sized Bitmap for give Bitmap. Returns this + * new Bitmap using default return statement + original texture coordinates + * are stored into RectF. + */ + private fun getTexture(bitmap: Bitmap, textureRect: RectF): Bitmap { + // Bitmap original size. + val w = bitmap.width + val h = bitmap.height + // Bitmap size expanded to next power of two. This is done due to + // the requirement on many devices, texture width and height should + // be power of two. + val newW = getNextHighestPO2(w) + val newH = getNextHighestPO2(h) + + // TODO: Is there another way to create a bigger Bitmap and copy + // original Bitmap to it more efficiently? Immutable bitmap anyone? + val bitmapTex = Bitmap.createBitmap(newW, newH, bitmap.config) + val c = Canvas(bitmapTex) + c.drawBitmap(bitmap, 0f, 0f, null) + + // Calculate final texture coordinates. + val texX = w.toFloat() / newW + val texY = h.toFloat() / newH + textureRect.set(0f, 0f, texX, texY) + + return bitmapTex + } + + /** + * Getter for textures. Creates Bitmap sized to nearest power of two, copies + * original Bitmap into it and returns it. RectF given as parameter is + * filled with actual texture coordinates in this new upscaled texture + * Bitmap. + */ + fun getTexture(textureRect: RectF, side: Int): Bitmap { + return when (side) { + SIDE_FRONT -> getTexture(mTextureFront!!, textureRect) + else -> getTexture(mTextureBack!!, textureRect) + } + } + + /** + * Returns true if back siding texture exists and it differs from front + * facing one. + */ + fun hasBackTexture(): Boolean { + return mTextureFront != mTextureBack + } + + /** + * Recycles and frees underlying Bitmaps. + */ + fun recycle() { + if (mTextureFront != null) { + mTextureFront!!.recycle() + } + mTextureFront = Bitmap.createBitmap(1, 1, Bitmap.Config.RGB_565) + mTextureFront!!.eraseColor(mColorFront) + if (mTextureBack != null) { + mTextureBack!!.recycle() + } + mTextureBack = Bitmap.createBitmap(1, 1, Bitmap.Config.RGB_565) + mTextureBack!!.eraseColor(mColorBack) + texturesChanged = false + } + + /** + * Resets this CurlPage into its initial state. + */ + fun reset() { + mColorBack = Color.WHITE + mColorFront = Color.WHITE + recycle() + } + + /** + * Setter blend color. + */ + fun setColor(color: Int, side: Int) { + when (side) { + SIDE_FRONT -> mColorFront = color + SIDE_BACK -> mColorBack = color + else -> { + mColorBack = color + mColorFront = mColorBack + } + } + } + + /** + * Setter for textures. + */ + fun setTexture(texture: Bitmap?, side: Int) { + var texture1 = texture + if (texture1 == null) { + texture1 = Bitmap.createBitmap(1, 1, Bitmap.Config.RGB_565) + if (side == SIDE_BACK) { + texture1!!.eraseColor(mColorBack) + } else { + texture1!!.eraseColor(mColorFront) + } + } + when (side) { + SIDE_FRONT -> { + if (mTextureFront != null) + mTextureFront!!.recycle() + mTextureFront = texture1 + } + SIDE_BACK -> { + if (mTextureBack != null) + mTextureBack!!.recycle() + mTextureBack = texture1 + } + SIDE_BOTH -> { + if (mTextureFront != null) + mTextureFront!!.recycle() + if (mTextureBack != null) + mTextureBack!!.recycle() + mTextureBack = texture1 + mTextureFront = mTextureBack + } + } + texturesChanged = true + } + + companion object { + + const val SIDE_BACK = 2 + const val SIDE_BOTH = 3 + const val SIDE_FRONT = 1 + } + +} diff --git a/app/src/main/java/io/legado/app/ui/widget/page/curl/CurlRenderer.kt b/app/src/main/java/io/legado/app/ui/widget/page/curl/CurlRenderer.kt new file mode 100644 index 000000000..dc0e124d5 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/page/curl/CurlRenderer.kt @@ -0,0 +1,252 @@ +package io.legado.app.ui.widget.page.curl + +import android.graphics.Color +import android.graphics.PointF +import android.graphics.RectF +import android.opengl.GLSurfaceView +import android.opengl.GLU +import java.util.* +import javax.microedition.khronos.egl.EGLConfig +import javax.microedition.khronos.opengles.GL10 + +/** + * Actual renderer class. + * + * @author harism + */ +class CurlRenderer +/** + * Basic constructor. + */ + (private val mObserver: Observer) : GLSurfaceView.Renderer { + // Background fill color. + private var mBackgroundColor: Int = 0 + // Curl meshes used for static and dynamic rendering. + private val mCurlMeshes: Vector = Vector() + private val mMargins = RectF() + // Page rectangles. + private val mPageRectLeft: RectF = RectF() + private val mPageRectRight: RectF = RectF() + // View mode. + private var mViewMode = SHOW_ONE_PAGE + // Screen size. + private var mViewportWidth: Int = 0 + private var mViewportHeight: Int = 0 + // Rect for render area. + private val mViewRect = RectF() + + /** + * Adds CurlMesh to this renderer. + */ + @Synchronized + fun addCurlMesh(mesh: CurlMesh) { + removeCurlMesh(mesh) + mCurlMeshes.add(mesh) + } + + /** + * Returns rect reserved for left or right page. Value page should be + * PAGE_LEFT or PAGE_RIGHT. + */ + fun getPageRect(page: Int): RectF? { + if (page == PAGE_LEFT) { + return mPageRectLeft + } else if (page == PAGE_RIGHT) { + return mPageRectRight + } + return null + } + + @Synchronized + override fun onDrawFrame(gl: GL10) { + + mObserver.onDrawFrame() + + gl.glClearColor( + Color.red(mBackgroundColor) / 255f, + Color.green(mBackgroundColor) / 255f, + Color.blue(mBackgroundColor) / 255f, + Color.alpha(mBackgroundColor) / 255f + ) + gl.glClear(GL10.GL_COLOR_BUFFER_BIT) + gl.glLoadIdentity() + + if (USE_PERSPECTIVE_PROJECTION) { + gl.glTranslatef(0f, 0f, -6f) + } + + for (i in mCurlMeshes.indices) { + mCurlMeshes[i].onDrawFrame(gl) + } + } + + override fun onSurfaceChanged(gl: GL10, width: Int, height: Int) { + gl.glViewport(0, 0, width, height) + mViewportWidth = width + mViewportHeight = height + + val ratio = width.toFloat() / height + mViewRect.top = 1.0f + mViewRect.bottom = -1.0f + mViewRect.left = -ratio + mViewRect.right = ratio + updatePageRects() + + gl.glMatrixMode(GL10.GL_PROJECTION) + gl.glLoadIdentity() + if (USE_PERSPECTIVE_PROJECTION) { + GLU.gluPerspective(gl, 20f, width.toFloat() / height, .1f, 100f) + } else { + GLU.gluOrtho2D( + gl, mViewRect.left, mViewRect.right, + mViewRect.bottom, mViewRect.top + ) + } + + gl.glMatrixMode(GL10.GL_MODELVIEW) + gl.glLoadIdentity() + } + + override fun onSurfaceCreated(gl: GL10, config: EGLConfig) { + gl.glClearColor(0f, 0f, 0f, 1f) + gl.glShadeModel(GL10.GL_SMOOTH) + gl.glHint(GL10.GL_PERSPECTIVE_CORRECTION_HINT, GL10.GL_NICEST) + gl.glHint(GL10.GL_LINE_SMOOTH_HINT, GL10.GL_NICEST) + gl.glHint(GL10.GL_POLYGON_SMOOTH_HINT, GL10.GL_NICEST) + gl.glEnable(GL10.GL_LINE_SMOOTH) + gl.glDisable(GL10.GL_DEPTH_TEST) + gl.glDisable(GL10.GL_CULL_FACE) + + mObserver.onSurfaceCreated() + } + + /** + * Removes CurlMesh from this renderer. + */ + @Synchronized + fun removeCurlMesh(mesh: CurlMesh) { + mCurlMeshes.remove(mesh) + } + + /** + * Change background/clear color. + */ + fun setBackgroundColor(color: Int) { + mBackgroundColor = color + } + + /** + * Set margins or padding. Note: margins are proportional. Meaning a value + * of .1f will produce a 10% margin. + */ + @Synchronized + fun setMargins( + left: Float, top: Float, right: Float, + bottom: Float + ) { + mMargins.left = left + mMargins.top = top + mMargins.right = right + mMargins.bottom = bottom + updatePageRects() + } + + /** + * Sets visible page count to one or two. Should be either SHOW_ONE_PAGE or + * SHOW_TWO_PAGES. + */ + @Synchronized + fun setViewMode(viewmode: Int) { + if (viewmode == SHOW_ONE_PAGE) { + mViewMode = viewmode + updatePageRects() + } else if (viewmode == SHOW_TWO_PAGES) { + mViewMode = viewmode + updatePageRects() + } + } + + /** + * Translates screen coordinates into view coordinates. + */ + fun translate(pt: PointF) { + pt.x = mViewRect.left + mViewRect.width() * pt.x / mViewportWidth + pt.y = mViewRect.top - -mViewRect.height() * pt.y / mViewportHeight + } + + /** + * Recalculates page rectangles. + */ + private fun updatePageRects() { + if (mViewRect.width() == 0f || mViewRect.height() == 0f) { + return + } else if (mViewMode == SHOW_ONE_PAGE) { + mPageRectRight.set(mViewRect) + mPageRectRight.left += mViewRect.width() * mMargins.left + mPageRectRight.right -= mViewRect.width() * mMargins.right + mPageRectRight.top += mViewRect.height() * mMargins.top + mPageRectRight.bottom -= mViewRect.height() * mMargins.bottom + + mPageRectLeft.set(mPageRectRight) + mPageRectLeft.offset(-mPageRectRight.width(), 0f) + + val bitmapW = (mPageRectRight.width() * mViewportWidth / mViewRect + .width()).toInt() + val bitmapH = (mPageRectRight.height() * mViewportHeight / mViewRect + .height()).toInt() + mObserver.onPageSizeChanged(bitmapW, bitmapH) + } else if (mViewMode == SHOW_TWO_PAGES) { + mPageRectRight.set(mViewRect) + mPageRectRight.left += mViewRect.width() * mMargins.left + mPageRectRight.right -= mViewRect.width() * mMargins.right + mPageRectRight.top += mViewRect.height() * mMargins.top + mPageRectRight.bottom -= mViewRect.height() * mMargins.bottom + + mPageRectLeft.set(mPageRectRight) + mPageRectLeft.right = (mPageRectLeft.right + mPageRectLeft.left) / 2 + mPageRectRight.left = mPageRectLeft.right + + val bitmapW = (mPageRectRight.width() * mViewportWidth / mViewRect + .width()).toInt() + val bitmapH = (mPageRectRight.height() * mViewportHeight / mViewRect + .height()).toInt() + mObserver.onPageSizeChanged(bitmapW, bitmapH) + } + } + + /** + * Observer for waiting render engine/state updates. + */ + interface Observer { + /** + * Called from onDrawFrame called before rendering is started. This is + * intended to be used for animation purposes. + */ + fun onDrawFrame() + + /** + * Called once page size is changed. Width and height tell the page size + * in pixels making it possible to update textures accordingly. + */ + fun onPageSizeChanged(width: Int, height: Int) + + /** + * Called from onSurfaceCreated to enable texture re-initialization etc + * what needs to be done when this happens. + */ + fun onSurfaceCreated() + } + + companion object { + + // Constant for requesting left page rect. + const val PAGE_LEFT = 1 + // Constant for requesting right page rect. + const val PAGE_RIGHT = 2 + // Constants for changing view mode. + const val SHOW_ONE_PAGE = 1 + const val SHOW_TWO_PAGES = 2 + // Set to true for checking quickly how perspective projection looks. + private const val USE_PERSPECTIVE_PROJECTION = false + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/page/curl/CurlView.kt b/app/src/main/java/io/legado/app/ui/widget/page/curl/CurlView.kt new file mode 100644 index 000000000..a6089c4bc --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/page/curl/CurlView.kt @@ -0,0 +1,766 @@ +package io.legado.app.ui.widget.page.curl + +import android.content.Context +import android.graphics.PixelFormat +import android.graphics.PointF +import android.opengl.GLSurfaceView +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * OpenGL ES View. + * + * @author harism + */ +class CurlView : GLSurfaceView, View.OnTouchListener, CurlRenderer.Observer { + + private var mAllowLastPageCurl = true + + private var mAnimate = false + private val mAnimationDurationTime: Long = 300 + private val mAnimationSource = PointF() + private var mAnimationStartTime: Long = 0 + private val mAnimationTarget = PointF() + private var mAnimationTargetEvent: Int = 0 + + private val mCurlDir = PointF() + + private val mCurlPos = PointF() + private var mCurlState = CURL_NONE + // Current bitmap index. This is always showed as front of right page. + private var mCurrentIndex = 0 + + // Start position for dragging. + private val mDragStartPos = PointF() + + private var mEnableTouchPressure = false + // Bitmap size. These are updated from renderer once it's initialized. + private var mPageBitmapHeight = -1 + + private var mPageBitmapWidth = -1 + // Page meshes. Left and right meshes are 'static' while curl is used to + // show page flipping. + private var mPageCurl: CurlMesh + private var mPageLeft: CurlMesh + private var mPageRight: CurlMesh + + private val mPointerPos = PointerPosition() + + private var mRenderer: CurlRenderer = CurlRenderer(this) + private var mRenderLeftPage = true + private var mSizeChangedObserver: SizeChangedObserver? = null + + // One page is the default. + private var mViewMode = SHOW_ONE_PAGE + + var mPageProvider: PageProvider? = null + set(value) { + field = value + mCurrentIndex = 0 + updatePages() + requestRender() + } + + /** + * Get current page index. Page indices are zero based values presenting + * page being shown on right side of the book. + */ + /** + * Set current page index. Page indices are zero based values presenting + * page being shown on right side of the book. E.g if you set value to 4; + * right side front facing bitmap will be with index 4, back facing 5 and + * for left side page index 3 is front facing, and index 2 back facing (once + * page is on left side it's flipped over). + * + * + * Current index is rounded to closest value divisible with 2. + */ + var currentIndex: Int + get() = mCurrentIndex + set(index) { + mCurrentIndex = if (mPageProvider == null || index < 0) { + 0 + } else { + if (mAllowLastPageCurl) { + min(index, mPageProvider!!.pageCount) + } else { + min(index, mPageProvider!!.pageCount - 1) + } + } + updatePages() + requestRender() + } + + /** + * Default constructor. + */ + constructor(ctx: Context) : super(ctx) + + /** + * Default constructor. + */ + constructor(ctx: Context, attrs: AttributeSet) : super(ctx, attrs) + + /** + * Default constructor. + */ + constructor(ctx: Context, attrs: AttributeSet, defStyle: Int) : this(ctx, attrs) + + /** + * Initialize method. + */ + init { + setEGLConfigChooser(8, 8, 8, 8, 16, 0) + holder.setFormat(PixelFormat.TRANSLUCENT) + setZOrderOnTop(true) + + setRenderer(mRenderer) + renderMode = RENDERMODE_WHEN_DIRTY + setOnTouchListener(this) + + // Even though left and right pages are static we have to allocate room + // for curl on them too as we are switching meshes. Another way would be + // to swap texture ids only. + mPageLeft = CurlMesh(10) + mPageRight = CurlMesh(10) + mPageCurl = CurlMesh(10) + mPageLeft.setFlipTexture(true) + mPageRight.setFlipTexture(false) + } + + override fun onDrawFrame() { + // We are not animating. + if (!mAnimate) { + return + } + + val currentTime = System.currentTimeMillis() + // If animation is done. + if (currentTime >= mAnimationStartTime + mAnimationDurationTime) { + if (mAnimationTargetEvent == SET_CURL_TO_RIGHT) { + // Switch curled page to right. + val right = mPageCurl + val curl = mPageRight + right.setRect(mRenderer.getPageRect(CurlRenderer.PAGE_RIGHT)!!) + right.setFlipTexture(false) + right.reset() + mRenderer.removeCurlMesh(curl) + mPageCurl = curl + mPageRight = right + // If we were curling left page update current index. + if (mCurlState == CURL_LEFT) { + --mCurrentIndex + } + } else if (mAnimationTargetEvent == SET_CURL_TO_LEFT) { + // Switch curled page to left. + val left = mPageCurl + val curl = mPageLeft + left.setRect(mRenderer.getPageRect(CurlRenderer.PAGE_LEFT)!!) + left.setFlipTexture(true) + left.reset() + mRenderer.removeCurlMesh(curl) + if (!mRenderLeftPage) { + mRenderer.removeCurlMesh(left) + } + mPageCurl = curl + mPageLeft = left + // If we were curling right page update current index. + if (mCurlState == CURL_RIGHT) { + ++mCurrentIndex + } + } + mCurlState = CURL_NONE + mAnimate = false + requestRender() + } else { + mPointerPos.mPos.set(mAnimationSource) + var t = 1f - (currentTime - mAnimationStartTime).toFloat() / mAnimationDurationTime + t = 1f - t * t * t * (3 - 2 * t) + mPointerPos.mPos.x += (mAnimationTarget.x - mAnimationSource.x) * t + mPointerPos.mPos.y += (mAnimationTarget.y - mAnimationSource.y) * t + updateCurlPos(mPointerPos) + } + } + + override fun onPageSizeChanged(width: Int, height: Int) { + mPageBitmapWidth = width + mPageBitmapHeight = height + updatePages() + requestRender() + } + + public override fun onSizeChanged(w: Int, h: Int, ow: Int, oh: Int) { + super.onSizeChanged(w, h, ow, oh) + requestRender() + if (mSizeChangedObserver != null) { + mSizeChangedObserver!!.onSizeChanged(w, h) + } + } + + override fun onSurfaceCreated() { + // In case surface is recreated, let page meshes drop allocated texture + // ids and ask for new ones. There's no need to set textures here as + // onPageSizeChanged should be called later on. + mPageLeft.resetTexture() + mPageRight.resetTexture() + mPageCurl.resetTexture() + } + + override fun onTouch(view: View, me: MotionEvent): Boolean { + // No dragging during animation at the moment. + if (mAnimate || mPageProvider == null) { + return false + } + + // We need page rects quite extensively so get them for later use. + val rightRect = mRenderer.getPageRect(CurlRenderer.PAGE_RIGHT) + val leftRect = mRenderer.getPageRect(CurlRenderer.PAGE_LEFT) + + // Store pointer position. + mPointerPos.mPos.set(me.x, me.y) + mRenderer.translate(mPointerPos.mPos) + if (mEnableTouchPressure) { + mPointerPos.mPressure = me.pressure + } else { + mPointerPos.mPressure = 0.8f + } + + when (me.action) { + MotionEvent.ACTION_DOWN -> { + run { + + // Once we receive pointer down event its position is mapped to + // right or left edge of page and that'll be the position from where + // user is holding the paper to make curl happen. + mDragStartPos.set(mPointerPos.mPos) + + // First we make sure it's not over or below page. Pages are + // supposed to be same height so it really doesn't matter do we use + // left or right one. + if (mDragStartPos.y > rightRect!!.top) { + mDragStartPos.y = rightRect.top + } else if (mDragStartPos.y < rightRect.bottom) { + mDragStartPos.y = rightRect.bottom + } + + // Then we have to make decisions for the user whether curl is going + // to happen from left or right, and on which page. + if (mViewMode == SHOW_TWO_PAGES) { + // If we have an open book and pointer is on the left from right + // page we'll mark drag position to left edge of left page. + // Additionally checking mCurrentIndex is higher than zero tells + // us there is a visible page at all. + if (mDragStartPos.x < rightRect.left && mCurrentIndex > 0) { + mDragStartPos.x = leftRect!!.left + startCurl(CURL_LEFT) + } else if (mDragStartPos.x >= rightRect.left && mCurrentIndex < mPageProvider!!.pageCount) { + mDragStartPos.x = rightRect.right + if (!mAllowLastPageCurl && mCurrentIndex >= mPageProvider!!.pageCount - 1) { + return false + } + startCurl(CURL_RIGHT) + }// Otherwise check pointer is on right page's side. + } else if (mViewMode == SHOW_ONE_PAGE) { + val halfX = (rightRect.right + rightRect.left) / 2 + if (mDragStartPos.x < halfX && mCurrentIndex > 0) { + mDragStartPos.x = rightRect.left + startCurl(CURL_LEFT) + } else if (mDragStartPos.x >= halfX && mCurrentIndex < mPageProvider!!.pageCount) { + mDragStartPos.x = rightRect.right + if (!mAllowLastPageCurl && mCurrentIndex >= mPageProvider!!.pageCount - 1) { + return false + } + startCurl(CURL_RIGHT) + } + } + // If we have are in curl state, let this case clause flow through + // to next one. We have pointer position and drag position defined + // and this will create first render request given these points. + if (mCurlState == CURL_NONE) { + return false + } + } + updateCurlPos(mPointerPos) + } + MotionEvent.ACTION_MOVE -> { + updateCurlPos(mPointerPos) + } + MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { + if (mCurlState == CURL_LEFT || mCurlState == CURL_RIGHT) { + // Animation source is the point from where animation starts. + // Also it's handled in a way we actually simulate touch events + // meaning the output is exactly the same as if user drags the + // page to other side. While not producing the best looking + // result (which is easier done by altering curl position and/or + // direction directly), this is done in a hope it made code a + // bit more readable and easier to maintain. + mAnimationSource.set(mPointerPos.mPos) + mAnimationStartTime = System.currentTimeMillis() + + // Given the explanation, here we decide whether to simulate + // drag to left or right end. + if (mViewMode == SHOW_ONE_PAGE && mPointerPos.mPos.x > (rightRect!!.left + rightRect.right) / 2 || mViewMode == SHOW_TWO_PAGES && mPointerPos.mPos.x > rightRect!!.left) { + // On right side target is always right page's right border. + mAnimationTarget.set(mDragStartPos) + mAnimationTarget.x = mRenderer + .getPageRect(CurlRenderer.PAGE_RIGHT)!!.right + mAnimationTargetEvent = SET_CURL_TO_RIGHT + } else { + // On left side target depends on visible pages. + mAnimationTarget.set(mDragStartPos) + if (mCurlState == CURL_RIGHT || mViewMode == SHOW_TWO_PAGES) { + mAnimationTarget.x = leftRect!!.left + } else { + mAnimationTarget.x = rightRect!!.left + } + mAnimationTargetEvent = SET_CURL_TO_LEFT + } + mAnimate = true + requestRender() + } + } + } + + return true + } + + /** + * Allow the last page to curl. + */ + fun setAllowLastPageCurl(allowLastPageCurl: Boolean) { + mAllowLastPageCurl = allowLastPageCurl + } + + /** + * Sets mPageCurl curl position. + */ + private fun setCurlPos(curlPos: PointF, curlDir: PointF, radius: Double) { + + // First reposition curl so that page doesn't 'rip off' from book. + if (mCurlState == CURL_RIGHT || mCurlState == CURL_LEFT && mViewMode == SHOW_ONE_PAGE) { + val pageRect = mRenderer.getPageRect(CurlRenderer.PAGE_RIGHT) + if (curlPos.x >= pageRect!!.right) { + mPageCurl.reset() + requestRender() + return + } + if (curlPos.x < pageRect.left) { + curlPos.x = pageRect.left + } + if (curlDir.y != 0f) { + val diffX = curlPos.x - pageRect.left + val leftY = curlPos.y + diffX * curlDir.x / curlDir.y + if (curlDir.y < 0 && leftY < pageRect.top) { + curlDir.x = curlPos.y - pageRect.top + curlDir.y = pageRect.left - curlPos.x + } else if (curlDir.y > 0 && leftY > pageRect.bottom) { + curlDir.x = pageRect.bottom - curlPos.y + curlDir.y = curlPos.x - pageRect.left + } + } + } else if (mCurlState == CURL_LEFT) { + val pageRect = mRenderer.getPageRect(CurlRenderer.PAGE_LEFT) + if (curlPos.x <= pageRect!!.left) { + mPageCurl.reset() + requestRender() + return + } + if (curlPos.x > pageRect.right) { + curlPos.x = pageRect.right + } + if (curlDir.y != 0f) { + val diffX = curlPos.x - pageRect.right + val rightY = curlPos.y + diffX * curlDir.x / curlDir.y + if (curlDir.y < 0 && rightY < pageRect.top) { + curlDir.x = pageRect.top - curlPos.y + curlDir.y = curlPos.x - pageRect.right + } else if (curlDir.y > 0 && rightY > pageRect.bottom) { + curlDir.x = curlPos.y - pageRect.bottom + curlDir.y = pageRect.right - curlPos.x + } + } + } + + // Finally normalize direction vector and do rendering. + val dist = sqrt((curlDir.x * curlDir.x + curlDir.y * curlDir.y).toDouble()) + if (dist != 0.0) { + curlDir.x /= dist.toFloat() + curlDir.y /= dist.toFloat() + mPageCurl.curl(curlPos, curlDir, radius) + } else { + mPageCurl.reset() + } + + requestRender() + } + + /** + * If set to true, touch event pressure information is used to adjust curl + * radius. The more you press, the flatter the curl becomes. This is + * somewhat experimental and results may vary significantly between devices. + * On emulator pressure information seems to be flat 1.0f which is maximum + * value and therefore not very much of use. + */ + fun setEnableTouchPressure(enableTouchPressure: Boolean) { + mEnableTouchPressure = enableTouchPressure + } + + /** + * Set margins (or padding). Note: margins are proportional. Meaning a value + * of .1f will produce a 10% margin. + */ + fun setMargins(left: Float, top: Float, right: Float, bottom: Float) { + mRenderer.setMargins(left, top, right, bottom) + } + + /** + * Setter for whether left side page is rendered. This is useful mostly for + * situations where right (main) page is aligned to left side of screen and + * left page is not visible anyway. + */ + fun setRenderLeftPage(renderLeftPage: Boolean) { + mRenderLeftPage = renderLeftPage + } + + /** + * Sets SizeChangedObserver for this View. Call back method is called from + * this View's onSizeChanged method. + */ + fun setSizeChangedObserver(observer: SizeChangedObserver) { + mSizeChangedObserver = observer + } + + /** + * Sets view mode. Value can be either SHOW_ONE_PAGE or SHOW_TWO_PAGES. In + * former case right page is made size of display, and in latter case two + * pages are laid on visible area. + */ + fun setViewMode(viewMode: Int) { + when (viewMode) { + SHOW_ONE_PAGE -> { + mViewMode = viewMode + mPageLeft.setFlipTexture(true) + mRenderer.setViewMode(CurlRenderer.SHOW_ONE_PAGE) + } + SHOW_TWO_PAGES -> { + mViewMode = viewMode + mPageLeft.setFlipTexture(false) + mRenderer.setViewMode(CurlRenderer.SHOW_TWO_PAGES) + } + } + } + + /** + * Switches meshes and loads new bitmaps if available. Updated to support 2 + * pages in landscape + */ + private fun startCurl(page: Int) { + when (page) { + + // Once right side page is curled, first right page is assigned into + // curled page. And if there are more bitmaps available new bitmap is + // loaded into right side mesh. + CURL_RIGHT -> { + // Remove meshes from renderer. + mRenderer.removeCurlMesh(mPageLeft) + mRenderer.removeCurlMesh(mPageRight) + mRenderer.removeCurlMesh(mPageCurl) + + // We are curling right page. + val curl = mPageRight + mPageRight = mPageCurl + mPageCurl = curl + + if (mCurrentIndex > 0) { + mPageLeft.setFlipTexture(true) + mPageLeft.setRect(mRenderer.getPageRect(CurlRenderer.PAGE_LEFT)!!) + mPageLeft.reset() + if (mRenderLeftPage) { + mRenderer.addCurlMesh(mPageLeft) + } + } + if (mCurrentIndex < mPageProvider!!.pageCount - 1) { + updatePage(mPageRight.texturePage, mCurrentIndex + 1) + mPageRight.setRect( + mRenderer.getPageRect(CurlRenderer.PAGE_RIGHT)!! + ) + mPageRight.setFlipTexture(false) + mPageRight.reset() + mRenderer.addCurlMesh(mPageRight) + } + + // Add curled page to renderer. + mPageCurl.setRect(mRenderer.getPageRect(CurlRenderer.PAGE_RIGHT)!!) + mPageCurl.setFlipTexture(false) + mPageCurl.reset() + mRenderer.addCurlMesh(mPageCurl) + + mCurlState = CURL_RIGHT + } + + // On left side curl, left page is assigned to curled page. And if + // there are more bitmaps available before currentIndex, new bitmap + // is loaded into left page. + CURL_LEFT -> { + // Remove meshes from renderer. + mRenderer.removeCurlMesh(mPageLeft) + mRenderer.removeCurlMesh(mPageRight) + mRenderer.removeCurlMesh(mPageCurl) + + // We are curling left page. + val curl = mPageLeft + mPageLeft = mPageCurl + mPageCurl = curl + + if (mCurrentIndex > 1) { + updatePage(mPageLeft.texturePage, mCurrentIndex - 2) + mPageLeft.setFlipTexture(true) + mPageLeft + .setRect(mRenderer.getPageRect(CurlRenderer.PAGE_LEFT)!!) + mPageLeft.reset() + if (mRenderLeftPage) { + mRenderer.addCurlMesh(mPageLeft) + } + } + + // If there is something to show on right page add it to renderer. + if (mCurrentIndex < mPageProvider!!.pageCount) { + mPageRight.setFlipTexture(false) + mPageRight.setRect( + mRenderer.getPageRect(CurlRenderer.PAGE_RIGHT)!! + ) + mPageRight.reset() + mRenderer.addCurlMesh(mPageRight) + } + + // How dragging previous page happens depends on view mode. + if (mViewMode == SHOW_ONE_PAGE || mCurlState == CURL_LEFT && mViewMode == SHOW_TWO_PAGES) { + mPageCurl.setRect( + mRenderer.getPageRect(CurlRenderer.PAGE_RIGHT)!! + ) + mPageCurl.setFlipTexture(false) + } else { + mPageCurl.setRect(mRenderer.getPageRect(CurlRenderer.PAGE_LEFT)!!) + mPageCurl.setFlipTexture(true) + } + mPageCurl.reset() + mRenderer.addCurlMesh(mPageCurl) + + mCurlState = CURL_LEFT + } + } + } + + /** + * Updates curl position. + */ + private fun updateCurlPos(pointerPos: PointerPosition) { + + // Default curl radius. + var radius = (mRenderer.getPageRect(CURL_RIGHT)!!.width() / 3).toDouble() + // TODO: This is not an optimal solution. Based on feedback received so + // far; pressure is not very accurate, it may be better not to map + // coefficient to range [0f, 1f] but something like [.2f, 1f] instead. + // Leaving it as is until get my hands on a real device. On emulator + // this doesn't work anyway. + radius *= max(1f - pointerPos.mPressure, 0f).toDouble() + // NOTE: Here we set pointerPos to mCurlPos. It might be a bit confusing + // later to see e.g "mCurlPos.x - mDragStartPos.x" used. But it's + // actually pointerPos we are doing calculations against. Why? Simply to + // optimize code a bit with the cost of making it unreadable. Otherwise + // we had to this in both of the next if-else branches. + mCurlPos.set(pointerPos.mPos) + + // If curl happens on right page, or on left page on two page mode, + // we'll calculate curl position from pointerPos. + if (mCurlState == CURL_RIGHT || mCurlState == CURL_LEFT && mViewMode == SHOW_TWO_PAGES) { + + mCurlDir.x = mCurlPos.x - mDragStartPos.x + mCurlDir.y = mCurlPos.y - mDragStartPos.y + val dist = + sqrt((mCurlDir.x * mCurlDir.x + mCurlDir.y * mCurlDir.y).toDouble()).toFloat() + + // Adjust curl radius so that if page is dragged far enough on + // opposite side, radius gets closer to zero. + val pageWidth = mRenderer.getPageRect(CurlRenderer.PAGE_RIGHT)!! + .width() + var curlLen = radius * Math.PI + if (dist > pageWidth * 2 - curlLen) { + curlLen = max(pageWidth * 2 - dist, 0f).toDouble() + radius = curlLen / Math.PI + } + + // Actual curl position calculation. + if (dist >= curlLen) { + val translate = (dist - curlLen) / 2 + if (mViewMode == SHOW_TWO_PAGES) { + mCurlPos.x -= (mCurlDir.x * translate / dist).toFloat() + } else { + val pageLeftX = mRenderer + .getPageRect(CurlRenderer.PAGE_RIGHT)!!.left + radius = max( + min((mCurlPos.x - pageLeftX).toDouble(), radius), + 0.0 + ) + } + mCurlPos.y -= (mCurlDir.y * translate / dist).toFloat() + } else { + val angle = Math.PI * sqrt(dist / curlLen) + val translate = radius * sin(angle) + mCurlPos.x += (mCurlDir.x * translate / dist).toFloat() + mCurlPos.y += (mCurlDir.y * translate / dist).toFloat() + } + } else if (mCurlState == CURL_LEFT) { + + // Adjust radius regarding how close to page edge we are. + val pageLeftX = mRenderer.getPageRect(CurlRenderer.PAGE_RIGHT)!!.left + radius = max(min((mCurlPos.x - pageLeftX).toDouble(), radius), 0.0) + + val pageRightX = mRenderer.getPageRect(CurlRenderer.PAGE_RIGHT)!!.right + mCurlPos.x -= min((pageRightX - mCurlPos.x).toDouble(), radius).toFloat() + mCurlDir.x = mCurlPos.x + mDragStartPos.x + mCurlDir.y = mCurlPos.y - mDragStartPos.y + }// Otherwise we'll let curl follow pointer position. + + setCurlPos(mCurlPos, mCurlDir, radius) + } + + /** + * Updates given CurlPage via PageProvider for page located at index. + */ + private fun updatePage(page: CurlPage, index: Int) { + // First reset page to initial state. + page.reset() + // Ask page provider to fill it up with bitmaps and colors. + mPageProvider!!.updatePage( + page, mPageBitmapWidth, mPageBitmapHeight, + index + ) + } + + /** + * Updates bitmaps for page meshes. + */ + fun updatePages() { + if (mPageProvider == null || mPageBitmapWidth <= 0 + || mPageBitmapHeight <= 0 + ) { + return + } + + // Remove meshes from renderer. + mRenderer.removeCurlMesh(mPageLeft) + mRenderer.removeCurlMesh(mPageRight) + mRenderer.removeCurlMesh(mPageCurl) + + var leftIdx = mCurrentIndex - 1 + var rightIdx = mCurrentIndex + var curlIdx = -1 + if (mCurlState == CURL_LEFT) { + curlIdx = leftIdx + --leftIdx + } else if (mCurlState == CURL_RIGHT) { + curlIdx = rightIdx + ++rightIdx + } + + if (rightIdx >= 0 && rightIdx < mPageProvider!!.pageCount) { + updatePage(mPageRight.texturePage, rightIdx) + mPageRight.setFlipTexture(false) + mPageRight.setRect(mRenderer.getPageRect(CurlRenderer.PAGE_RIGHT)!!) + mPageRight.reset() + mRenderer.addCurlMesh(mPageRight) + } + if (leftIdx >= 0 && leftIdx < mPageProvider!!.pageCount) { + updatePage(mPageLeft.texturePage, leftIdx) + mPageLeft.setFlipTexture(true) + mPageLeft.setRect(mRenderer.getPageRect(CurlRenderer.PAGE_LEFT)!!) + mPageLeft.reset() + if (mRenderLeftPage) { + mRenderer.addCurlMesh(mPageLeft) + } + } + if (curlIdx >= 0 && curlIdx < mPageProvider!!.pageCount) { + updatePage(mPageCurl.texturePage, curlIdx) + + if (mCurlState == CURL_RIGHT) { + mPageCurl.setFlipTexture(true) + mPageCurl.setRect( + mRenderer.getPageRect(CurlRenderer.PAGE_RIGHT)!! + ) + } else { + mPageCurl.setFlipTexture(false) + mPageCurl + .setRect(mRenderer.getPageRect(CurlRenderer.PAGE_LEFT)!!) + } + + mPageCurl.reset() + mRenderer.addCurlMesh(mPageCurl) + } + } + + /** + * Provider for feeding 'book' with bitmaps which are used for rendering + * pages. + */ + interface PageProvider { + + /** + * Return number of pages available. + */ + val pageCount: Int + + /** + * Called once new bitmaps/textures are needed. Width and height are in + * pixels telling the size it will be drawn on screen and following them + * ensures that aspect ratio remains. But it's possible to return bitmap + * of any size though. You should use provided CurlPage for storing page + * information for requested page number.

+ *

+ * Index is a number between 0 and getBitmapCount() - 1. + */ + fun updatePage(page: CurlPage, width: Int, height: Int, index: Int) + } + + /** + * Simple holder for pointer position. + */ + private inner class PointerPosition { + internal var mPos = PointF() + internal var mPressure: Float = 0.toFloat() + } + + /** + * Observer interface for handling CurlView size changes. + */ + interface SizeChangedObserver { + + /** + * Called once CurlView size changes. + */ + fun onSizeChanged(width: Int, height: Int) + } + + companion object { + + // Curl state. We are flipping none, left or right page. + private const val CURL_LEFT = 1 + private const val CURL_NONE = 0 + private const val CURL_RIGHT = 2 + + // Constants for mAnimationTargetEvent. + private const val SET_CURL_TO_LEFT = 1 + private const val SET_CURL_TO_RIGHT = 2 + + // Shows one page at the center of view. + const val SHOW_ONE_PAGE = 1 + // Shows two pages side by side. + const val SHOW_TWO_PAGES = 2 + } + +} diff --git a/app/src/main/java/io/legado/app/ui/widget/page/delegate/CoverPageDelegate.kt b/app/src/main/java/io/legado/app/ui/widget/page/delegate/CoverPageDelegate.kt new file mode 100644 index 000000000..10ac07abd --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/page/delegate/CoverPageDelegate.kt @@ -0,0 +1,80 @@ +package io.legado.app.ui.widget.page.delegate + +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.drawable.GradientDrawable +import io.legado.app.ui.widget.page.PageView + +class CoverPageDelegate(pageView: PageView) : HorizontalPageDelegate(pageView) { + + private val shadowDrawableR: GradientDrawable + private val bitmapMatrix = Matrix() + + init { + val shadowColors = intArrayOf(0x66111111, 0x00000000) + shadowDrawableR = GradientDrawable( + GradientDrawable.Orientation.LEFT_RIGHT, shadowColors + ) + shadowDrawableR.gradientType = GradientDrawable.LINEAR_GRADIENT + } + + override fun onScrollStart() { + val distanceX: Float + when (direction) { + Direction.NEXT -> distanceX = + if (isCancel) { + var dis = viewWidth - startX + touchX + if (dis > viewWidth) { + dis = viewWidth.toFloat() + } + viewWidth - dis + } else { + -(touchX + (viewWidth - startX)) + } + else -> distanceX = + if (isCancel) { + -(touchX - startX) + } else { + viewWidth - (touchX - startX) + } + } + + startScroll(touchX.toInt(), 0, distanceX.toInt(), 0) + } + + override fun onScrollStop() { + curPage?.x = 0.toFloat() + if (!isCancel) { + pageView.fillPage(direction) + } + } + + override fun onDraw(canvas: Canvas) { + val offsetX = touchX - startX + + if ((direction == Direction.NEXT && offsetX > 0) + || (direction == Direction.PREV && offsetX < 0) + ) return + + val distanceX = if (offsetX > 0) offsetX - viewWidth else offsetX + viewWidth + bitmap?.let { + if (direction == Direction.PREV) { + bitmapMatrix.setTranslate(distanceX, 0.toFloat()) + canvas.drawBitmap(it, bitmapMatrix, null) + } else if (direction == Direction.NEXT) { + curPage?.translationX = offsetX + } + addShadow(distanceX.toInt(), canvas) + } + } + + private fun addShadow(left: Int, canvas: Canvas) { + if (left < 0) { + shadowDrawableR.setBounds(left + viewWidth, 0, left + viewWidth + 30, viewHeight) + shadowDrawableR.draw(canvas) + } else if (left > 0) { + shadowDrawableR.setBounds(left, 0, left + 30, viewHeight) + shadowDrawableR.draw(canvas) + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/page/delegate/EventExtensions.kt b/app/src/main/java/io/legado/app/ui/widget/page/delegate/EventExtensions.kt new file mode 100644 index 000000000..99916e27a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/page/delegate/EventExtensions.kt @@ -0,0 +1,20 @@ +package io.legado.app.ui.widget.page.delegate + +import android.view.MotionEvent + +fun MotionEvent.toAction(action: Int): MotionEvent { + return MotionEvent.obtain( + downTime, + eventTime, + action, + x, + y, + pressure, + size, + metaState, + xPrecision, + yPrecision, + deviceId, + edgeFlags + ) +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/page/delegate/HorizontalPageDelegate.kt b/app/src/main/java/io/legado/app/ui/widget/page/delegate/HorizontalPageDelegate.kt new file mode 100644 index 000000000..1f2b3fa42 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/page/delegate/HorizontalPageDelegate.kt @@ -0,0 +1,53 @@ +package io.legado.app.ui.widget.page.delegate + +import android.view.MotionEvent +import io.legado.app.ui.widget.page.PageView +import io.legado.app.utils.screenshot +import kotlin.math.abs + +abstract class HorizontalPageDelegate(pageView: PageView) : PageDelegate(pageView) { + + override fun onScroll( + e1: MotionEvent, + e2: MotionEvent, + distanceX: Float, + distanceY: Float + ): Boolean { + if (!isMoved) { + val event = e1.toAction(MotionEvent.ACTION_UP) + curPage?.dispatchTouchEvent(event) + event.recycle() + if (abs(distanceX) > abs(distanceY)) { + if (distanceX < 0) { + //如果上一页不存在 + if (!hasPrev()) { + noNext = true + return true + } + //上一页截图 + bitmap = prevPage?.screenshot() + } else { + //如果不存在表示没有下一页了 + if (!hasNext()) { + noNext = true + return true + } + //下一页截图 + bitmap = nextPage?.screenshot() + } + isMoved = true + } + } + if (isMoved) { + isCancel = if (pageView.isScrollDelegate) { + if (direction == Direction.NEXT) distanceY < 0 else distanceY > 0 + } else { + if (direction == Direction.NEXT) distanceX < 0 else distanceX > 0 + } + isRunning = true + //设置触摸点 + setTouchPoint(e2.x, e2.y) + } + return isMoved + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/page/delegate/NoAnimPageDelegate.kt b/app/src/main/java/io/legado/app/ui/widget/page/delegate/NoAnimPageDelegate.kt new file mode 100644 index 000000000..7008455b8 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/page/delegate/NoAnimPageDelegate.kt @@ -0,0 +1,19 @@ +package io.legado.app.ui.widget.page.delegate + +import android.graphics.Canvas +import io.legado.app.ui.widget.page.PageView + +class NoAnimPageDelegate(pageView: PageView) : HorizontalPageDelegate(pageView) { + override fun onScrollStart() { + startScroll(touchX.toInt(), 0, 0, 0) + } + + override fun onDraw(canvas: Canvas) { + } + + override fun onScrollStop() { + if (!isCancel) { + pageView.fillPage(direction) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/page/delegate/PageDelegate.kt b/app/src/main/java/io/legado/app/ui/widget/page/delegate/PageDelegate.kt new file mode 100644 index 000000000..d094e4a82 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/page/delegate/PageDelegate.kt @@ -0,0 +1,324 @@ +package io.legado.app.ui.widget.page.delegate + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.RectF +import android.view.GestureDetector +import android.view.MotionEvent +import android.widget.Scroller +import androidx.annotation.CallSuper +import androidx.interpolator.view.animation.FastOutLinearInInterpolator +import com.google.android.material.snackbar.Snackbar +import io.legado.app.ui.widget.page.ContentView +import io.legado.app.ui.widget.page.PageView +import io.legado.app.utils.screenshot +import io.legado.app.utils.snackbar +import kotlin.math.abs + +abstract class PageDelegate(protected val pageView: PageView) { + val centerRectF = RectF( + pageView.width * 0.33f, pageView.height * 0.33f, + pageView.width * 0.66f, pageView.height * 0.66f + ) + //起始点 + protected var startX: Float = 0.toFloat() + protected var startY: Float = 0.toFloat() + //触碰点 + protected var touchX: Float = 0.toFloat() + protected var touchY: Float = 0.toFloat() + + protected val nextPage: ContentView? + get() = pageView.nextPage + + protected val curPage: ContentView? + get() = pageView.curPage + + protected val prevPage: ContentView? + get() = pageView.prevPage + + protected var bitmap: Bitmap? = null + + protected var viewWidth: Int = pageView.width + protected var viewHeight: Int = pageView.height + //textView在顶端或低端 + protected var atTop: Boolean = false + protected var atBottom: Boolean = false + + private var snackbar: Snackbar? = null + + private val scroller: Scroller by lazy { + Scroller( + pageView.context, + FastOutLinearInInterpolator() + ) + } + + private val detector: GestureDetector by lazy { + GestureDetector( + pageView.context, + GestureListener() + ) + } + + var isMoved = false + var noNext = true + + //移动方向 + var direction = Direction.NONE + var isCancel = false + var isRunning = false + var isStarted = false + + protected fun setStartPoint(x: Float, y: Float, invalidate: Boolean = true) { + startX = x + startY = y + + if (invalidate) { + invalidate() + } + } + + protected fun setTouchPoint(x: Float, y: Float, invalidate: Boolean = true) { + touchX = x + touchY = y + + if (invalidate) { + invalidate() + } + + onScroll() + } + + protected fun invalidate() { + pageView.invalidate() + } + + protected fun startScroll(startX: Int, startY: Int, dx: Int, dy: Int) { + scroller.startScroll( + startX, + startY, + dx, + dy, + if (dx != 0) (abs(dx) * 0.3).toInt() else (abs(dy) * 0.3).toInt() + ) + isRunning = true + isStarted = true + invalidate() + } + + protected fun stopScroll() { + isRunning = false + isStarted = false + invalidate() + if (pageView.isScrollDelegate) { + pageView.postDelayed({ + bitmap?.recycle() + bitmap = null + }, 100) + } else { + bitmap?.recycle() + bitmap = null + } + } + + fun setViewSize(width: Int, height: Int) { + viewWidth = width + viewHeight = height + invalidate() + centerRectF.set( + width * 0.33f, height * 0.33f, + width * 0.66f, height * 0.66f + ) + } + + fun scroll() { + if (scroller.computeScrollOffset()) { + setTouchPoint(scroller.currX.toFloat(), scroller.currY.toFloat()) + } else if (isStarted) { + setTouchPoint(scroller.finalX.toFloat(), scroller.finalY.toFloat(), false) + onScrollStop() + stopScroll() + } + } + + fun abort() { + if (!scroller.isFinished) { + scroller.abortAnimation() + } + } + + fun start(direction: Direction) { + if (isStarted) return + if (direction === Direction.NEXT) { + val x = viewWidth.toFloat() + val y = viewHeight.toFloat() + //初始化动画 + setStartPoint(x, y, false) + //设置点击点 + setTouchPoint(x, y, false) + //设置方向 + if (!hasNext()) { + return + } + } else { + val x = 0.toFloat() + val y = viewHeight.toFloat() + //初始化动画 + setStartPoint(x, y, false) + //设置点击点 + setTouchPoint(x, y, false) + //设置方向方向 + if (!hasPrev()) { + return + } + } + onScrollStart() + } + + /** + * 触摸事件处理 + */ + @CallSuper + open fun onTouch(event: MotionEvent): Boolean { + if (isStarted) return false + if (curPage?.isTextSelected() == true) { + curPage?.dispatchTouchEvent(event) + return true + } + if (event.action == MotionEvent.ACTION_DOWN) { + curPage?.let { + it.contentTextView()?.let { contentTextView -> + atTop = contentTextView.atTop() + atBottom = contentTextView.atBottom() + } + it.dispatchTouchEvent(event) + } + } else if (event.action == MotionEvent.ACTION_UP) { + curPage?.dispatchTouchEvent(event) + if (isMoved) { + // 开启翻页效果 + if (!noNext) onScrollStart() + return true + } + } + return detector.onTouchEvent(event) + } + + abstract fun onScrollStart()//scroller start + + abstract fun onDraw(canvas: Canvas)//绘制 + + abstract fun onScrollStop()//scroller finish + + open fun onScroll() {//移动contentView, slidePage + } + + open fun onPageUp() { + } + + abstract fun onScroll( + e1: MotionEvent, + e2: MotionEvent, + distanceX: Float, + distanceY: Float + ): Boolean + + enum class Direction { + NONE, PREV, NEXT + } + + /** + * 触摸事件处理 + */ + private inner class GestureListener : GestureDetector.SimpleOnGestureListener() { + + override fun onDown(e: MotionEvent): Boolean { +// abort() + //是否移动 + isMoved = false + //是否存在下一章 + noNext = false + //是否正在执行动画 + isRunning = false + //取消 + isCancel = false + //是下一章还是前一章 + direction = Direction.NONE + //设置起始位置的触摸点 + setStartPoint(e.x, e.y) + return true + } + + override fun onSingleTapUp(e: MotionEvent): Boolean { + val x = e.x + val y = e.y + if (centerRectF.contains(x, y)) { + pageView.callback?.clickCenter() + setTouchPoint(x, y) + } else { + bitmap = if (x > viewWidth / 2) { + //设置动画方向 + if (!hasNext()) { + return true + } + //下一页截图 + nextPage?.screenshot() + } else { + if (!hasPrev()) { + return true + } + //上一页截图 + prevPage?.screenshot() + } + setTouchPoint(x, y) + onScrollStart() + } + return true + } + + override fun onScroll( + e1: MotionEvent, + e2: MotionEvent, + distanceX: Float, + distanceY: Float + ): Boolean { + return this@PageDelegate.onScroll(e1, e2, distanceX, distanceY) + } + } + + fun hasPrev(): Boolean { + //上一页的参数配置 + direction = Direction.PREV + val hasPrev = pageView.pageFactory?.hasPrev() == true + if (!hasPrev) { + snackbar ?: let { + snackbar = pageView.snackbar("没有上一页") + } + snackbar?.let { + if (!it.isShown) { + it.setText("没有上一页") + it.show() + } + } + } + return hasPrev + } + + fun hasNext(): Boolean { + //进行下一页的配置 + direction = Direction.NEXT + val hasNext = pageView.pageFactory?.hasNext() == true + if (!hasNext) { + snackbar ?: let { + snackbar = pageView.snackbar("没有下一页") + } + snackbar?.let { + if (!it.isShown) { + it.setText("没有下一页") + it.show() + } + } + } + return hasNext + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/page/delegate/ScrollPageDelegate.kt b/app/src/main/java/io/legado/app/ui/widget/page/delegate/ScrollPageDelegate.kt new file mode 100644 index 000000000..4e333bd1c --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/page/delegate/ScrollPageDelegate.kt @@ -0,0 +1,124 @@ +package io.legado.app.ui.widget.page.delegate + +import android.graphics.Canvas +import android.graphics.Matrix +import android.view.MotionEvent +import io.legado.app.ui.widget.page.PageView +import io.legado.app.utils.screenshot +import kotlin.math.abs + +class ScrollPageDelegate(pageView: PageView) : PageDelegate(pageView) { + + private val bitmapMatrix = Matrix() + + override fun onScrollStart() { + if (!atTop && !atBottom) { + stopScroll() + return + } + val distanceY: Float + when (direction) { + Direction.NEXT -> distanceY = + if (isCancel) { + var dis = viewHeight - startY + touchY + if (dis > viewHeight) { + dis = viewHeight.toFloat() + } + viewHeight - dis + } else { + -(touchY + (viewHeight - startY)) + } + else -> distanceY = + if (isCancel) { + -(touchY - startY) + } else { + viewHeight - (touchY - startY) + } + } + + startScroll(0, touchY.toInt(), 0, distanceY.toInt()) + } + + override fun onDraw(canvas: Canvas) { + if (atTop || atBottom) { + val offsetY = touchY - startY + + if ((direction == Direction.NEXT && offsetY > 0) + || (direction == Direction.PREV && offsetY < 0) + ) return + + val distanceY = if (offsetY > 0) offsetY - viewHeight else offsetY + viewHeight + if (atTop && direction == Direction.PREV) { + bitmap?.let { + bitmapMatrix.setTranslate(0.toFloat(), distanceY) + canvas.drawBitmap(it, bitmapMatrix, null) + } + } else if (atBottom && direction == Direction.NEXT) { + bitmap?.let { + bitmapMatrix.setTranslate(0.toFloat(), distanceY) + canvas.drawBitmap(it, bitmapMatrix, null) + } + } + } + } + + override fun onScrollStop() { + if (!isCancel) { + pageView.fillPage(direction) + } + } + + override fun onScroll( + e1: MotionEvent, + e2: MotionEvent, + distanceX: Float, + distanceY: Float + ): Boolean { + if (!isMoved && abs(distanceX) < abs(distanceY)) { + if (distanceY < 0) { + if (atTop) { + val event = e1.toAction(MotionEvent.ACTION_UP) + curPage?.dispatchTouchEvent(event) + event.recycle() + //如果上一页不存在 + if (!hasPrev()) { + noNext = true + return true + } + //上一页截图 + bitmap = prevPage?.screenshot() + } + } else { + if (atBottom) { + val event = e1.toAction(MotionEvent.ACTION_UP) + curPage?.dispatchTouchEvent(event) + event.recycle() + //如果不存在表示没有下一页了 + if (!hasNext()) { + noNext = true + return true + } + //下一页截图 + bitmap = nextPage?.screenshot() + } + } + isMoved = true + } + if ((atTop && direction != Direction.PREV) || (atBottom && direction != Direction.NEXT) || direction == Direction.NONE) { + //传递触摸事件到textView + curPage?.dispatchTouchEvent(e2) + } + if (isMoved) { + isCancel = if (pageView.isScrollDelegate) { + if (direction == Direction.NEXT) distanceY < 0 else distanceY > 0 + } else { + if (direction == Direction.NEXT) distanceX < 0 else distanceX > 0 + } + isRunning = true + //设置触摸点 + setTouchPoint(e2.x, e2.y) + } + return isMoved + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/page/delegate/SimulationPageDelegate.kt b/app/src/main/java/io/legado/app/ui/widget/page/delegate/SimulationPageDelegate.kt new file mode 100644 index 000000000..d7ee69211 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/page/delegate/SimulationPageDelegate.kt @@ -0,0 +1,62 @@ +package io.legado.app.ui.widget.page.delegate + +import android.graphics.Canvas +import android.view.MotionEvent +import io.legado.app.ui.widget.page.PageView +import io.legado.app.ui.widget.page.curl.CurlPage +import io.legado.app.ui.widget.page.curl.CurlView +import io.legado.app.utils.screenshot + +class SimulationPageDelegate(pageView: PageView) : HorizontalPageDelegate(pageView) { + + init { + pageView.curlView ?: let { + pageView.curlView = CurlView(pageView.context) + pageView.addView(pageView.curlView) + pageView.curlView?.mPageProvider = PageProvider() + pageView.curlView?.setSizeChangedObserver(SizeChangedObserver()) + pageView.curlView?.currentIndex = 1 + } + } + + override fun onTouch(event: MotionEvent): Boolean { + pageView.curlView?.dispatchTouchEvent(event) + return super.onTouch(event) + } + + override fun onScrollStart() { + } + + override fun onDraw(canvas: Canvas) { + } + + override fun onScrollStop() { + } + + override fun onPageUp() { + pageView.curlView?.updatePages() + pageView.curlView?.requestRender() + } + + private inner class PageProvider : CurlView.PageProvider { + + override val pageCount: Int + get() = 3 + + override fun updatePage(page: CurlPage, width: Int, height: Int, index: Int) { + when (index) { + 0 -> page.setTexture(prevPage?.screenshot(), CurlPage.SIDE_BOTH) + 1 -> page.setTexture(curPage?.screenshot(), CurlPage.SIDE_BOTH) + 2 -> page.setTexture(nextPage?.screenshot(), CurlPage.SIDE_BOTH) + } + } + } + + // 定义书籍尺寸的变化监听器 + private inner class SizeChangedObserver : CurlView.SizeChangedObserver { + override fun onSizeChanged(width: Int, height: Int) { + pageView.curlView?.setViewMode(CurlView.SHOW_ONE_PAGE) + pageView.curlView?.setMargins(0f, 0f, 0f, 0f) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/page/delegate/SlidePageDelegate.kt b/app/src/main/java/io/legado/app/ui/widget/page/delegate/SlidePageDelegate.kt new file mode 100644 index 000000000..2615976e6 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/page/delegate/SlidePageDelegate.kt @@ -0,0 +1,66 @@ +package io.legado.app.ui.widget.page.delegate + +import android.graphics.Canvas +import android.graphics.Matrix +import io.legado.app.ui.widget.page.PageView + +class SlidePageDelegate(pageView: PageView) : HorizontalPageDelegate(pageView) { + + private val bitmapMatrix = Matrix() + + override fun onScrollStart() { + val distanceX: Float + when (direction) { + Direction.NEXT -> distanceX = + if (isCancel) { + var dis = viewWidth - startX + touchX + if (dis > viewWidth) { + dis = viewWidth.toFloat() + } + viewWidth - dis + } else { + -(touchX + (viewWidth - startX)) + } + else -> distanceX = + if (isCancel) { + -(touchX - startX) + } else { + viewWidth - (touchX - startX) + } + } + + startScroll(touchX.toInt(), 0, distanceX.toInt(), 0) + } + + override fun onDraw(canvas: Canvas) { + val offsetX = touchX - startX + + if ((direction == Direction.NEXT && offsetX > 0) + || (direction == Direction.PREV && offsetX < 0) + ) return + + val distanceX = if (offsetX > 0) offsetX - viewWidth else offsetX + viewWidth + bitmap?.let { + bitmapMatrix.setTranslate(distanceX, 0.toFloat()) + canvas.drawBitmap(it, bitmapMatrix, null) + } + } + + override fun onScroll() { + val offsetX = touchX - startX + + if ((direction == Direction.NEXT && offsetX > 0) + || (direction == Direction.PREV && offsetX < 0) + ) return + + curPage?.translationX = offsetX + } + + override fun onScrollStop() { + curPage?.x = 0.toFloat() + + if (!isCancel) { + pageView.fillPage(direction) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/recycler/RecyclerViewAtViewPager2.kt b/app/src/main/java/io/legado/app/ui/widget/recycler/RecyclerViewAtViewPager2.kt new file mode 100644 index 000000000..79132f627 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/recycler/RecyclerViewAtViewPager2.kt @@ -0,0 +1,41 @@ +package io.legado.app.ui.widget.recycler + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import androidx.recyclerview.widget.RecyclerView +import kotlin.math.abs + +class RecyclerViewAtViewPager2(context: Context, attrs: AttributeSet?) : + RecyclerView(context, attrs) { + + private var startX: Int = 0 + private var startY: Int = 0 + + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + when (ev.action) { + MotionEvent.ACTION_DOWN -> { + startX = ev.x.toInt() + startY = ev.y.toInt() + parent.requestDisallowInterceptTouchEvent(true) + } + MotionEvent.ACTION_MOVE -> { + val endX = ev.x.toInt() + val endY = ev.y.toInt() + val disX = abs(endX - startX) + val disY = abs(endY - startY) + if (disX > disY) { + parent.requestDisallowInterceptTouchEvent(canScrollHorizontally(startX - endX)) + } else { + parent.requestDisallowInterceptTouchEvent(canScrollVertically(startY - endY)) + } + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> parent.requestDisallowInterceptTouchEvent( + false + ) + } + return super.dispatchTouchEvent(ev) + } + + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/recycler/RefreshRecyclerView.kt b/app/src/main/java/io/legado/app/ui/widget/recycler/RefreshRecyclerView.kt new file mode 100644 index 000000000..8fcc56d2c --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/recycler/RefreshRecyclerView.kt @@ -0,0 +1,78 @@ +package io.legado.app.ui.widget.recycler + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.widget.LinearLayout +import androidx.recyclerview.widget.LinearLayoutManager +import io.legado.app.R +import kotlinx.android.synthetic.main.view_refresh_recycler.view.* + + +class RefreshRecyclerView(context: Context?, attrs: AttributeSet?) : LinearLayout(context, attrs) { + + private var durTouchX = -1000000f + private var durTouchY = -1000000f + + var onRefreshStart: (() -> Unit)? = null + + init { + orientation = VERTICAL + LayoutInflater.from(context).inflate(R.layout.view_refresh_recycler, this, true) + recycler_view.setOnTouchListener(object : OnTouchListener { + @SuppressLint("ClickableViewAccessibility") + override fun onTouch(v: View?, event: MotionEvent?): Boolean { + when (event?.action) { + MotionEvent.ACTION_DOWN -> { + durTouchX = event.x + durTouchY = event.y + } + MotionEvent.ACTION_MOVE -> { + if (durTouchX == -1000000f) { + durTouchX = event.x + } + if (durTouchY == -1000000f) + durTouchY = event.y + + val dY = event.y - durTouchY //>0下拉 + durTouchY = event.y + if (!refresh_progress_bar.isAutoLoading && refresh_progress_bar.getSecondDurProgress() == refresh_progress_bar.secondFinalProgress) { + recycler_view.adapter?.let { + if (it.itemCount > 0) { + if (0 == (recycler_view.layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition()) { + refresh_progress_bar.setSecondDurProgress((refresh_progress_bar.getSecondDurProgress() + dY / 2).toInt()) + } + } else { + refresh_progress_bar.setSecondDurProgress((refresh_progress_bar.getSecondDurProgress() + dY / 2).toInt()) + } + } + return refresh_progress_bar.getSecondDurProgress() > 0 + } + } + MotionEvent.ACTION_UP -> { + if (!refresh_progress_bar.isAutoLoading && refresh_progress_bar.secondMaxProgress > 0 && refresh_progress_bar.getSecondDurProgress() > 0) { + if (refresh_progress_bar.getSecondDurProgress() >= refresh_progress_bar.secondMaxProgress) { + refresh_progress_bar.isAutoLoading = true + onRefreshStart?.invoke() + } else { + refresh_progress_bar.setSecondDurProgressWithAnim(0) + } + } + durTouchX = -1000000f + durTouchY = -1000000f + } + } + return false + } + }) + } + + fun startLoading() { + refresh_progress_bar.isAutoLoading = true + onRefreshStart?.invoke() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/recycler/scroller/FastScrollRecyclerView.kt b/app/src/main/java/io/legado/app/ui/widget/recycler/scroller/FastScrollRecyclerView.kt new file mode 100644 index 000000000..0a2ebf741 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/recycler/scroller/FastScrollRecyclerView.kt @@ -0,0 +1,195 @@ +package io.legado.app.ui.widget.recycler.scroller + +import android.content.Context +import android.util.AttributeSet +import android.view.ViewGroup +import androidx.annotation.ColorInt +import androidx.recyclerview.widget.RecyclerView +import io.legado.app.R + +class FastScrollRecyclerView : RecyclerView { + + private var mFastScroller: FastScroller? = null + + constructor(context: Context) : super(context) { + + layout(context, null) + + layoutParams = + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + + } + + + @JvmOverloads + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int = 0) : super(context, attrs, defStyleAttr) { + + layout(context, attrs) + + } + + + override fun setAdapter(adapter: Adapter<*>?) { + + super.setAdapter(adapter) + + if (adapter is FastScroller.SectionIndexer) { + setSectionIndexer(adapter as FastScroller.SectionIndexer?) + } else if (adapter == null) { + setSectionIndexer(null) + } + + } + + + override fun setVisibility(visibility: Int) { + + super.setVisibility(visibility) + mFastScroller?.visibility = visibility + + } + + + /** + * Set the [FastScroller.SectionIndexer] for the [FastScroller]. + * + * @param sectionIndexer The SectionIndexer that provides section text for the FastScroller + */ + fun setSectionIndexer(sectionIndexer: FastScroller.SectionIndexer?) { + + mFastScroller?.setSectionIndexer(sectionIndexer) + + } + + + /** + * Set the enabled state of fast scrolling. + * + * @param enabled True to enable fast scrolling, false otherwise + */ + fun setFastScrollEnabled(enabled: Boolean) { + + mFastScroller!!.isEnabled = enabled + + } + + + /** + * Hide the scrollbar when not scrolling. + * + * @param hideScrollbar True to hide the scrollbar, false to show + */ + fun setHideScrollbar(hideScrollbar: Boolean) { + + mFastScroller?.setFadeScrollbar(hideScrollbar) + + } + + /** + * Display a scroll track while scrolling. + * + * @param visible True to show scroll track, false to hide + */ + fun setTrackVisible(visible: Boolean) { + + mFastScroller?.setTrackVisible(visible) + + } + + /** + * Set the color of the scroll track. + * + * @param color The color for the scroll track + */ + fun setTrackColor(@ColorInt color: Int) { + + mFastScroller?.setTrackColor(color) + + } + + + /** + * Set the color for the scroll handle. + * + * @param color The color for the scroll handle + */ + fun setHandleColor(@ColorInt color: Int) { + + mFastScroller?.setHandleColor(color) + + } + + + /** + * Show the section bubble while scrolling. + * + * @param visible True to show the bubble, false to hide + */ + fun setBubbleVisible(visible: Boolean) { + + mFastScroller?.setBubbleVisible(visible) + + } + + + /** + * Set the background color of the index bubble. + * + * @param color The background color for the index bubble + */ + fun setBubbleColor(@ColorInt color: Int) { + + mFastScroller?.setBubbleColor(color) + + } + + + /** + * Set the text color of the index bubble. + * + * @param color The text color for the index bubble + */ + fun setBubbleTextColor(@ColorInt color: Int) { + mFastScroller?.setBubbleTextColor(color) + } + + + /** + * Set the fast scroll state change listener. + * + * @param fastScrollStateChangeListener The interface that will listen to fastscroll state change events + */ + fun setFastScrollStateChangeListener(fastScrollStateChangeListener: FastScrollStateChangeListener) { + + mFastScroller?.setFastScrollStateChangeListener(fastScrollStateChangeListener) + + } + + + override fun onAttachedToWindow() { + + super.onAttachedToWindow() + + mFastScroller?.attachRecyclerView(this) + + val parent = parent + if (parent is ViewGroup) { + parent.addView(mFastScroller) + mFastScroller?.setLayoutParams(parent) + } + } + + + override fun onDetachedFromWindow() { + mFastScroller?.detachRecyclerView() + super.onDetachedFromWindow() + } + + + private fun layout(context: Context, attrs: AttributeSet?) { + mFastScroller = FastScroller(context, attrs) + mFastScroller?.id = R.id.fast_scroller + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/recycler/scroller/FastScrollStateChangeListener.kt b/app/src/main/java/io/legado/app/ui/widget/recycler/scroller/FastScrollStateChangeListener.kt new file mode 100644 index 000000000..55afa8370 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/recycler/scroller/FastScrollStateChangeListener.kt @@ -0,0 +1,14 @@ +package io.legado.app.ui.widget.recycler.scroller + +interface FastScrollStateChangeListener { + + /** + * Called when fast scrolling begins + */ + fun onFastScrollStart(fastScroller: FastScroller) + + /** + * Called when fast scrolling ends + */ + fun onFastScrollStop(fastScroller: FastScroller) +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/recycler/scroller/FastScroller.kt b/app/src/main/java/io/legado/app/ui/widget/recycler/scroller/FastScroller.kt new file mode 100644 index 000000000..25af39c6a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/recycler/scroller/FastScroller.kt @@ -0,0 +1,519 @@ +package io.legado.app.ui.widget.recycler.scroller + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.ViewPropertyAnimator +import android.widget.* +import androidx.annotation.ColorInt +import androidx.annotation.IdRes +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat +import androidx.core.view.GravityCompat +import androidx.core.view.ViewCompat +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.StaggeredGridLayoutManager +import io.legado.app.R +import io.legado.app.lib.theme.accentColor +import io.legado.app.utils.ColorUtils +import io.legado.app.utils.getCompatColor +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + + +class FastScroller : LinearLayout { + @ColorInt + private var mBubbleColor: Int = 0 + @ColorInt + private var mHandleColor: Int = 0 + private var mBubbleHeight: Int = 0 + private var mHandleHeight: Int = 0 + private var mViewHeight: Int = 0 + private var mFadeScrollbar: Boolean = false + private var mShowBubble: Boolean = false + private var mSectionIndexer: SectionIndexer? = null + private var mScrollbarAnimator: ViewPropertyAnimator? = null + private var mBubbleAnimator: ViewPropertyAnimator? = null + private var mRecyclerView: RecyclerView? = null + private lateinit var mBubbleView: TextView + private lateinit var mHandleView: ImageView + private lateinit var mTrackView: ImageView + private lateinit var mScrollbar: View + private var mBubbleImage: Drawable? = null + private var mHandleImage: Drawable? = null + private var mTrackImage: Drawable? = null + private var mFastScrollStateChangeListener: FastScrollStateChangeListener? = null + private val mScrollbarHider = Runnable { this.hideScrollbar() } + + private val mScrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + if (!mHandleView.isSelected && isEnabled) { + setViewPositions(getScrollProportion(recyclerView)) + } + } + + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + super.onScrollStateChanged(recyclerView, newState) + if (isEnabled) { + when (newState) { + RecyclerView.SCROLL_STATE_DRAGGING -> { + handler.removeCallbacks(mScrollbarHider) + cancelAnimation(mScrollbarAnimator) + if (!isViewVisible(mScrollbar)) { + showScrollbar() + } + } + RecyclerView.SCROLL_STATE_IDLE -> if (mFadeScrollbar && !mHandleView.isSelected) { + handler.postDelayed(mScrollbarHider, sScrollbarHideDelay.toLong()) + } + } + } + } + } + + constructor(context: Context) : super(context) { + layout(context, null) + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT) + } + + @JvmOverloads + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int = 0) : super(context, attrs, defStyleAttr) { + layout(context, attrs) + layoutParams = generateLayoutParams(attrs) + } + + override fun setLayoutParams(params: ViewGroup.LayoutParams) { + params.width = LayoutParams.WRAP_CONTENT + super.setLayoutParams(params) + } + + fun setLayoutParams(viewGroup: ViewGroup) { + @IdRes val recyclerViewId = mRecyclerView?.id ?: View.NO_ID + val marginTop = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_top) + val marginBottom = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_bottom) + require(recyclerViewId != View.NO_ID) { "RecyclerView must have a view ID" } + when (viewGroup) { + is ConstraintLayout -> { + val constraintSet = ConstraintSet() + @IdRes val layoutId = id + constraintSet.clone(viewGroup) + constraintSet.connect(layoutId, ConstraintSet.TOP, recyclerViewId, ConstraintSet.TOP) + constraintSet.connect(layoutId, ConstraintSet.BOTTOM, recyclerViewId, ConstraintSet.BOTTOM) + constraintSet.connect(layoutId, ConstraintSet.END, recyclerViewId, ConstraintSet.END) + constraintSet.applyTo(viewGroup) + val layoutParams = layoutParams as ConstraintLayout.LayoutParams + layoutParams.setMargins(0, marginTop, 0, marginBottom) + setLayoutParams(layoutParams) + } + is CoordinatorLayout -> { + val layoutParams = layoutParams as CoordinatorLayout.LayoutParams + layoutParams.anchorId = recyclerViewId + layoutParams.anchorGravity = GravityCompat.END + layoutParams.setMargins(0, marginTop, 0, marginBottom) + setLayoutParams(layoutParams) + } + is FrameLayout -> { + val layoutParams = layoutParams as FrameLayout.LayoutParams + layoutParams.gravity = GravityCompat.END + layoutParams.setMargins(0, marginTop, 0, marginBottom) + setLayoutParams(layoutParams) + } + is RelativeLayout -> { + val layoutParams = layoutParams as RelativeLayout.LayoutParams + val endRule = RelativeLayout.ALIGN_END + layoutParams.addRule(RelativeLayout.ALIGN_TOP, recyclerViewId) + layoutParams.addRule(RelativeLayout.ALIGN_BOTTOM, recyclerViewId) + layoutParams.addRule(endRule, recyclerViewId) + layoutParams.setMargins(0, marginTop, 0, marginBottom) + setLayoutParams(layoutParams) + } + else -> throw IllegalArgumentException("Parent ViewGroup must be a ConstraintLayout, CoordinatorLayout, FrameLayout, or RelativeLayout") + } + updateViewHeights() + } + + fun setSectionIndexer(sectionIndexer: SectionIndexer?) { + mSectionIndexer = sectionIndexer + } + + fun attachRecyclerView(recyclerView: RecyclerView) { + mRecyclerView = recyclerView + if (mRecyclerView != null) { + mRecyclerView!!.addOnScrollListener(mScrollListener) + post { + // set initial positions for bubble and handle + setViewPositions(getScrollProportion(mRecyclerView)) + } + } + } + + fun detachRecyclerView() { + if (mRecyclerView != null) { + mRecyclerView!!.removeOnScrollListener(mScrollListener) + mRecyclerView = null + } + } + + /** + * Hide the scrollbar when not scrolling. + * + * @param fadeScrollbar True to hide the scrollbar, false to show + */ + fun setFadeScrollbar(fadeScrollbar: Boolean) { + mFadeScrollbar = fadeScrollbar + mScrollbar.visibility = if (fadeScrollbar) View.GONE else View.VISIBLE + } + + /** + * Show the section bubble while scrolling. + * + * @param visible True to show the bubble, false to hide + */ + fun setBubbleVisible(visible: Boolean) { + mShowBubble = visible + } + + /** + * Display a scroll track while scrolling. + * + * @param visible True to show scroll track, false to hide + */ + fun setTrackVisible(visible: Boolean) { + mTrackView.visibility = if (visible) View.VISIBLE else View.GONE + } + + /** + * Set the color of the scroll track. + * + * @param color The color for the scroll track + */ + fun setTrackColor(@ColorInt color: Int) { + if (mTrackImage == null) { + val drawable = ContextCompat.getDrawable(context, R.drawable.fastscroll_track) + if (drawable != null) { + mTrackImage = DrawableCompat.wrap(drawable) + } + } + DrawableCompat.setTint(mTrackImage!!, color) + mTrackView.setImageDrawable(mTrackImage) + } + + /** + * Set the color for the scroll handle. + * + * @param color The color for the scroll handle + */ + fun setHandleColor(@ColorInt color: Int) { + mHandleColor = color + if (mHandleImage == null) { + val drawable = ContextCompat.getDrawable(context, R.drawable.fastscroll_handle) + if (drawable != null) { + mHandleImage = DrawableCompat.wrap(drawable) + } + } + DrawableCompat.setTint(mHandleImage!!, mHandleColor) + mHandleView.setImageDrawable(mHandleImage) + } + + /** + * Set the background color of the index bubble. + * + * @param color The background color for the index bubble + */ + fun setBubbleColor(@ColorInt color: Int) { + mBubbleColor = color + if (mBubbleImage == null) { + val drawable = ContextCompat.getDrawable(context, R.drawable.fastscroll_bubble) + if (drawable != null) { + mBubbleImage = DrawableCompat.wrap(drawable) + } + } + DrawableCompat.setTint(mBubbleImage!!, mBubbleColor) + mBubbleView.background = mBubbleImage + } + + /** + * Set the text color of the index bubble. + * + * @param color The text color for the index bubble + */ + fun setBubbleTextColor(@ColorInt color: Int) { + mBubbleView.setTextColor(color) + } + + /** + * Set the fast scroll state change listener. + * + * @param fastScrollStateChangeListener The interface that will listen to fastscroll state change events + */ + fun setFastScrollStateChangeListener(fastScrollStateChangeListener: FastScrollStateChangeListener) { + mFastScrollStateChangeListener = fastScrollStateChangeListener + } + + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) + visibility = if (enabled) View.VISIBLE else View.GONE + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + when (event.action) { + MotionEvent.ACTION_DOWN -> { + if (event.x < mHandleView.x - ViewCompat.getPaddingStart(mHandleView)) { + return false + } + requestDisallowInterceptTouchEvent(true) + setHandleSelected(true) + handler.removeCallbacks(mScrollbarHider) + cancelAnimation(mScrollbarAnimator) + cancelAnimation(mBubbleAnimator) + if (!isViewVisible(mScrollbar)) { + showScrollbar() + } + if (mShowBubble && mSectionIndexer != null) { + showBubble() + } + if (mFastScrollStateChangeListener != null) { + mFastScrollStateChangeListener!!.onFastScrollStart(this) + } + val y = event.y + setViewPositions(y) + setRecyclerViewPosition(y) + return true + } + MotionEvent.ACTION_MOVE -> { + val y = event.y + setViewPositions(y) + setRecyclerViewPosition(y) + return true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + requestDisallowInterceptTouchEvent(false) + setHandleSelected(false) + if (mFadeScrollbar) { + handler.postDelayed(mScrollbarHider, sScrollbarHideDelay.toLong()) + } + hideBubble() + if (mFastScrollStateChangeListener != null) { + mFastScrollStateChangeListener!!.onFastScrollStop(this) + } + return true + } + } + return super.onTouchEvent(event) + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + mViewHeight = h + } + + private fun setRecyclerViewPosition(y: Float) { + if (mRecyclerView != null && mRecyclerView!!.adapter != null) { + val itemCount = mRecyclerView!!.adapter!!.itemCount + val proportion: Float = when { + mHandleView.y == 0f -> 0f + mHandleView.y + mHandleHeight >= mViewHeight - sTrackSnapRange -> 1f + else -> y / mViewHeight.toFloat() + } + var scrolledItemCount = (proportion * itemCount).roundToInt() + if (isLayoutReversed(mRecyclerView!!.layoutManager!!)) { + scrolledItemCount = itemCount - scrolledItemCount + } + val targetPos = getValueInRange(0, itemCount - 1, scrolledItemCount) + mRecyclerView!!.layoutManager!!.scrollToPosition(targetPos) + if (mShowBubble && mSectionIndexer != null) { + mBubbleView.text = mSectionIndexer!!.getSectionText(targetPos) + } + } + } + + private fun getScrollProportion(recyclerView: RecyclerView?): Float { + if (recyclerView == null) { + return 0f + } + val verticalScrollOffset = recyclerView.computeVerticalScrollOffset() + val verticalScrollRange = recyclerView.computeVerticalScrollRange() + val rangeDiff = (verticalScrollRange - mViewHeight).toFloat() + val proportion = verticalScrollOffset.toFloat() / if (rangeDiff > 0) rangeDiff else 1f + return mViewHeight * proportion + } + + private fun getValueInRange(min: Int, max: Int, value: Int): Int { + val minimum = max(min, value) + return min(minimum, max) + } + + private fun setViewPositions(y: Float) { + mBubbleHeight = mBubbleView.height + mHandleHeight = mHandleView.height + val bubbleY = getValueInRange(0, mViewHeight - mBubbleHeight - mHandleHeight / 2, (y - mBubbleHeight).toInt()) + val handleY = getValueInRange(0, mViewHeight - mHandleHeight, (y - mHandleHeight / 2).toInt()) + if (mShowBubble) { + mBubbleView.y = bubbleY.toFloat() + } + mHandleView.y = handleY.toFloat() + } + + private fun updateViewHeights() { + val measureSpec = MeasureSpec.makeMeasureSpec(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED) + mBubbleView.measure(measureSpec, measureSpec) + mBubbleHeight = mBubbleView.measuredHeight + mHandleView.measure(measureSpec, measureSpec) + mHandleHeight = mHandleView.measuredHeight + } + + private fun isLayoutReversed(layoutManager: RecyclerView.LayoutManager): Boolean { + if (layoutManager is LinearLayoutManager) { + return layoutManager.reverseLayout + } else if (layoutManager is StaggeredGridLayoutManager) { + return layoutManager.reverseLayout + } + return false + } + + private fun isViewVisible(view: View?): Boolean { + return view != null && view.visibility == View.VISIBLE + } + + private fun cancelAnimation(animator: ViewPropertyAnimator?) { + animator?.cancel() + } + + private fun showBubble() { + if (!isViewVisible(mBubbleView)) { + mBubbleView.visibility = View.VISIBLE + mBubbleAnimator = mBubbleView.animate().alpha(1f) + .setDuration(sBubbleAnimDuration.toLong()) + .setListener(object : AnimatorListenerAdapter() { + + // adapter required for new alpha value to stick + }) + } + } + + private fun hideBubble() { + if (isViewVisible(mBubbleView)) { + mBubbleAnimator = mBubbleView.animate().alpha(0f) + .setDuration(sBubbleAnimDuration.toLong()) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + super.onAnimationEnd(animation) + mBubbleView.visibility = View.GONE + mBubbleAnimator = null + } + + override fun onAnimationCancel(animation: Animator) { + super.onAnimationCancel(animation) + mBubbleView.visibility = View.GONE + mBubbleAnimator = null + } + }) + } + } + + private fun showScrollbar() { + mRecyclerView?.let { mRecyclerView -> + if (mRecyclerView.computeVerticalScrollRange() - mViewHeight > 0) { + val transX = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_padding_end).toFloat() + mScrollbar.translationX = transX + mScrollbar.visibility = View.VISIBLE + mScrollbarAnimator = mScrollbar.animate().translationX(0f).alpha(1f) + .setDuration(sScrollbarAnimDuration.toLong()) + .setListener(object : AnimatorListenerAdapter() { + + // adapter required for new alpha value to stick + }) + } + } + } + + private fun hideScrollbar() { + val transX = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_padding_end).toFloat() + mScrollbarAnimator = mScrollbar.animate().translationX(transX).alpha(0f) + .setDuration(sScrollbarAnimDuration.toLong()) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + super.onAnimationEnd(animation) + mScrollbar.visibility = View.GONE + mScrollbarAnimator = null + } + + override fun onAnimationCancel(animation: Animator) { + super.onAnimationCancel(animation) + mScrollbar.visibility = View.GONE + mScrollbarAnimator = null + } + }) + } + + private fun setHandleSelected(selected: Boolean) { + mHandleView.isSelected = selected + DrawableCompat.setTint(mHandleImage!!, if (selected) mBubbleColor else mHandleColor) + } + + private fun layout(context: Context, attrs: AttributeSet?) { + View.inflate(context, R.layout.view_fastscroller, this) + clipChildren = false + orientation = HORIZONTAL + mBubbleView = findViewById(R.id.fastscroll_bubble) + mHandleView = findViewById(R.id.fastscroll_handle) + mTrackView = findViewById(R.id.fastscroll_track) + mScrollbar = findViewById(R.id.fastscroll_scrollbar) + @ColorInt var bubbleColor = ColorUtils.adjustAlpha(context.accentColor, 0.8f) + @ColorInt var handleColor = context.accentColor + @ColorInt var trackColor = context.getCompatColor(R.color.transparent30) + @ColorInt var textColor = + if (ColorUtils.isColorLight(bubbleColor)) Color.BLACK else Color.WHITE + var fadeScrollbar = true + var showBubble = false + var showTrack = true + if (attrs != null) { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.FastScroller, 0, 0) + if (typedArray != null) { + try { + bubbleColor = typedArray.getColor(R.styleable.FastScroller_bubbleColor, bubbleColor) + handleColor = typedArray.getColor(R.styleable.FastScroller_handleColor, handleColor) + trackColor = typedArray.getColor(R.styleable.FastScroller_trackColor, trackColor) + textColor = typedArray.getColor(R.styleable.FastScroller_bubbleTextColor, textColor) + fadeScrollbar = typedArray.getBoolean(R.styleable.FastScroller_fadeScrollbar, fadeScrollbar) + showBubble = typedArray.getBoolean(R.styleable.FastScroller_showBubble, showBubble) + showTrack = typedArray.getBoolean(R.styleable.FastScroller_showTrack, showTrack) + } finally { + typedArray.recycle() + } + } + } + setTrackColor(trackColor) + setHandleColor(handleColor) + setBubbleColor(bubbleColor) + setBubbleTextColor(textColor) + setFadeScrollbar(fadeScrollbar) + setBubbleVisible(showBubble) + setTrackVisible(showTrack) + } + + interface SectionIndexer { + fun getSectionText(position: Int): String + } + + companion object { + private const val sBubbleAnimDuration = 100 + private const val sScrollbarAnimDuration = 300 + private const val sScrollbarHideDelay = 1000 + private const val sTrackSnapRange = 5 + } + +} diff --git a/app/src/main/java/io/legado/app/ui/widget/seekbar/VerticalSeekBar.kt b/app/src/main/java/io/legado/app/ui/widget/seekbar/VerticalSeekBar.kt new file mode 100644 index 000000000..f08e2b3c6 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/seekbar/VerticalSeekBar.kt @@ -0,0 +1,363 @@ +package io.legado.app.ui.widget.seekbar + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.KeyEvent +import android.view.MotionEvent +import android.widget.ProgressBar +import androidx.appcompat.widget.AppCompatSeekBar +import androidx.core.view.ViewCompat +import io.legado.app.R +import io.legado.app.lib.theme.ATH +import io.legado.app.lib.theme.ThemeStore +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method + +class VerticalSeekBar : AppCompatSeekBar { + + private var mIsDragging: Boolean = false + private var mThumb: Drawable? = null + private var mMethodSetProgressFromUser: Method? = null + private var mRotationAngle = ROTATION_ANGLE_CW_90 + + var rotationAngle: Int + get() = mRotationAngle + set(angle) { + require(isValidRotationAngle(angle)) { "Invalid angle specified :$angle" } + + if (mRotationAngle == angle) { + return + } + + mRotationAngle = angle + + if (useViewRotation()) { + val wrapper = wrapper + wrapper?.applyViewRotation() + } else { + requestLayout() + } + } + + private val wrapper: VerticalSeekBarWrapper? + get() { + val parent = parent + + return if (parent is VerticalSeekBarWrapper) { + parent + } else { + null + } + } + + constructor(context: Context) : super(context) { + initialize(context, null, 0, 0) + } + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { + initialize(context, attrs, 0, 0) + } + + constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super( + context, + attrs, + defStyle + ) { + initialize(context, attrs, defStyle, 0) + } + + private fun initialize( + context: Context, + attrs: AttributeSet?, + defStyleAttr: Int, + defStyleRes: Int + ) { + ATH.setTint(this, ThemeStore.accentColor(context)) + ViewCompat.setLayoutDirection(this, ViewCompat.LAYOUT_DIRECTION_LTR) + + if (attrs != null) { + val a = context.obtainStyledAttributes( + attrs, + R.styleable.VerticalSeekBar, + defStyleAttr, + defStyleRes + ) + val rotationAngle = a.getInteger(R.styleable.VerticalSeekBar_seekBarRotation, 0) + if (isValidRotationAngle(rotationAngle)) { + mRotationAngle = rotationAngle + } + a.recycle() + } + } + + override fun setThumb(thumb: Drawable) { + mThumb = thumb + super.setThumb(thumb) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + return if (useViewRotation()) { + onTouchEventUseViewRotation(event) + } else { + onTouchEventTraditionalRotation(event) + } + } + + private fun onTouchEventTraditionalRotation(event: MotionEvent): Boolean { + if (!isEnabled) { + return false + } + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + isPressed = true + onStartTrackingTouch() + trackTouchEvent(event) + attemptClaimDrag(true) + invalidate() + } + + MotionEvent.ACTION_MOVE -> if (mIsDragging) { + trackTouchEvent(event) + } + + MotionEvent.ACTION_UP -> { + if (mIsDragging) { + trackTouchEvent(event) + onStopTrackingTouch() + isPressed = false + } else { + // Touch up when we never crossed the touch slop threshold + // should + // be interpreted as a tap-seek to that location. + onStartTrackingTouch() + trackTouchEvent(event) + onStopTrackingTouch() + attemptClaimDrag(false) + } + // ProgressBar doesn't know to repaint the thumb drawable + // in its inactive state when the touch stops (because the + // value has not apparently changed) + invalidate() + } + + MotionEvent.ACTION_CANCEL -> { + if (mIsDragging) { + onStopTrackingTouch() + isPressed = false + } + invalidate() // see above explanation + } + } + return true + } + + private fun onTouchEventUseViewRotation(event: MotionEvent): Boolean { + val handled = super.onTouchEvent(event) + + if (handled) { + when (event.action) { + MotionEvent.ACTION_DOWN -> attemptClaimDrag(true) + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> attemptClaimDrag(false) + } + } + + return handled + } + + private fun trackTouchEvent(event: MotionEvent) { + val paddingLeft = super.getPaddingLeft() + val paddingRight = super.getPaddingRight() + val height = height + + val available = height - paddingLeft - paddingRight + val y = event.y.toInt() + + val scale: Float + var value = 0f + + when (mRotationAngle) { + ROTATION_ANGLE_CW_90 -> value = (y - paddingLeft).toFloat() + ROTATION_ANGLE_CW_270 -> value = (height - paddingLeft - y).toFloat() + } + + scale = if (value < 0 || available == 0) { + 0.0f + } else if (value > available) { + 1.0f + } else { + value / available.toFloat() + } + + val max = max + val progress = scale * max + + setProgressFromUser(progress.toInt(), true) + } + + /** + * Tries to claim the user's drag motion, and requests disallowing any + * ancestors from stealing events in the drag. + */ + private fun attemptClaimDrag(active: Boolean) { + val parent = parent + parent?.requestDisallowInterceptTouchEvent(active) + } + + /** + * This is called when the user has started touching this widget. + */ + private fun onStartTrackingTouch() { + mIsDragging = true + } + + /** + * This is called when the user either releases his touch or the touch is + * canceled. + */ + private fun onStopTrackingTouch() { + mIsDragging = false + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + if (isEnabled) { + val handled: Boolean + var direction = 0 + + when (keyCode) { + KeyEvent.KEYCODE_DPAD_DOWN -> { + direction = if (mRotationAngle == ROTATION_ANGLE_CW_90) 1 else -1 + handled = true + } + KeyEvent.KEYCODE_DPAD_UP -> { + direction = if (mRotationAngle == ROTATION_ANGLE_CW_270) 1 else -1 + handled = true + } + KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT -> + // move view focus to previous/next view + return false + else -> handled = false + } + + if (handled) { + val keyProgressIncrement = keyProgressIncrement + var progress = progress + + progress += direction * keyProgressIncrement + + if (progress in 0..max) { + setProgressFromUser(progress, true) + } + + return true + } + } + + return super.onKeyDown(keyCode, event) + } + + @Synchronized + override fun setProgress(progress: Int) { + super.setProgress(progress) + if (!useViewRotation()) { + refreshThumb() + } + } + + @Synchronized + private fun setProgressFromUser(progress: Int, fromUser: Boolean) { + if (mMethodSetProgressFromUser == null) { + try { + val m: Method = ProgressBar::class.java.getDeclaredMethod( + "setProgress", + Int::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType + ) + m.isAccessible = true + mMethodSetProgressFromUser = m + } catch (e: NoSuchMethodException) { + } + + } + + if (mMethodSetProgressFromUser != null) { + try { + mMethodSetProgressFromUser!!.invoke(this, progress, fromUser) + } catch (e: IllegalArgumentException) { + } catch (e: IllegalAccessException) { + } catch (e: InvocationTargetException) { + } + + } else { + super.setProgress(progress) + } + refreshThumb() + } + + @Synchronized + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + if (useViewRotation()) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } else { + super.onMeasure(heightMeasureSpec, widthMeasureSpec) + + val lp = layoutParams + + if (isInEditMode && lp != null && lp.height >= 0) { + setMeasuredDimension(super.getMeasuredHeight(), lp.height) + } else { + setMeasuredDimension(super.getMeasuredHeight(), super.getMeasuredWidth()) + } + } + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + if (useViewRotation()) { + super.onSizeChanged(w, h, oldw, oldh) + } else { + super.onSizeChanged(h, w, oldh, oldw) + } + } + + @Synchronized + override fun onDraw(canvas: Canvas) { + if (!useViewRotation()) { + when (mRotationAngle) { + ROTATION_ANGLE_CW_90 -> { + canvas.rotate(90f) + canvas.translate(0f, (-super.getWidth()).toFloat()) + } + ROTATION_ANGLE_CW_270 -> { + canvas.rotate(-90f) + canvas.translate((-super.getHeight()).toFloat(), 0f) + } + } + } + + super.onDraw(canvas) + } + + // refresh thumb position + private fun refreshThumb() { + onSizeChanged(super.getWidth(), super.getHeight(), 0, 0) + } + + /*package*/ + internal fun useViewRotation(): Boolean { + return !isInEditMode + } + + companion object { + const val ROTATION_ANGLE_CW_90 = 90 + const val ROTATION_ANGLE_CW_270 = 270 + + private fun isValidRotationAngle(angle: Int): Boolean { + return angle == ROTATION_ANGLE_CW_90 || angle == ROTATION_ANGLE_CW_270 + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/seekbar/VerticalSeekBarWrapper.kt b/app/src/main/java/io/legado/app/ui/widget/seekbar/VerticalSeekBarWrapper.kt new file mode 100644 index 000000000..da69c33ab --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/seekbar/VerticalSeekBarWrapper.kt @@ -0,0 +1,184 @@ +package io.legado.app.ui.widget.seekbar + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout + +import androidx.core.view.ViewCompat +import kotlin.math.max + +class VerticalSeekBarWrapper @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + private val childSeekBar: VerticalSeekBar? + get() { + val child = if (childCount > 0) getChildAt(0) else null + return if (child is VerticalSeekBar) child else null + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + if (useViewRotation()) { + onSizeChangedUseViewRotation(w, h, oldw, oldh) + } else { + onSizeChangedTraditionalRotation(w, h, oldw, oldh) + } + } + + @SuppressLint("RtlHardcoded") + private fun onSizeChangedTraditionalRotation(w: Int, h: Int, oldw: Int, oldh: Int) { + val seekBar = childSeekBar + + if (seekBar != null) { + val hPadding = paddingLeft + paddingRight + val vPadding = paddingTop + paddingBottom + val lp = seekBar.layoutParams as LayoutParams + + lp.width = ViewGroup.LayoutParams.WRAP_CONTENT + lp.height = max(0, h - vPadding) + seekBar.layoutParams = lp + + seekBar.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED) + + val seekBarMeasuredWidth = seekBar.measuredWidth + seekBar.measure( + MeasureSpec.makeMeasureSpec( + max(0, w - hPadding), + MeasureSpec.AT_MOST + ), + MeasureSpec.makeMeasureSpec( + max(0, h - vPadding), + MeasureSpec.EXACTLY + ) + ) + + lp.gravity = Gravity.TOP or Gravity.LEFT + lp.leftMargin = (max(0, w - hPadding) - seekBarMeasuredWidth) / 2 + seekBar.layoutParams = lp + } + + super.onSizeChanged(w, h, oldw, oldh) + } + + private fun onSizeChangedUseViewRotation(w: Int, h: Int, oldw: Int, oldh: Int) { + val seekBar = childSeekBar + + if (seekBar != null) { + val hPadding = paddingLeft + paddingRight + val vPadding = paddingTop + paddingBottom + seekBar.measure( + MeasureSpec.makeMeasureSpec( + max(0, h - vPadding), + MeasureSpec.EXACTLY + ), + MeasureSpec.makeMeasureSpec( + max(0, w - hPadding), + MeasureSpec.AT_MOST + ) + ) + } + + applyViewRotation(w, h) + super.onSizeChanged(w, h, oldw, oldh) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val seekBar = childSeekBar + val widthMode = MeasureSpec.getMode(widthMeasureSpec) + val heightMode = MeasureSpec.getMode(heightMeasureSpec) + val widthSize = MeasureSpec.getSize(widthMeasureSpec) + val heightSize = MeasureSpec.getSize(heightMeasureSpec) + + if (seekBar != null && widthMode != MeasureSpec.EXACTLY) { + val seekBarWidth: Int + val seekBarHeight: Int + val hPadding = paddingLeft + paddingRight + val vPadding = paddingTop + paddingBottom + val innerContentWidthMeasureSpec = + MeasureSpec.makeMeasureSpec(max(0, widthSize - hPadding), widthMode) + val innerContentHeightMeasureSpec = + MeasureSpec.makeMeasureSpec(max(0, heightSize - vPadding), heightMode) + + if (useViewRotation()) { + seekBar.measure(innerContentHeightMeasureSpec, innerContentWidthMeasureSpec) + seekBarWidth = seekBar.measuredHeight + seekBarHeight = seekBar.measuredWidth + } else { + seekBar.measure(innerContentWidthMeasureSpec, innerContentHeightMeasureSpec) + seekBarWidth = seekBar.measuredWidth + seekBarHeight = seekBar.measuredHeight + } + + val measuredWidth = + View.resolveSizeAndState(seekBarWidth + hPadding, widthMeasureSpec, 0) + val measuredHeight = + View.resolveSizeAndState(seekBarHeight + vPadding, heightMeasureSpec, 0) + + setMeasuredDimension(measuredWidth, measuredHeight) + } else { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + } + + /*package*/ + internal fun applyViewRotation() { + applyViewRotation(width, height) + } + + private fun applyViewRotation(w: Int, h: Int) { + val seekBar = childSeekBar + + if (seekBar != null) { + val isLTR = ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_LTR + val rotationAngle = seekBar.rotationAngle + val seekBarMeasuredWidth = seekBar.measuredWidth + val seekBarMeasuredHeight = seekBar.measuredHeight + val hPadding = paddingLeft + paddingRight + val vPadding = paddingTop + paddingBottom + val hOffset = (max(0, w - hPadding) - seekBarMeasuredHeight) * 0.5f + val lp = seekBar.layoutParams + + lp.width = max(0, h - vPadding) + lp.height = ViewGroup.LayoutParams.WRAP_CONTENT + + seekBar.layoutParams = lp + + seekBar.pivotX = (if (isLTR) 0 else max(0, h - vPadding)).toFloat() + seekBar.pivotY = 0f + + when (rotationAngle) { + VerticalSeekBar.ROTATION_ANGLE_CW_90 -> { + seekBar.rotation = 90f + if (isLTR) { + seekBar.translationX = seekBarMeasuredHeight + hOffset + seekBar.translationY = 0f + } else { + seekBar.translationX = -hOffset + seekBar.translationY = seekBarMeasuredWidth.toFloat() + } + } + VerticalSeekBar.ROTATION_ANGLE_CW_270 -> { + seekBar.rotation = 270f + if (isLTR) { + seekBar.translationX = hOffset + seekBar.translationY = seekBarMeasuredWidth.toFloat() + } else { + seekBar.translationX = -(seekBarMeasuredHeight + hOffset) + seekBar.translationY = 0f + } + } + } + } + } + + private fun useViewRotation(): Boolean { + val seekBar = childSeekBar + return seekBar?.useViewRotation() ?: false + } +} diff --git a/app/src/main/java/io/legado/app/utils/ACache.kt b/app/src/main/java/io/legado/app/utils/ACache.kt new file mode 100644 index 000000000..7549813e8 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/ACache.kt @@ -0,0 +1,779 @@ +//Copyright (c) 2017. 章钦豪. All rights reserved. +package io.legado.app.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.PixelFormat +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.util.Log +import io.legado.app.App +import org.json.JSONArray +import org.json.JSONObject +import java.io.* +import java.util.* +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +import kotlin.math.min + + +/** + * 本地缓存 + */ +class ACache private constructor(cacheDir: File, max_size: Long, max_count: Int) { + + + companion object { + const val TIME_HOUR = 60 * 60 + const val TIME_DAY = TIME_HOUR * 24 + private const val MAX_SIZE = 1000 * 1000 * 50 // 50 mb + private const val MAX_COUNT = Integer.MAX_VALUE // 不限制存放数据的数量 + private val mInstanceMap = HashMap() + + @JvmOverloads + fun get( + ctx: Context, + cacheName: String = "ACache", + maxSize: Long = MAX_SIZE.toLong(), + maxCount: Int = MAX_COUNT, + cacheDir: Boolean = true + ): ACache { + val f = if (cacheDir) File(ctx.cacheDir, cacheName) else File(ctx.filesDir, cacheName) + return get(f, maxSize, maxCount) + } + + @JvmOverloads + fun get( + cacheDir: File, + maxSize: Long = MAX_SIZE.toLong(), + maxCount: Int = MAX_COUNT + ): ACache { + synchronized(this) { + var manager = mInstanceMap[cacheDir.absoluteFile.toString() + myPid()] + if (manager == null) { + manager = ACache(cacheDir, maxSize, maxCount) + mInstanceMap[cacheDir.absolutePath + myPid()] = manager + } + return manager + } + } + + private fun myPid(): String { + return "_" + android.os.Process.myPid() + } + } + + private var mCache: ACacheManager? = null + + init { + try { + if (!cacheDir.exists() && !cacheDir.mkdirs()) { + Log.i("ACache", "can't make dirs in %s" + cacheDir.absolutePath) + } + mCache = ACacheManager(cacheDir, max_size, max_count) + } catch (e: Exception) { + e.printStackTrace() + } + + } + + // ======================================= + // ============ String数据 读写 ============== + // ======================================= + + /** + * 保存 String数据 到 缓存中 + * + * @param key 保存的key + * @param value 保存的String数据 + */ + fun put(key: String, value: String) { + mCache?.let { mCache -> + try { + val file = mCache.newFile(key) + file.writeText(value) + mCache.put(file) + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + /** + * 保存 String数据 到 缓存中 + * + * @param key 保存的key + * @param value 保存的String数据 + * @param saveTime 保存的时间,单位:秒 + */ + fun put(key: String, value: String, saveTime: Int) { + put(key, Utils.newStringWithDateInfo(saveTime, value)) + } + + /** + * 读取 String数据 + * + * @return String 数据 + */ + fun getAsString(key: String): String? { + mCache?.let { mCache -> + val file = mCache[key] + if (!file.exists()) + return null + var removeFile = false + try { + val text = file.readText() + if (!Utils.isDue(text)) { + return Utils.clearDateInfo(text) + } else { + removeFile = true + } + } catch (e: IOException) { + e.printStackTrace() + } finally { + if (removeFile) + remove(key) + } + } + return null + } + + // ======================================= + // ========== JSONObject 数据 读写 ========= + // ======================================= + + /** + * 保存 JSONObject数据 到 缓存中 + * + * @param key 保存的key + * @param value 保存的JSON数据 + */ + fun put(key: String, value: JSONObject) { + put(key, value.toString()) + } + + /** + * 保存 JSONObject数据 到 缓存中 + * + * @param key 保存的key + * @param value 保存的JSONObject数据 + * @param saveTime 保存的时间,单位:秒 + */ + fun put(key: String, value: JSONObject, saveTime: Int) { + put(key, value.toString(), saveTime) + } + + /** + * 读取JSONObject数据 + * + * @return JSONObject数据 + */ + fun getAsJSONObject(key: String): JSONObject? { + val json = getAsString(key) + return try { + JSONObject(json) + } catch (e: Exception) { + null + } + } + + // ======================================= + // ============ JSONArray 数据 读写 ============= + // ======================================= + + /** + * 保存 JSONArray数据 到 缓存中 + * + * @param key 保存的key + * @param value 保存的JSONArray数据 + */ + fun put(key: String, value: JSONArray) { + put(key, value.toString()) + } + + /** + * 保存 JSONArray数据 到 缓存中 + * + * @param key 保存的key + * @param value 保存的JSONArray数据 + * @param saveTime 保存的时间,单位:秒 + */ + fun put(key: String, value: JSONArray, saveTime: Int) { + put(key, value.toString(), saveTime) + } + + /** + * 读取JSONArray数据 + * + * @return JSONArray数据 + */ + fun getAsJSONArray(key: String): JSONArray? { + val json = getAsString(key) + return try { + JSONArray(json) + } catch (e: Exception) { + null + } + + } + + // ======================================= + // ============== byte 数据 读写 ============= + // ======================================= + + /** + * 保存 byte数据 到 缓存中 + * + * @param key 保存的key + * @param value 保存的数据 + */ + fun put(key: String, value: ByteArray) { + mCache?.let { mCache -> + val file = mCache.newFile(key) + file.writeBytes(value) + mCache.put(file) + } + } + + /** + * 保存 byte数据 到 缓存中 + * + * @param key 保存的key + * @param value 保存的数据 + * @param saveTime 保存的时间,单位:秒 + */ + fun put(key: String, value: ByteArray, saveTime: Int) { + put(key, Utils.newByteArrayWithDateInfo(saveTime, value)) + } + + /** + * 获取 byte 数据 + * + * @return byte 数据 + */ + fun getAsBinary(key: String): ByteArray? { + mCache?.let { mCache -> + var removeFile = false + try { + val file = mCache[key] + if (!file.exists()) + return null + + val byteArray = file.readBytes() + return if (!Utils.isDue(byteArray)) { + Utils.clearDateInfo(byteArray) + } else { + removeFile = true + null + } + } catch (e: Exception) { + e.printStackTrace() + } finally { + if (removeFile) + remove(key) + } + } + return null + } + + /** + * 保存 Serializable数据到 缓存中 + * + * @param key 保存的key + * @param value 保存的value + * @param saveTime 保存的时间,单位:秒 + */ + @JvmOverloads + fun put(key: String, value: Serializable, saveTime: Int = -1) { + try { + val byteArrayOutputStream = ByteArrayOutputStream() + ObjectOutputStream(byteArrayOutputStream).use { oos -> + oos.writeObject(value) + val data = byteArrayOutputStream.toByteArray() + if (saveTime != -1) { + put(key, data, saveTime) + } else { + put(key, data) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + /** + * 读取 Serializable数据 + * + * @return Serializable 数据 + */ + fun getAsObject(key: String): Any? { + val data = getAsBinary(key) + if (data != null) { + var bais: ByteArrayInputStream? = null + var ois: ObjectInputStream? = null + try { + bais = ByteArrayInputStream(data) + ois = ObjectInputStream(bais) + return ois.readObject() + } catch (e: Exception) { + e.printStackTrace() + } finally { + try { + bais?.close() + } catch (e: IOException) { + e.printStackTrace() + } + + try { + ois?.close() + } catch (e: IOException) { + e.printStackTrace() + } + + } + } + return null + + } + + // ======================================= + // ============== bitmap 数据 读写 ============= + // ======================================= + + /** + * 保存 bitmap 到 缓存中 + * + * @param key 保存的key + * @param value 保存的bitmap数据 + */ + fun put(key: String, value: Bitmap) { + put(key, Utils.bitmap2Bytes(value)) + } + + /** + * 保存 bitmap 到 缓存中 + * + * @param key 保存的key + * @param value 保存的 bitmap 数据 + * @param saveTime 保存的时间,单位:秒 + */ + fun put(key: String, value: Bitmap, saveTime: Int) { + put(key, Utils.bitmap2Bytes(value), saveTime) + } + + /** + * 读取 bitmap 数据 + * + * @return bitmap 数据 + */ + fun getAsBitmap(key: String): Bitmap? { + return if (getAsBinary(key) == null) { + null + } else Utils.bytes2Bitmap(getAsBinary(key)!!) + } + + // ======================================= + // ============= drawable 数据 读写 ============= + // ======================================= + + /** + * 保存 drawable 到 缓存中 + * + * @param key 保存的key + * @param value 保存的drawable数据 + */ + fun put(key: String, value: Drawable) { + put(key, Utils.drawable2Bitmap(value)) + } + + /** + * 保存 drawable 到 缓存中 + * + * @param key 保存的key + * @param value 保存的 drawable 数据 + * @param saveTime 保存的时间,单位:秒 + */ + fun put(key: String, value: Drawable, saveTime: Int) { + put(key, Utils.drawable2Bitmap(value), saveTime) + } + + /** + * 读取 Drawable 数据 + * + * @return Drawable 数据 + */ + fun getAsDrawable(key: String): Drawable? { + return if (getAsBinary(key) == null) { + null + } else Utils.bitmap2Drawable( + Utils.bytes2Bitmap( + getAsBinary(key)!! + ) + ) + } + + /** + * 获取缓存文件 + * + * @return value 缓存的文件 + */ + fun file(key: String): File? { + mCache?.let { mCache -> + try { + val f = mCache.newFile(key) + if (f.exists()) { + return f + } + } catch (e: Exception) { + e.printStackTrace() + } + } + return null + } + + /** + * 移除某个key + * + * @return 是否移除成功 + */ + fun remove(key: String): Boolean { + return mCache?.remove(key) == true + } + + /** + * 清除所有数据 + */ + fun clear() { + mCache?.clear() + } + + /** + * @author 杨福海(michael) www.yangfuhai.com + * @version 1.0 + * title 时间计算工具类 + */ + private object Utils { + + private const val mSeparator = ' ' + + /** + * 判断缓存的String数据是否到期 + * + * @return true:到期了 false:还没有到期 + */ + fun isDue(str: String): Boolean { + return isDue(str.toByteArray()) + } + + /** + * 判断缓存的byte数据是否到期 + * + * @return true:到期了 false:还没有到期 + */ + fun isDue(data: ByteArray): Boolean { + try { + val text = getDateInfoFromDate(data) + if (text != null && text.size == 2) { + var saveTimeStr = text[0] + while (saveTimeStr.startsWith("0")) { + saveTimeStr = saveTimeStr + .substring(1) + } + val saveTime = java.lang.Long.valueOf(saveTimeStr) + val deleteAfter = java.lang.Long.valueOf(text[1]) + if (System.currentTimeMillis() > saveTime + deleteAfter * 1000) { + return true + } + } + } catch (e: Exception) { + e.printStackTrace() + } + + return false + } + + fun newStringWithDateInfo(second: Int, strInfo: String): String { + return createDateInfo(second) + strInfo + } + + fun newByteArrayWithDateInfo(second: Int, data2: ByteArray): ByteArray { + val data1 = createDateInfo(second).toByteArray() + val retData = ByteArray(data1.size + data2.size) + System.arraycopy(data1, 0, retData, 0, data1.size) + System.arraycopy(data2, 0, retData, data1.size, data2.size) + return retData + } + + fun clearDateInfo(strInfo: String?): String? { + strInfo?.let { + if (hasDateInfo(strInfo.toByteArray())) { + return strInfo.substring(strInfo.indexOf(mSeparator) + 1) + } + } + return strInfo + } + + fun clearDateInfo(data: ByteArray): ByteArray { + return if (hasDateInfo(data)) { + copyOfRange( + data, indexOf(data, mSeparator) + 1, + data.size + ) + } else data + } + + fun hasDateInfo(data: ByteArray?): Boolean { + return (data != null && data.size > 15 && data[13] == '-'.toByte() + && indexOf(data, mSeparator) > 14) + } + + fun getDateInfoFromDate(data: ByteArray): Array? { + if (hasDateInfo(data)) { + val saveDate = String(copyOfRange(data, 0, 13)) + val deleteAfter = String( + copyOfRange( + data, 14, + indexOf(data, mSeparator) + ) + ) + return arrayOf(saveDate, deleteAfter) + } + return null + } + + private fun indexOf(data: ByteArray, c: Char): Int { + for (i in data.indices) { + if (data[i] == c.toByte()) { + return i + } + } + return -1 + } + + private fun copyOfRange(original: ByteArray, from: Int, to: Int): ByteArray { + val newLength = to - from + require(newLength >= 0) { "$from > $to" } + val copy = ByteArray(newLength) + System.arraycopy( + original, from, copy, 0, + min(original.size - from, newLength) + ) + return copy + } + + private fun createDateInfo(second: Int): String { + val currentTime = StringBuilder(System.currentTimeMillis().toString() + "") + while (currentTime.length < 13) { + currentTime.insert(0, "0") + } + return "$currentTime-$second$mSeparator" + } + + /* + * Bitmap → byte[] + */ + fun bitmap2Bytes(bm: Bitmap): ByteArray { + val byteArrayOutputStream = ByteArrayOutputStream() + bm.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream) + return byteArrayOutputStream.toByteArray() + } + + /* + * byte[] → Bitmap + */ + fun bytes2Bitmap(b: ByteArray): Bitmap? { + return if (b.isEmpty()) { + null + } else BitmapFactory.decodeByteArray(b, 0, b.size) + } + + /* + * Drawable → Bitmap + */ + fun drawable2Bitmap(drawable: Drawable): Bitmap { + // 取 drawable 的长宽 + val w = drawable.intrinsicWidth + val h = drawable.intrinsicHeight + // 取 drawable 的颜色格式 + val config = if (drawable.opacity != PixelFormat.OPAQUE) + Bitmap.Config.ARGB_8888 + else + Bitmap.Config.RGB_565 + // 建立对应 bitmap + val bitmap = Bitmap.createBitmap(w, h, config) + // 建立对应 bitmap 的画布 + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, w, h) + // 把 drawable 内容画到画布中 + drawable.draw(canvas) + return bitmap + } + + /* + * Bitmap → Drawable + */ + fun bitmap2Drawable(bm: Bitmap?): Drawable? { + return if (bm == null) { + null + } else BitmapDrawable(App.INSTANCE.resources, bm) + } + } + + /** + * @author 杨福海(michael) www.yangfuhai.com + * @version 1.0 + * title 缓存管理器 + */ + open inner class ACacheManager( + private var cacheDir: File, + private val sizeLimit: Long, + private val countLimit: Int + ) { + private val cacheSize: AtomicLong = AtomicLong() + private val cacheCount: AtomicInteger = AtomicInteger() + private val lastUsageDates = Collections + .synchronizedMap(HashMap()) + + init { + calculateCacheSizeAndCacheCount() + } + + /** + * 计算 cacheSize和cacheCount + */ + private fun calculateCacheSizeAndCacheCount() { + Thread { + + try { + var size = 0 + var count = 0 + val cachedFiles = cacheDir.listFiles() + if (cachedFiles != null) { + for (cachedFile in cachedFiles) { + size += calculateSize(cachedFile).toInt() + count += 1 + lastUsageDates[cachedFile] = cachedFile.lastModified() + } + cacheSize.set(size.toLong()) + cacheCount.set(count) + } + } catch (e: Exception) { + e.printStackTrace() + } + + + }.start() + } + + fun put(file: File) { + + try { + var curCacheCount = cacheCount.get() + while (curCacheCount + 1 > countLimit) { + val freedSize = removeNext() + cacheSize.addAndGet(-freedSize) + + curCacheCount = cacheCount.addAndGet(-1) + } + cacheCount.addAndGet(1) + + val valueSize = calculateSize(file) + var curCacheSize = cacheSize.get() + while (curCacheSize + valueSize > sizeLimit) { + val freedSize = removeNext() + curCacheSize = cacheSize.addAndGet(-freedSize) + } + cacheSize.addAndGet(valueSize) + + val currentTime = System.currentTimeMillis() + file.setLastModified(currentTime) + lastUsageDates[file] = currentTime + } catch (e: Exception) { + e.printStackTrace() + } + + } + + operator fun get(key: String): File { + val file = newFile(key) + val currentTime = System.currentTimeMillis() + file.setLastModified(currentTime) + lastUsageDates[file] = currentTime + + return file + } + + fun newFile(key: String): File { + return File(cacheDir, key.hashCode().toString() + "") + } + + fun remove(key: String): Boolean { + val image = get(key) + return image.delete() + } + + fun clear() { + try { + lastUsageDates.clear() + cacheSize.set(0) + val files = cacheDir.listFiles() + if (files != null) { + for (f in files) { + f.delete() + } + } + } catch (e: Exception) { + e.printStackTrace() + } + + } + + /** + * 移除旧的文件 + */ + private fun removeNext(): Long { + try { + if (lastUsageDates.isEmpty()) { + return 0 + } + + var oldestUsage: Long? = null + var mostLongUsedFile: File? = null + val entries = lastUsageDates.entries + synchronized(lastUsageDates) { + for ((key, lastValueUsage) in entries) { + if (mostLongUsedFile == null) { + mostLongUsedFile = key + oldestUsage = lastValueUsage + } else { + if (lastValueUsage < oldestUsage!!) { + oldestUsage = lastValueUsage + mostLongUsedFile = key + } + } + } + } + + var fileSize: Long = 0 + if (mostLongUsedFile != null) { + fileSize = calculateSize(mostLongUsedFile!!) + if (mostLongUsedFile!!.delete()) { + lastUsageDates.remove(mostLongUsedFile) + } + } + return fileSize + } catch (e: Exception) { + e.printStackTrace() + return 0 + } + + } + + private fun calculateSize(file: File): Long { + return file.length() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/AlertDialogExtensions.kt b/app/src/main/java/io/legado/app/utils/AlertDialogExtensions.kt new file mode 100644 index 000000000..2f261a2e7 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/AlertDialogExtensions.kt @@ -0,0 +1,13 @@ +package io.legado.app.utils + +import android.view.WindowManager +import androidx.appcompat.app.AlertDialog +import io.legado.app.lib.theme.ATH + +fun AlertDialog.applyTint(): AlertDialog { + return ATH.setAlertDialogTint(this) +} + +fun AlertDialog.requestInputMethod(){ + window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) +} diff --git a/app/src/main/java/io/legado/app/utils/AnkoExtensions.kt b/app/src/main/java/io/legado/app/utils/AnkoExtensions.kt deleted file mode 100644 index 4fdace873..000000000 --- a/app/src/main/java/io/legado/app/utils/AnkoExtensions.kt +++ /dev/null @@ -1,53 +0,0 @@ -package io.legado.app.utils - -import android.view.View -import androidx.annotation.StringRes -import androidx.fragment.app.Fragment -import com.google.android.material.snackbar.Snackbar -import org.jetbrains.anko.dip -import org.jetbrains.anko.longToast -import org.jetbrains.anko.toast - - -/** - * Display the Snackbar with the [Snackbar.LENGTH_SHORT] duration. - * - * @param message the message text resource. - */ -@JvmName("snackbar2") -fun View.snackbar(@StringRes message: Int) = Snackbar - .make(this, message, Snackbar.LENGTH_SHORT) - .apply { show() } - -/** - * Display Snackbar with the [Snackbar.LENGTH_LONG] duration. - * - * @param message the message text resource. - */ -@JvmName("longSnackbar2") -fun View.longSnackbar(@StringRes message: Int) = Snackbar - .make(this, message, Snackbar.LENGTH_LONG) - .apply { show() } - -/** - * Display Snackbar with the [Snackbar.LENGTH_LONG] duration. - * - * @param message the message text resource. - */ -@JvmName("longSnackbar2") -fun View.longSnackbar(@StringRes message: Int, @StringRes actionText: Int, action: (View) -> Unit) = Snackbar - .make(this, message, Snackbar.LENGTH_LONG) - .setAction(actionText, action) - .apply { show() } - -fun Fragment.toast(textResource: Int) = requireActivity().toast(textResource) - -fun Fragment.toast(text: CharSequence) = requireActivity().toast(text) - -fun Fragment.longToast(textResource: Int) = requireActivity().longToast(textResource) - -fun Fragment.longToast(text: CharSequence) = requireActivity().longToast(text) - -fun Fragment.dip(value: Int): Int = requireActivity().dip(value) - -fun Fragment.dip(value: Float): Int = requireActivity().dip(value) diff --git a/app/src/main/java/io/legado/app/utils/BatteryUtils.kt b/app/src/main/java/io/legado/app/utils/BatteryUtils.kt new file mode 100644 index 000000000..c7d49a3aa --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/BatteryUtils.kt @@ -0,0 +1,16 @@ +package io.legado.app.utils + +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.BatteryManager + +object BatteryUtils { + + fun getLevel(context: Context): Int { + val iFilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED) + val batteryStatus = context.registerReceiver(null, iFilter) + + return batteryStatus?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1 + } +} diff --git a/app/src/main/java/io/legado/app/utils/BitmapUtils.kt b/app/src/main/java/io/legado/app/utils/BitmapUtils.kt new file mode 100644 index 000000000..00f1d4c58 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/BitmapUtils.kt @@ -0,0 +1,255 @@ +package io.legado.app.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Bitmap.Config +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.renderscript.Allocation +import android.renderscript.Element +import android.renderscript.RenderScript +import android.renderscript.ScriptIntrinsicBlur +import android.view.View +import io.legado.app.App +import java.io.IOException +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.min +import kotlin.math.sqrt + + +@Suppress("unused", "WeakerAccess") +object BitmapUtils { + /** + * 从path中获取图片信息,在通过BitmapFactory.decodeFile(String path)方法将突破转成Bitmap时, + * 遇到大一些的图片,我们经常会遇到OOM(Out Of Memory)的问题。所以用到了我们上面提到的BitmapFactory.Options这个类。 + * + * @param path 文件路径 + * @param width 想要显示的图片的宽度 + * @param height 想要显示的图片的高度 + * @return + */ + fun decodeBitmap(path: String, width: Int, height: Int): Bitmap { + val op = BitmapFactory.Options() + // inJustDecodeBounds如果设置为true,仅仅返回图片实际的宽和高,宽和高是赋值给opts.outWidth,opts.outHeight; + op.inJustDecodeBounds = true + BitmapFactory.decodeFile(path, op) //获取尺寸信息 + //获取比例大小 + val wRatio = ceil((op.outWidth / width).toDouble()).toInt() + val hRatio = ceil((op.outHeight / height).toDouble()).toInt() + //如果超出指定大小,则缩小相应的比例 + if (wRatio > 1 && hRatio > 1) { + if (wRatio > hRatio) { + op.inSampleSize = wRatio + } else { + op.inSampleSize = hRatio + } + } + op.inJustDecodeBounds = false + return BitmapFactory.decodeFile(path, op) + } + + /** 从path中获取Bitmap图片 + * @param path 图片路径 + * @return + */ + + fun decodeBitmap(path: String): Bitmap { + val opts = BitmapFactory.Options() + + opts.inJustDecodeBounds = true + BitmapFactory.decodeFile(path, opts) + + opts.inSampleSize = computeSampleSize(opts, -1, 128 * 128) + + opts.inJustDecodeBounds = false + + return BitmapFactory.decodeFile(path, opts) + } + + /** + * 以最省内存的方式读取本地资源的图片 + * @param context 设备上下文 + * @param resId 资源ID + * @return + */ + fun decodeBitmap(context: Context, resId: Int): Bitmap? { + val opt = BitmapFactory.Options() + opt.inPreferredConfig = Config.RGB_565 + //获取资源图片 + val `is` = context.resources.openRawResource(resId) + return BitmapFactory.decodeStream(`is`, null, opt) + } + + /** + * @param context 设备上下文 + * @param resId 资源ID + * @param width + * @param height + * @return + */ + fun decodeBitmap(context: Context, resId: Int, width: Int, height: Int): Bitmap? { + + var inputStream = context.resources.openRawResource(resId) + + val op = BitmapFactory.Options() + // inJustDecodeBounds如果设置为true,仅仅返回图片实际的宽和高,宽和高是赋值给opts.outWidth,opts.outHeight; + op.inJustDecodeBounds = true + BitmapFactory.decodeStream(inputStream, null, op) //获取尺寸信息 + //获取比例大小 + val wRatio = ceil((op.outWidth / width).toDouble()).toInt() + val hRatio = ceil((op.outHeight / height).toDouble()).toInt() + //如果超出指定大小,则缩小相应的比例 + if (wRatio > 1 && hRatio > 1) { + if (wRatio > hRatio) { + op.inSampleSize = wRatio + } else { + op.inSampleSize = hRatio + } + } + inputStream = context.resources.openRawResource(resId) + op.inJustDecodeBounds = false + return BitmapFactory.decodeStream(inputStream, null, op) + } + + /** + * @param context 设备上下文 + * @param fileNameInAssets Assets里面文件的名称 + * @param width 图片的宽度 + * @param height 图片的高度 + * @return Bitmap + * @throws IOException + */ + @Throws(IOException::class) + fun decodeBitmap(context: Context, fileNameInAssets: String, width: Int, height: Int): Bitmap? { + + var inputStream = context.assets.open(fileNameInAssets) + val op = BitmapFactory.Options() + // inJustDecodeBounds如果设置为true,仅仅返回图片实际的宽和高,宽和高是赋值给opts.outWidth,opts.outHeight; + op.inJustDecodeBounds = true + BitmapFactory.decodeStream(inputStream, null, op) //获取尺寸信息 + //获取比例大小 + val wRatio = ceil((op.outWidth / width).toDouble()).toInt() + val hRatio = ceil((op.outHeight / height).toDouble()).toInt() + //如果超出指定大小,则缩小相应的比例 + if (wRatio > 1 && hRatio > 1) { + if (wRatio > hRatio) { + op.inSampleSize = wRatio + } else { + op.inSampleSize = hRatio + } + } + inputStream = context.assets.open(fileNameInAssets) + op.inJustDecodeBounds = false + return BitmapFactory.decodeStream(inputStream, null, op) + } + + + //图片不被压缩 + fun convertViewToBitmap(view: View, bitmapWidth: Int, bitmapHeight: Int): Bitmap { + val bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888) + view.draw(Canvas(bitmap)) + return bitmap + } + + + /** + * @param options + * @param minSideLength + * @param maxNumOfPixels + * @return + * 设置恰当的inSampleSize是解决该问题的关键之一。BitmapFactory.Options提供了另一个成员inJustDecodeBounds。 + * 设置inJustDecodeBounds为true后,decodeFile并不分配空间,但可计算出原始图片的长度和宽度,即opts.width和opts.height。 + * 有了这两个参数,再通过一定的算法,即可得到一个恰当的inSampleSize。 + * 查看Android源码,Android提供了下面这种动态计算的方法。 + */ + fun computeSampleSize( + options: BitmapFactory.Options, + minSideLength: Int, + maxNumOfPixels: Int + ): Int { + + val initialSize = computeInitialSampleSize(options, minSideLength, maxNumOfPixels) + + var roundedSize: Int + + if (initialSize <= 8) { + roundedSize = 1 + while (roundedSize < initialSize) { + roundedSize = roundedSize shl 1 + } + } else { + roundedSize = (initialSize + 7) / 8 * 8 + } + + return roundedSize + } + + + private fun computeInitialSampleSize( + options: BitmapFactory.Options, + minSideLength: Int, + maxNumOfPixels: Int + ): Int { + + val w = options.outWidth.toDouble() + val h = options.outHeight.toDouble() + + val lowerBound = if (maxNumOfPixels == -1) + 1 + else + ceil(sqrt(w * h / maxNumOfPixels)).toInt() + + val upperBound = if (minSideLength == -1) 128 else min( + floor(w / minSideLength), + floor(h / minSideLength) + ).toInt() + + if (upperBound < lowerBound) { + // return the larger one when there is no overlapping zone. + return lowerBound + } + + return if (maxNumOfPixels == -1 && minSideLength == -1) { + 1 + } else if (minSideLength == -1) { + lowerBound + } else { + upperBound + } + } + + /** + * 高斯模糊 + */ + fun stackBlur(srcBitmap: Bitmap?): Bitmap? { + if (srcBitmap == null) return null + val rs = RenderScript.create(App.INSTANCE) + val blurredBitmap = srcBitmap.copy(Config.ARGB_8888, true) + + //分配用于渲染脚本的内存 + val input = Allocation.createFromBitmap( + rs, + blurredBitmap, + Allocation.MipmapControl.MIPMAP_FULL, + Allocation.USAGE_SHARED + ) + val output = Allocation.createTyped(rs, input.type) + + //加载我们想要使用的特定脚本的实例。 + val script = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)) + script.setInput(input) + + //设置模糊半径 + script.setRadius(8f) + + //启动 ScriptIntrinsicBlur + script.forEach(output) + + //将输出复制到模糊的位图 + output.copyTo(blurredBitmap) + + return blurredBitmap + } + +} diff --git a/app/src/main/java/io/legado/app/utils/ColorUtils.kt b/app/src/main/java/io/legado/app/utils/ColorUtils.kt new file mode 100644 index 000000000..b592bb6c9 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/ColorUtils.kt @@ -0,0 +1,81 @@ +package io.legado.app.utils + +import android.graphics.Color + +import androidx.annotation.ColorInt +import androidx.annotation.FloatRange + +object ColorUtils { + + fun intToString(intColor: Int): String { + return String.format("#%06X", 0xFFFFFF and intColor) + } + + + fun stripAlpha(@ColorInt color: Int): Int { + return -0x1000000 or color + } + + @ColorInt + fun shiftColor(@ColorInt color: Int, @FloatRange(from = 0.0, to = 2.0) by: Float): Int { + if (by == 1f) return color + val alpha = Color.alpha(color) + val hsv = FloatArray(3) + Color.colorToHSV(color, hsv) + hsv[2] *= by // value component + return (alpha shl 24) + (0x00ffffff and Color.HSVToColor(hsv)) + } + + @ColorInt + fun darkenColor(@ColorInt color: Int): Int { + return shiftColor(color, 0.9f) + } + + @ColorInt + fun lightenColor(@ColorInt color: Int): Int { + return shiftColor(color, 1.1f) + } + + fun isColorLight(@ColorInt color: Int): Boolean { + val darkness = + 1 - (0.299 * Color.red(color) + 0.587 * Color.green(color) + 0.114 * Color.blue(color)) / 255 + return darkness < 0.4 + } + + @ColorInt + fun invertColor(@ColorInt color: Int): Int { + val r = 255 - Color.red(color) + val g = 255 - Color.green(color) + val b = 255 - Color.blue(color) + return Color.argb(Color.alpha(color), r, g, b) + } + + @ColorInt + fun adjustAlpha(@ColorInt color: Int, @FloatRange(from = 0.0, to = 1.0) factor: Float): Int { + val alpha = Math.round(Color.alpha(color) * factor) + val red = Color.red(color) + val green = Color.green(color) + val blue = Color.blue(color) + return Color.argb(alpha, red, green, blue) + } + + @ColorInt + fun withAlpha(@ColorInt baseColor: Int, @FloatRange(from = 0.0, to = 1.0) alpha: Float): Int { + val a = Math.min(255, Math.max(0, (alpha * 255).toInt())) shl 24 + val rgb = 0x00ffffff and baseColor + return a + rgb + } + + /** + * Taken from CollapsingToolbarLayout's CollapsingTextHelper class. + */ + fun blendColors(color1: Int, color2: Int, @FloatRange(from = 0.0, to = 1.0) ratio: Float): Int { + val inverseRatio = 1f - ratio + val a = Color.alpha(color1) * inverseRatio + Color.alpha(color2) * ratio + val r = Color.red(color1) * inverseRatio + Color.red(color2) * ratio + val g = Color.green(color1) * inverseRatio + Color.green(color2) * ratio + val b = Color.blue(color1) * inverseRatio + Color.blue(color2) * ratio + return Color.argb(a.toInt(), r.toInt(), g.toInt(), b.toInt()) + } + +} diff --git a/app/src/main/java/io/legado/app/utils/ContextExtensions.kt b/app/src/main/java/io/legado/app/utils/ContextExtensions.kt index a94267fda..c51be0b80 100644 --- a/app/src/main/java/io/legado/app/utils/ContextExtensions.kt +++ b/app/src/main/java/io/legado/app/utils/ContextExtensions.kt @@ -1,38 +1,43 @@ package io.legado.app.utils import android.content.Context +import android.content.res.ColorStateList +import android.graphics.drawable.Drawable +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat import androidx.core.content.edit import org.jetbrains.anko.connectivityManager import org.jetbrains.anko.defaultSharedPreferences fun Context.isOnline() = connectivityManager.activeNetworkInfo?.isConnected == true -fun Context.getPrefBoolean(key: String, defValue: Boolean) = +fun Context.getPrefBoolean(key: String, defValue: Boolean = false) = defaultSharedPreferences.getBoolean(key, defValue) -fun Context.putPrefBoolean(key: String, value: Boolean) = +fun Context.putPrefBoolean(key: String, value: Boolean = false) = defaultSharedPreferences.edit { putBoolean(key, value) } -fun Context.getPrefInt(key: String, defValue: Int) = +fun Context.getPrefInt(key: String, defValue: Int = 0) = defaultSharedPreferences.getInt(key, defValue) fun Context.putPrefInt(key: String, value: Int) = defaultSharedPreferences.edit { putInt(key, value) } -fun Context.getPrefLong(key: String, defValue: Long) = +fun Context.getPrefLong(key: String, defValue: Long = 0L) = defaultSharedPreferences.getLong(key, defValue) fun Context.putPrefLong(key: String, value: Long) = defaultSharedPreferences.edit { putLong(key, value) } -fun Context.getPrefString(key: String, defValue: String) = +fun Context.getPrefString(key: String, defValue: String? = null) = defaultSharedPreferences.getString(key, defValue) fun Context.putPrefString(key: String, value: String) = defaultSharedPreferences.edit { putString(key, value) } -fun Context.getPrefStringSet(key: String, defValue: MutableSet) = +fun Context.getPrefStringSet(key: String, defValue: MutableSet? = null) = defaultSharedPreferences.getStringSet(key, defValue) fun Context.putPrefStringSet(key: String, value: MutableSet) = @@ -40,3 +45,26 @@ fun Context.putPrefStringSet(key: String, value: MutableSet) = fun Context.removePref(key: String) = defaultSharedPreferences.edit { remove(key) } + + +fun Context.getCompatColor(@ColorRes id: Int): Int = ContextCompat.getColor(this, id) + +fun Context.getCompatDrawable(@DrawableRes id: Int): Drawable? = ContextCompat.getDrawable(this, id) + +fun Context.getCompatColorStateList(@ColorRes id: Int): ColorStateList? = ContextCompat.getColorStateList(this, id) + +fun Context.getStatusBarHeight(): Int { + val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android") + return resources.getDimensionPixelSize(resourceId) +} + +fun Context.getNavigationBarHeight(): Int { + val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android") + return resources.getDimensionPixelSize(resourceId) +} + +val Context.isNightTheme: Boolean + get() = getPrefBoolean("isNightTheme") + +val Context.isTransparentStatusBar: Boolean + get() = getPrefBoolean("transparentStatusBar") \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/EncoderUtils.kt b/app/src/main/java/io/legado/app/utils/EncoderUtils.kt new file mode 100644 index 000000000..8e5c03522 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/EncoderUtils.kt @@ -0,0 +1,39 @@ +package io.legado.app.utils + +import android.util.Base64 +import java.nio.charset.StandardCharsets + +object EncoderUtils { + + fun escape(src: String): String { + val tmp = StringBuilder() + for (char in src) { + val charCode = char.toInt() + if (charCode in 48..57 || charCode in 65..90 || charCode in 97..122) { + tmp.append(char) + continue + } + + val prefix = when { + charCode < 16 -> "%0" + charCode < 256 -> "%" + else -> "%u" + } + tmp.append(prefix).append(charCode.toString(16)) + } + return tmp.toString() + } + + fun base64Decode(str: String): String { + val bytes = Base64.decode(str, Base64.DEFAULT) + return try { + String(bytes, StandardCharsets.UTF_8) + } catch (e: Exception) { + String(bytes) + } + } + + fun base64Encode(str: String): String? { + return Base64.encodeToString(str.toByteArray(), Base64.DEFAULT) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/EventBusKt.kt b/app/src/main/java/io/legado/app/utils/EventBusKt.kt new file mode 100644 index 000000000..82c3f0f30 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/EventBusKt.kt @@ -0,0 +1,64 @@ +package io.legado.app.utils + +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import com.jeremyliao.liveeventbus.LiveEventBus + +inline fun eventObservable(tag: String): LiveEventBus.Observable { + return LiveEventBus.get().with(tag, EVENT::class.java) +} + +inline fun postEvent(tag: String, event: EVENT) { + LiveEventBus.get().with(tag, EVENT::class.java).post(event) +} + +inline fun AppCompatActivity.observeEvent( + vararg tags: String, + noinline observer: (EVENT) -> Unit +) { + val o = Observer { + observer(it) + } + tags.forEach { + eventObservable(it).observe(this, o) + } +} + + +inline fun AppCompatActivity.observeEventSticky( + vararg tags: String, + noinline observer: (EVENT) -> Unit +) { + val o = Observer { + observer(it) + } + tags.forEach { + eventObservable(it).observeSticky(this, o) + } +} + +inline fun Fragment.observeEvent( + vararg tags: String, + noinline observer: (EVENT) -> Unit +) { + val o = Observer { + observer(it) + } + tags.forEach { + eventObservable(it).observe(this, o) + } +} + +inline fun Fragment.observeEventSticky( + vararg tags: String, + noinline observer: (EVENT) -> Unit +) { + val o = Observer { + observer(it) + } + tags.forEach { + eventObservable(it).observeSticky(this, o) + } +} + diff --git a/app/src/main/java/io/legado/app/utils/FileUtils.kt b/app/src/main/java/io/legado/app/utils/FileUtils.kt index 493347429..4626fe489 100644 --- a/app/src/main/java/io/legado/app/utils/FileUtils.kt +++ b/app/src/main/java/io/legado/app/utils/FileUtils.kt @@ -1,5 +1,217 @@ package io.legado.app.utils +import android.content.ContentUris +import android.content.Context +import android.net.Uri import android.os.Environment +import android.os.storage.StorageManager +import android.provider.DocumentsContract +import android.provider.MediaStore +import android.util.Log +import androidx.core.content.ContextCompat +import java.io.File +import java.io.IOException +import java.lang.reflect.Array +import java.util.* -fun getSdPath() = Environment.getExternalStorageDirectory().absolutePath + +object FileUtils { + + fun getFileByPath(filePath: String): File? { + return if (filePath.isBlank()) null else File(filePath) + } + + fun getSdCardPath(): String { + var sdCardDirectory = Environment.getExternalStorageDirectory().absolutePath + + try { + sdCardDirectory = File(sdCardDirectory).canonicalPath + } catch (ioe: IOException) { + ioe.printStackTrace() + } + + return sdCardDirectory + } + + fun getStorageData(pContext: Context): ArrayList? { + + val storageManager = pContext.getSystemService(Context.STORAGE_SERVICE) as StorageManager + + try { + val getVolumeList = storageManager.javaClass.getMethod("getVolumeList") + + val storageVolumeClazz = Class.forName("android.os.storage.StorageVolume") + val getPath = storageVolumeClazz.getMethod("getPath") + + val invokeVolumeList = getVolumeList.invoke(storageManager) + val length = Array.getLength(invokeVolumeList) + + val list = ArrayList() + for (i in 0 until length) { + val storageValume = Array.get(invokeVolumeList, i)//得到StorageVolume对象 + val path = getPath.invoke(storageValume) as String + + list.add(path) + } + return list + } catch (e: Exception) { + e.printStackTrace() + } + + return null + } + + + fun getExtSdCardPaths(con: Context): ArrayList { + val paths = ArrayList() + val files = ContextCompat.getExternalFilesDirs(con, "external") + val firstFile = files[0] + for (file in files) { + if (file != null && file != firstFile) { + val index = file.absolutePath.lastIndexOf("/Android/data") + if (index < 0) { + Log.w("", "Unexpected external file dir: " + file.absolutePath) + } else { + var path = file.absolutePath.substring(0, index) + try { + path = File(path).canonicalPath + } catch (e: IOException) { + // Keep non-canonical path. + } + + paths.add(path) + } + } + } + return paths + } + + fun getPath(context: Context, uri: Uri): String? { + // DocumentProvider + if (DocumentsContract.isDocumentUri(context, uri)) { + // ExternalStorageProvider + if (isExternalStorageDocument(uri)) { + val docId = DocumentsContract.getDocumentId(uri) + val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val type = split[0] + + if ("primary".equals(type, ignoreCase = true)) { + return Environment.getExternalStorageDirectory().toString() + "/" + split[1] + } + + } else if (isDownloadsDocument(uri)) { + val id = DocumentsContract.getDocumentId(uri) + val split = id.split(":").dropLastWhile { it.isEmpty() }.toTypedArray() + val type = split[0] + if ("raw".equals( + type, + ignoreCase = true + ) + ) { //处理某些机型(比如Goole Pixel )ID是raw:/storage/emulated/0/Download/c20f8664da05ab6b4644913048ea8c83.mp4 + return split[1] + } + + val contentUri = ContentUris.withAppendedId( + Uri.parse("content://downloads/public_downloads"), java.lang.Long.valueOf(id) + ) + + return getDataColumn(context, contentUri, null, null) + } else if (isMediaDocument(uri)) { + val docId = DocumentsContract.getDocumentId(uri) + val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val type = split[0] + + var contentUri: Uri? = null + if ("image" == type) { + contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } else if ("video" == type) { + contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI + } else if ("audio" == type) { + contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + } + + val selection = "_id=?" + val selectionArgs = arrayOf(split[1]) + + return getDataColumn(context, contentUri, selection, selectionArgs) + }// MediaProvider + // DownloadsProvider + } else if ("content".equals(uri.scheme!!, ignoreCase = true)) { + + // Return the remote address + return if (isGooglePhotosUri(uri)) uri.lastPathSegment else getDataColumn( + context, + uri, + null, + null + ) + + } else if ("file".equals(uri.scheme!!, ignoreCase = true)) { + return uri.path + }// File + // MediaStore (and general) + + return null + } + + fun getDataColumn( + context: Context, uri: Uri?, selection: String?, + selectionArgs: kotlin.Array? + ): String? { + + val column = "_data" + val projection = arrayOf(column) + + try { + context.contentResolver.query( + uri!!, + projection, + selection, + selectionArgs, + null + )?.use { cursor -> + if (cursor.moveToFirst()) { + val index = cursor.getColumnIndexOrThrow(column) + return cursor.getString(index) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + + return null + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is ExternalStorageProvider. + */ + fun isExternalStorageDocument(uri: Uri): Boolean { + return "com.android.externalstorage.documents" == uri.authority + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is DownloadsProvider. + */ + fun isDownloadsDocument(uri: Uri): Boolean { + return "com.android.providers.downloads.documents" == uri.authority + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is MediaProvider. + */ + fun isMediaDocument(uri: Uri): Boolean { + return "com.android.providers.media.documents" == uri.authority + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is Google Photos. + */ + fun isGooglePhotosUri(uri: Uri): Boolean { + return "com.google.android.apps.photos.content" == uri.authority + } + +} diff --git a/app/src/main/java/io/legado/app/utils/FloatExtensions.kt b/app/src/main/java/io/legado/app/utils/FloatExtensions.kt new file mode 100644 index 000000000..af9576c00 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/FloatExtensions.kt @@ -0,0 +1,16 @@ +package io.legado.app.utils + +import android.content.res.Resources + + +val Float.dp: Float // [xxhdpi](360 -> 1080) + get() = android.util.TypedValue.applyDimension( + android.util.TypedValue.COMPLEX_UNIT_DIP, this, Resources.getSystem().displayMetrics + ) + + +val Float.sp: Float // [xxhdpi](360 -> 1080) + get() = android.util.TypedValue.applyDimension( + android.util.TypedValue.COMPLEX_UNIT_SP, this, Resources.getSystem().displayMetrics + ) + diff --git a/app/src/main/java/io/legado/app/utils/FragmentExtensions.kt b/app/src/main/java/io/legado/app/utils/FragmentExtensions.kt new file mode 100644 index 000000000..f233929fb --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/FragmentExtensions.kt @@ -0,0 +1,78 @@ +package io.legado.app.utils + +import android.app.Activity +import android.app.Service +import android.content.Intent +import android.content.res.ColorStateList +import android.graphics.drawable.Drawable +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.core.content.edit +import androidx.fragment.app.Fragment +import org.jetbrains.anko.connectivityManager +import org.jetbrains.anko.defaultSharedPreferences +import org.jetbrains.anko.internals.AnkoInternals + +fun Fragment.isOnline() = requireContext().connectivityManager.activeNetworkInfo?.isConnected == true + +fun Fragment.getPrefBoolean(key: String, defValue: Boolean = false) = + requireContext().defaultSharedPreferences.getBoolean(key, defValue) + +fun Fragment.putPrefBoolean(key: String, value: Boolean = false) = + requireContext().defaultSharedPreferences.edit { putBoolean(key, value) } + +fun Fragment.getPrefInt(key: String, defValue: Int = 0) = + requireContext().defaultSharedPreferences.getInt(key, defValue) + +fun Fragment.putPrefInt(key: String, value: Int) = + requireContext().defaultSharedPreferences.edit { putInt(key, value) } + +fun Fragment.getPrefLong(key: String, defValue: Long = 0L) = + requireContext().defaultSharedPreferences.getLong(key, defValue) + +fun Fragment.putPrefLong(key: String, value: Long) = + requireContext().defaultSharedPreferences.edit { putLong(key, value) } + +fun Fragment.getPrefString(key: String, defValue: String? = null) = + requireContext().defaultSharedPreferences.getString(key, defValue) + +fun Fragment.putPrefString(key: String, value: String) = + requireContext().defaultSharedPreferences.edit { putString(key, value) } + +fun Fragment.getPrefStringSet(key: String, defValue: MutableSet? = null) = + requireContext().defaultSharedPreferences.getStringSet(key, defValue) + +fun Fragment.putPrefStringSet(key: String, value: MutableSet) = + requireContext().defaultSharedPreferences.edit { putStringSet(key, value) } + +fun Fragment.removePref(key: String) = + requireContext().defaultSharedPreferences.edit { remove(key) } + +fun Fragment.getCompatColor(@ColorRes id: Int): Int = requireContext().getCompatColor(id) + +fun Fragment.getCompatDrawable(@DrawableRes id: Int): Drawable? = requireContext().getCompatDrawable(id) + +fun Fragment.getCompatColorStateList(@ColorRes id: Int): ColorStateList? = requireContext().getCompatColorStateList(id) + +val Fragment.isNightTheme: Boolean + get() = getPrefBoolean("isNightTheme") + +val Fragment.isTransparentStatusBar: Boolean + get() = getPrefBoolean("transparentStatusBar") + + +inline fun Fragment.startActivity(vararg params: Pair) = + AnkoInternals.internalStartActivity(requireActivity(), T::class.java, params) + + +inline fun Fragment.startActivityForResult(requestCode: Int, vararg params: Pair) = + startActivityForResult(AnkoInternals.createIntent(requireActivity(), T::class.java, params), requestCode) + +inline fun Fragment.startService(vararg params: Pair) = + AnkoInternals.internalStartService(requireActivity(), T::class.java, params) + +inline fun Fragment.stopService(vararg params: Pair) = + AnkoInternals.internalStopService(requireActivity(), T::class.java, params) + +inline fun Fragment.intentFor(vararg params: Pair): Intent = + AnkoInternals.createIntent(requireActivity(), T::class.java, params) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/GsonExtensions.kt b/app/src/main/java/io/legado/app/utils/GsonExtensions.kt new file mode 100644 index 000000000..d81ef7ac6 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/GsonExtensions.kt @@ -0,0 +1,39 @@ +package io.legado.app.utils + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken +import org.jetbrains.anko.attempt +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type + +val GSON: Gson by lazy { + GsonBuilder() + .disableHtmlEscaping() + .setPrettyPrinting() + .create() +} + +inline fun genericType() = object : TypeToken() {}.type + +inline fun Gson.fromJsonObject(json: String?): T? {//可转成任意类型 + return attempt { + val result: T? = fromJson(json, genericType()) + result + }.value +} + +inline fun Gson.fromJsonArray(json: String?): List? { + return attempt { + val result: List? = fromJson(json, ParameterizedTypeImpl(T::class.java)) + result + }.value +} + +class ParameterizedTypeImpl(private val clazz: Class<*>) : ParameterizedType { + override fun getRawType(): Type = List::class.java + + override fun getOwnerType(): Type? = null + + override fun getActualTypeArguments(): Array = arrayOf(clazz) +} diff --git a/app/src/main/java/io/legado/app/utils/IntExtensions.kt b/app/src/main/java/io/legado/app/utils/IntExtensions.kt new file mode 100644 index 000000000..2354a6a78 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/IntExtensions.kt @@ -0,0 +1,16 @@ +package io.legado.app.utils + +import android.content.res.Resources + +val Int.dp: Int + get() = android.util.TypedValue.applyDimension( + android.util.TypedValue.COMPLEX_UNIT_DIP, this.toFloat(), Resources.getSystem().displayMetrics + ).toInt() + +val Int.sp: Int + get() = android.util.TypedValue.applyDimension( + android.util.TypedValue.COMPLEX_UNIT_SP, this.toFloat(), Resources.getSystem().displayMetrics + ).toInt() + +val Int.hexString: String + get() = Integer.toHexString(this) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/LogUtils.kt b/app/src/main/java/io/legado/app/utils/LogUtils.kt new file mode 100644 index 000000000..1662974a7 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/LogUtils.kt @@ -0,0 +1,71 @@ +package io.legado.app.utils + +import android.annotation.SuppressLint +import io.legado.app.App +import io.legado.app.help.FileHelp +import java.io.File +import java.text.SimpleDateFormat +import java.util.* +import java.util.logging.* +import java.util.logging.Formatter + + +object LogUtils { + const val TIME_PATTERN = "yyyy-MM-dd HH:mm:ss" + + @JvmStatic + fun d(tag: String, msg: String) { + logger.log(Level.INFO, "$tag $msg") + } + + @JvmStatic + fun e(tag: String, msg: String) { + logger.log(Level.WARNING, "$tag $msg") + } + + private val logger: Logger by lazy { + Logger.getGlobal().apply { + addHandler(fileHandler) + } + } + + private val fileHandler by lazy { + val logFolder = FileHelp.getCachePath() + File.separator + "logs" + FileHelp.getFolder(logFolder) + FileHandler( + logFolder + File.separator + "app.log", + 10240, + 10 + ).apply { + formatter = object : Formatter() { + override fun format(record: LogRecord): String { + // 设置文件输出格式 + return (getCurrentDateStr(TIME_PATTERN) + ": " + record.message + "\n") + } + } + level = if (App.INSTANCE.getPrefBoolean("recordLog")) { + Level.INFO + } else { + Level.OFF + } + } + } + + fun upLevel() { + fileHandler.level = if (App.INSTANCE.getPrefBoolean("recordLog")) { + Level.INFO + } else { + Level.OFF + } + } + + /** + * 获取当前时间 + */ + @SuppressLint("SimpleDateFormat") + fun getCurrentDateStr(pattern: String): String { + val date = Date() + val sdf = SimpleDateFormat(pattern) + return sdf.format(date) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/MD5Utils.java b/app/src/main/java/io/legado/app/utils/MD5Utils.java new file mode 100644 index 000000000..d4f0d8a12 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/MD5Utils.java @@ -0,0 +1,40 @@ +package io.legado.app.utils; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * 将字符串转化为MD5 + */ + +public class MD5Utils { + + public static String strToMd5By32(String str) { + if (str == null) return null; + String reStr = null; + try { + MessageDigest md5 = MessageDigest.getInstance("MD5"); + byte[] bytes = md5.digest(str.getBytes()); + StringBuilder stringBuffer = new StringBuilder(); + for (byte b : bytes) { + int bt = b & 0xff; + if (bt < 16) { + stringBuffer.append(0); + } + stringBuffer.append(Integer.toHexString(bt)); + } + reStr = stringBuffer.toString(); + } catch (NoSuchAlgorithmException e) { + e.printStackTrace(); + } + return reStr; + } + + public static String strToMd5By16(String str) { + String reStr = strToMd5By32(str); + if (reStr != null) { + reStr = reStr.substring(8, 24); + } + return reStr; + } +} diff --git a/app/src/main/java/io/legado/app/utils/MenuExtensions.kt b/app/src/main/java/io/legado/app/utils/MenuExtensions.kt new file mode 100644 index 000000000..d4b55caaa --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/MenuExtensions.kt @@ -0,0 +1,30 @@ +package io.legado.app.utils + +import android.content.Context +import android.view.Menu +import androidx.appcompat.view.menu.MenuBuilder +import androidx.appcompat.view.menu.MenuItemImpl +import androidx.core.view.forEach +import io.legado.app.R +import io.legado.app.lib.theme.DrawableUtils +import io.legado.app.lib.theme.primaryTextColor + +fun Menu.applyTint(context: Context, inPrimary: Boolean = true): Menu = this.let { menu -> + if (menu is MenuBuilder) { + menu.setOptionalIconsVisible(true) + } + val primaryTextColor = context.primaryTextColor + val defaultTextColor = context.getCompatColor(R.color.tv_text_default) + menu.forEach { item -> + (item as MenuItemImpl).let { impl -> + //overflow:展开的item + DrawableUtils.setTint( + impl.icon, + if (!inPrimary) defaultTextColor + else if (impl.requiresOverflow()) defaultTextColor + else primaryTextColor + ) + } + } + return menu +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/MiscExtensions.kt b/app/src/main/java/io/legado/app/utils/MiscExtensions.kt index 24bf95c9a..84665cd8c 100644 --- a/app/src/main/java/io/legado/app/utils/MiscExtensions.kt +++ b/app/src/main/java/io/legado/app/utils/MiscExtensions.kt @@ -2,8 +2,11 @@ package io.legado.app.utils import com.jayway.jsonpath.ReadContext -fun ReadContext.readString(path: String) = this.read(path, String::class.java) +fun ReadContext.readString(path: String): String? = this.read(path, String::class.java) -fun ReadContext.readBool(path: String) = this.read(path, Boolean::class.java) +fun ReadContext.readBool(path: String): Boolean? = this.read(path, Boolean::class.java) + +fun ReadContext.readInt(path: String): Int? = this.read(path, Int::class.java) + +fun ReadContext.readLong(path: String): Long? = this.read(path, Long::class.java) -fun ReadContext.readInt(path: String) = this.read(path, Int::class.java) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/NetworkUtils.kt b/app/src/main/java/io/legado/app/utils/NetworkUtils.kt new file mode 100644 index 000000000..3f42784f9 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/NetworkUtils.kt @@ -0,0 +1,96 @@ +package io.legado.app.utils + +import retrofit2.Response +import java.net.URL +import java.util.* + +object NetworkUtils { + fun getUrl(response: Response<*>): String { + val networkResponse = response.raw().networkResponse + return networkResponse?.request?.url?.toString() + ?: response.raw().request.url.toString() + } + + private val notNeedEncoding: BitSet by lazy { + val bitSet = BitSet(256) + for (i in 'a'.toInt()..'z'.toInt()) { + bitSet.set(i) + } + for (i in 'A'.toInt()..'Z'.toInt()) { + bitSet.set(i) + } + for (i in '0'.toInt()..'9'.toInt()) { + bitSet.set(i) + } + for (char in "+-_.$:()!*@&#,[]") { + bitSet.set(char.toInt()) + } + return@lazy bitSet + } + + /** + * 支持JAVA的URLEncoder.encode出来的string做判断。 即: 将' '转成'+' + * 0-9a-zA-Z保留

+ * ! * ' ( ) ; : @ & = + $ , / ? # [ ] 保留 + * 其他字符转成%XX的格式,X是16进制的大写字符,范围是[0-9A-F] + */ + fun hasUrlEncoded(str: String): Boolean { + var needEncode = false + var i = 0 + while (i < str.length) { + val c = str[i] + if (notNeedEncoding.get(c.toInt())) { + i++ + continue + } + if (c == '%' && i + 2 < str.length) { + // 判断是否符合urlEncode规范 + val c1 = str[++i] + val c2 = str[++i] + if (isDigit16Char(c1) && isDigit16Char(c2)) { + i++ + continue + } + } + // 其他字符,肯定需要urlEncode + needEncode = true + break + } + + return !needEncode + } + + /** + * 判断c是否是16进制的字符 + */ + private fun isDigit16Char(c: Char): Boolean { + return c in '0'..'9' || c in 'A'..'F' || c in 'a'..'f' + } + + /** + * 获取绝对地址 + */ + fun getAbsoluteURL(baseURL: String?, relativePath: String?): String? { + if (baseURL.isNullOrEmpty()) return relativePath + if (relativePath.isNullOrEmpty()) return baseURL + var relativeUrl = relativePath + try { + val absoluteUrl = URL(baseURL.substringBefore(",")) + val parseUrl = URL(absoluteUrl, relativePath) + relativeUrl = parseUrl.toString() + return relativeUrl + } catch (e: Exception) { + e.printStackTrace() + } + return relativeUrl + } + + fun getBaseUrl(url: String?): String? { + if (url == null || !url.startsWith("http")) return null + val index = url.indexOf("/", 9) + return if (index == -1) { + url + } else url.substring(0, index) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/README.md b/app/src/main/java/io/legado/app/utils/README.md deleted file mode 100644 index a31b943b5..000000000 --- a/app/src/main/java/io/legado/app/utils/README.md +++ /dev/null @@ -1 +0,0 @@ -## 放置一些工具类 \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/Snackbars.kt b/app/src/main/java/io/legado/app/utils/Snackbars.kt new file mode 100644 index 000000000..f60def01f --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/Snackbars.kt @@ -0,0 +1,257 @@ +package io.legado.app.utils + +import android.view.View +import androidx.annotation.StringRes +import com.google.android.material.snackbar.Snackbar + +/** + * Display the Snackbar with the [Snackbar.LENGTH_SHORT] duration. + * + * @param message the message text resource. + */ +@Deprecated("Use 'View.snackbar(Int)' instead.", ReplaceWith("view.snackbar(message)")) +inline fun snackbar(view: View, message: Int) = Snackbar + .make(view, message, Snackbar.LENGTH_SHORT) + .apply { show() } + +/** + * Display Snackbar with the [Snackbar.LENGTH_LONG] duration. + * + * @param message the message text resource. + */ +@Deprecated("Use 'View.longSnackbar(Int)' instead.", ReplaceWith("view.longSnackbar(message)")) +inline fun longSnackbar(view: View, message: Int) = Snackbar + .make(view, message, Snackbar.LENGTH_LONG) + .apply { show() } + +/** + * Display Snackbar with the [Snackbar.LENGTH_INDEFINITE] duration. + * + * @param message the message text resource. + */ +@Deprecated("Use 'View.indefiniteSnackbar(Int)' instead.", ReplaceWith("view.indefiniteSnackbar(message)")) +inline fun indefiniteSnackbar(view: View, message: Int) = Snackbar + .make(view, message, Snackbar.LENGTH_INDEFINITE) + .apply { show() } + +/** + * Display the Snackbar with the [Snackbar.LENGTH_SHORT] duration. + * + * @param message the message text. + */ +@Deprecated("Use 'View.snackbar(CharSequence)' instead.", ReplaceWith("view.snackbar(message)")) +inline fun snackbar(view: View, message: CharSequence) = Snackbar + .make(view, message, Snackbar.LENGTH_SHORT) + .apply { show() } + +/** + * Display Snackbar with the [Snackbar.LENGTH_LONG] duration. + * + * @param message the message text. + */ +@Deprecated("Use 'View.longSnackbar(CharSequence)' instead.", ReplaceWith("view.longSnackbar(message)")) +inline fun longSnackbar(view: View, message: CharSequence) = Snackbar + .make(view, message, Snackbar.LENGTH_LONG) + .apply { show() } + +/** + * Display Snackbar with the [Snackbar.LENGTH_INDEFINITE] duration. + * + * @param message the message text. + */ +@Deprecated("Use 'View.indefiniteSnackbar(CharSequence)' instead.", ReplaceWith("view.indefiniteSnackbar(message)")) +inline fun indefiniteSnackbar(view: View, message: CharSequence) = Snackbar + .make(view, message, Snackbar.LENGTH_INDEFINITE) + .apply { show() } + +/** + * Display the Snackbar with the [Snackbar.LENGTH_SHORT] duration. + * + * @param message the message text resource. + */ +@Deprecated("Use 'View.snackbar(Int, Int, (View) -> Unit)' instead.", ReplaceWith("view.snackbar(message, actionText, action)")) +inline fun snackbar(view: View, message: Int, actionText: Int, noinline action: (View) -> Unit) = Snackbar + .make(view, message, Snackbar.LENGTH_SHORT) + .setAction(actionText, action) + .apply { show() } + +/** + * Display Snackbar with the [Snackbar.LENGTH_LONG] duration. + * + * @param message the message text resource. + */ +@Deprecated("Use 'View.longSnackbar(Int, Int, (View) -> Unit)' instead.", ReplaceWith("view.longSnackbar(message, actionText, action)")) +inline fun longSnackbar(view: View, message: Int, actionText: Int, noinline action: (View) -> Unit) = Snackbar + .make(view, message, Snackbar.LENGTH_LONG) + .setAction(actionText, action) + .apply { show() } + +/** + * Display Snackbar with the [Snackbar.LENGTH_INDEFINITE] duration. + * + * @param message the message text resource. + */ +@Deprecated("Use 'View.indefiniteSnackbar(Int, Int, (View) -> Unit)' instead.", ReplaceWith("view.indefiniteSnackbar(message, actionText, action)")) +inline fun indefiniteSnackbar(view: View, message: Int, actionText: Int, noinline action: (View) -> Unit) = Snackbar + .make(view, message, Snackbar.LENGTH_INDEFINITE) + .setAction(actionText, action) + .apply { show() } + +/** + * Display the Snackbar with the [Snackbar.LENGTH_SHORT] duration. + * + * @param message the message text. + */ +@Deprecated("Use 'View.snackbar(CharSequence, CharSequence, (View) -> Unit)' instead.", ReplaceWith("view.snackbar(message, actionText, action)")) +inline fun snackbar(view: View, message: CharSequence, actionText: CharSequence, noinline action: (View) -> Unit) = Snackbar + .make(view, message, Snackbar.LENGTH_SHORT) + .setAction(actionText, action) + .apply { show() } + +/** + * Display Snackbar with the [Snackbar.LENGTH_LONG] duration. + * + * @param message the message text. + */ +@Deprecated("Use 'View.longSnackbar(CharSequence, CharSequence, (View) -> Unit)' instead.", ReplaceWith("view.longSnackbar(message, actionText, action)")) +inline fun longSnackbar(view: View, message: CharSequence, actionText: CharSequence, noinline action: (View) -> Unit) = Snackbar + .make(view, message, Snackbar.LENGTH_LONG) + .setAction(actionText, action) + .apply { show() } + +/** + * Display Snackbar with the [Snackbar.LENGTH_INDEFINITE] duration. + * + * @param message the message text. + */ +@Deprecated("Use 'View.indefiniteSnackbar(CharSequence, CharSequence, (View) -> Unit)' instead.", ReplaceWith("view.indefiniteSnackbar(message, actionText, action)")) +inline fun indefiniteSnackbar(view: View, message: CharSequence, actionText: CharSequence, noinline action: (View) -> Unit) = Snackbar + .make(view, message, Snackbar.LENGTH_INDEFINITE) + .setAction(actionText, action) + .apply { show() } + +/** + * Display the Snackbar with the [Snackbar.LENGTH_SHORT] duration. + * + * @param message the message text resource. + */ +@JvmName("snackbar2") +inline fun View.snackbar(@StringRes message: Int) = Snackbar + .make(this, message, Snackbar.LENGTH_SHORT) + .apply { show() } + +/** + * Display Snackbar with the [Snackbar.LENGTH_LONG] duration. + * + * @param message the message text resource. + */ +@JvmName("longSnackbar2") +inline fun View.longSnackbar(@StringRes message: Int) = Snackbar + .make(this, message, Snackbar.LENGTH_LONG) + .apply { show() } + +/** + * Display Snackbar with the [Snackbar.LENGTH_INDEFINITE] duration. + * + * @param message the message text resource. + */ +@JvmName("indefiniteSnackbar2") +inline fun View.indefiniteSnackbar(@StringRes message: Int) = Snackbar + .make(this, message, Snackbar.LENGTH_INDEFINITE) + .apply { show() } + +/** + * Display the Snackbar with the [Snackbar.LENGTH_SHORT] duration. + * + * @param message the message text. + */ +@JvmName("snackbar2") +inline fun View.snackbar(message: CharSequence) = Snackbar + .make(this, message, Snackbar.LENGTH_SHORT) + .apply { show() } + +/** + * Display Snackbar with the [Snackbar.LENGTH_LONG] duration. + * + * @param message the message text. + */ +@JvmName("longSnackbar2") +inline fun View.longSnackbar(message: CharSequence) = Snackbar + .make(this, message, Snackbar.LENGTH_LONG) + .apply { show() } + +/** + * Display Snackbar with the [Snackbar.LENGTH_INDEFINITE] duration. + * + * @param message the message text. + */ +@JvmName("indefiniteSnackbar2") +inline fun View.indefiniteSnackbar(message: CharSequence) = Snackbar + .make(this, message, Snackbar.LENGTH_INDEFINITE) + .apply { show() } + +/** + * Display the Snackbar with the [Snackbar.LENGTH_SHORT] duration. + * + * @param message the message text resource. + */ +@JvmName("snackbar2") +inline fun View.snackbar(message: Int, @StringRes actionText: Int, noinline action: (View) -> Unit) = Snackbar + .make(this, message, Snackbar.LENGTH_SHORT) + .setAction(actionText, action) + .apply { show() } + +/** + * Display Snackbar with the [Snackbar.LENGTH_LONG] duration. + * + * @param message the message text resource. + */ +@JvmName("longSnackbar2") +inline fun View.longSnackbar(@StringRes message: Int, @StringRes actionText: Int, noinline action: (View) -> Unit) = Snackbar + .make(this, message, Snackbar.LENGTH_LONG) + .setAction(actionText, action) + .apply { show() } + +/** + * Display Snackbar with the [Snackbar.LENGTH_INDEFINITE] duration. + * + * @param message the message text resource. + */ +@JvmName("indefiniteSnackbar2") +inline fun View.indefiniteSnackbar(@StringRes message: Int, @StringRes actionText: Int, noinline action: (View) -> Unit) = Snackbar + .make(this, message, Snackbar.LENGTH_INDEFINITE) + .setAction(actionText, action) + .apply { show() } + +/** + * Display the Snackbar with the [Snackbar.LENGTH_SHORT] duration. + * + * @param message the message text. + */ +@JvmName("snackbar2") +inline fun View.snackbar(message: CharSequence, actionText: CharSequence, noinline action: (View) -> Unit) = Snackbar + .make(this, message, Snackbar.LENGTH_SHORT) + .setAction(actionText, action) + .apply { show() } + +/** + * Display Snackbar with the [Snackbar.LENGTH_LONG] duration. + * + * @param message the message text. + */ +@JvmName("longSnackbar2") +inline fun View.longSnackbar(message: CharSequence, actionText: CharSequence, noinline action: (View) -> Unit) = Snackbar + .make(this, message, Snackbar.LENGTH_LONG) + .setAction(actionText, action) + .apply { show() } + +/** + * Display Snackbar with the [Snackbar.LENGTH_INDEFINITE] duration. + * + * @param message the message text. + */ +@JvmName("indefiniteSnackbar2") +inline fun View.indefiniteSnackbar(message: CharSequence, actionText: CharSequence, noinline action: (View) -> Unit) = Snackbar + .make(this, message, Snackbar.LENGTH_INDEFINITE) + .setAction(actionText, action) + .apply { show() } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/StringExtensions.kt b/app/src/main/java/io/legado/app/utils/StringExtensions.kt index 23f35a357..987a0fce8 100644 --- a/app/src/main/java/io/legado/app/utils/StringExtensions.kt +++ b/app/src/main/java/io/legado/app/utils/StringExtensions.kt @@ -1,6 +1,47 @@ package io.legado.app.utils -fun String?.strim() = if (this.isNullOrBlank()) null else this.trim() +// import org.apache.commons.text.StringEscapeUtils -fun String.isAbsUrl() = this.startsWith("http://", true) +fun String?.safeTrim() = if (this.isNullOrBlank()) null else this.trim() + +fun String?.isAbsUrl() = if (this.isNullOrBlank()) false else this.startsWith("http://", true) || this.startsWith("https://", true) + +fun String?.isJson(): Boolean = this?.run { + val str = this.trim() + when { + str.startsWith("{") && str.endsWith("}") -> true + str.startsWith("[") && str.endsWith("]") -> true + else -> false + } +} ?: false + +fun String?.isJsonObject(): Boolean = this?.run { + val str = this.trim() + str.startsWith("{") && str.endsWith("}") +} ?: false + +fun String?.isJsonArray(): Boolean = this?.run { + val str = this.trim() + str.startsWith("[") && str.endsWith("]") +} ?: false + +fun String?.htmlFormat(): String = if (this.isNullOrBlank()) "" else + this.replace("(?i)<(br[\\s/]*|/*p\\b.*?|/*div\\b.*?)>".toRegex(), "\n")// 替换特定标签为换行符 + .replace("<[script>]*.*?>| ".toRegex(), "")// 删除script标签对和空格转义符 + .replace("\\s*\\n+\\s*".toRegex(), "\n  ")// 移除空行,并增加段前缩进2个汉字 + .replace("^[\\n\\s]+".toRegex(), "  ")//移除开头空行,并增加段前缩进2个汉字 + .replace("[\\n\\s]+$".toRegex(), "") //移除尾部空行 + +fun String.splitNotBlank(vararg delimiter: String): Array = run { + this.split(*delimiter).map { it.trim() }.filterNot { it.isBlank() }.toTypedArray() +} + +fun String.splitNotBlank(regex: Regex, limit: Int = 0): Array = run { + this.split(regex, limit).map { it.trim() }.filterNot { it.isBlank() }.toTypedArray() +} + +fun String.startWithIgnoreCase(start: String): Boolean { + return if (this.isBlank()) false else startsWith(start, true) +} + diff --git a/app/src/main/java/io/legado/app/utils/StringUtils.kt b/app/src/main/java/io/legado/app/utils/StringUtils.kt new file mode 100644 index 000000000..031e0f705 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/StringUtils.kt @@ -0,0 +1,255 @@ +package io.legado.app.utils + +import android.annotation.SuppressLint +import android.text.TextUtils +import android.text.TextUtils.isEmpty +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.* +import java.util.regex.Matcher +import java.util.regex.Pattern + +object StringUtils { + private val TAG = "StringUtils" + private const val HOUR_OF_DAY = 24 + private const val DAY_OF_YESTERDAY = 2 + private const val TIME_UNIT = 60 + private val ChnMap = chnMap + + private val chnMap: HashMap + get() { + val map = HashMap() + var cnStr = "零一二三四五六七八九十" + var c = cnStr.toCharArray() + for (i in 0..10) { + map[c[i]] = i + } + cnStr = "〇壹贰叁肆伍陆柒捌玖拾" + c = cnStr.toCharArray() + for (i in 0..10) { + map[c[i]] = i + } + map['两'] = 2 + map['百'] = 100 + map['佰'] = 100 + map['千'] = 1000 + map['仟'] = 1000 + map['万'] = 10000 + map['亿'] = 100000000 + return map + } + + //将时间转换成日期 + fun dateConvert(time: Long, pattern: String): String { + val date = Date(time) + @SuppressLint("SimpleDateFormat") val format = SimpleDateFormat(pattern) + return format.format(date) + } + + //将日期转换成昨天、今天、明天 + fun dateConvert(source: String, pattern: String): String { + @SuppressLint("SimpleDateFormat") val format = SimpleDateFormat(pattern) + val calendar = Calendar.getInstance() + try { + val date = format.parse(source) + val curTime = calendar.timeInMillis + calendar.time = date + //将MISC 转换成 sec + val difSec = Math.abs((curTime - date.time) / 1000) + val difMin = difSec / 60 + val difHour = difMin / 60 + val difDate = difHour / 60 + val oldHour = calendar.get(Calendar.HOUR) + //如果没有时间 + if (oldHour == 0) { + //比日期:昨天今天和明天 + if (difDate == 0L) { + return "今天" + } else if (difDate < DAY_OF_YESTERDAY) { + return "昨天" + } else { + @SuppressLint("SimpleDateFormat") val convertFormat = SimpleDateFormat("yyyy-MM-dd") + return convertFormat.format(date) + } + } + + return when { + difSec < TIME_UNIT -> difSec.toString() + "秒前" + difMin < TIME_UNIT -> difMin.toString() + "分钟前" + difHour < HOUR_OF_DAY -> difHour.toString() + "小时前" + difDate < DAY_OF_YESTERDAY -> "昨天" + else -> { + @SuppressLint("SimpleDateFormat") val convertFormat = SimpleDateFormat("yyyy-MM-dd") + convertFormat.format(date) + } + } + } catch (e: ParseException) { + e.printStackTrace() + } + + return "" + } + + @SuppressLint("DefaultLocale") + fun toFirstCapital(str: String): String { + return str.substring(0, 1).toUpperCase() + str.substring(1) + } + + /** + * 将文本中的半角字符,转换成全角字符 + */ + fun halfToFull(input: String): String { + val c = input.toCharArray() + for (i in c.indices) { + if (c[i].toInt() == 32) + //半角空格 + { + c[i] = 12288.toChar() + continue + } + //根据实际情况,过滤不需要转换的符号 + //if (c[i] == 46) //半角点号,不转换 + // continue; + + if (c[i].toInt() in 33..126) + //其他符号都转换为全角 + c[i] = (c[i].toInt() + 65248).toChar() + } + return String(c) + } + + //功能:字符串全角转换为半角 + fun fullToHalf(input: String): String { + val c = input.toCharArray() + for (i in c.indices) { + if (c[i].toInt() == 12288) + //全角空格 + { + c[i] = 32.toChar() + continue + } + + if (c[i].toInt() in 65281..65374) + c[i] = (c[i].toInt() - 65248).toChar() + } + return String(c) + } + + fun chineseNumToInt(chNum: String): Int { + var result = 0 + var tmp = 0 + var billion = 0 + val cn = chNum.toCharArray() + + // "一零二五" 形式 + if (cn.size > 1 && chNum.matches("^[〇零一二三四五六七八九壹贰叁肆伍陆柒捌玖]$".toRegex())) { + for (i in cn.indices) { + cn[i] = (48 + ChnMap[cn[i]]!!).toChar() + } + return Integer.parseInt(String(cn)) + } + + // "一千零二十五", "一千二" 形式 + try { + for (i in cn.indices) { + val tmpNum = ChnMap[cn[i]]!! + if (tmpNum == 100000000) { + result += tmp + result *= tmpNum + billion = billion * 100000000 + result + result = 0 + tmp = 0 + } else if (tmpNum == 10000) { + result += tmp + result *= tmpNum + tmp = 0 + } else if (tmpNum >= 10) { + if (tmp == 0) + tmp = 1 + result += tmpNum * tmp + tmp = 0 + } else { + tmp = if (i >= 2 && i == cn.size - 1 && ChnMap[cn[i - 1]]!! > 10) + tmpNum * ChnMap[cn[i - 1]]!! / 10 + else + tmp * 10 + tmpNum + } + } + result += tmp + billion + return result + } catch (e: Exception) { + return -1 + } + + } + + fun stringToInt(str: String?): Int { + if (str != null) { + val num = fullToHalf(str).replace("\\s".toRegex(), "") + return try { + Integer.parseInt(num) + } catch (e: Exception) { + chineseNumToInt(num) + } + + } + return -1 + } + + fun isContainNumber(company: String): Boolean { + val p = Pattern.compile("[0-9]") + val m = p.matcher(company) + return m.find() + } + + fun isNumeric(str: String): Boolean { + val pattern = Pattern.compile("[0-9]*") + val isNum = pattern.matcher(str) + return isNum.matches() + } + + // 移除字符串首尾空字符的高效方法(利用ASCII值判断,包括全角空格) + fun trim(s: String): String { + if (isEmpty(s)) return "" + var start = 0 + val len = s.length + var end = len - 1 + while (start < end && (s[start].toInt() <= 0x20 || s[start] == ' ')) { + ++start + } + while (start < end && (s[end].toInt() <= 0x20 || s[end] == ' ')) { + --end + } + if (end < len) ++end + return if (start > 0 || end < len) s.substring(start, end) else s + } + + fun repeat(str: String, n: Int): String { + val stringBuilder = StringBuilder() + for (i in 0 until n) { + stringBuilder.append(str) + } + return stringBuilder.toString() + } + + fun removeUTFCharacters(data: String?): String? { + if (data == null) return null + val p = Pattern.compile("\\\\u(\\p{XDigit}{4})") + val m = p.matcher(data) + val buf = StringBuffer(data.length) + while (m.find()) { + val ch = Integer.parseInt(m.group(1), 16).toChar().toString() + m.appendReplacement(buf, Matcher.quoteReplacement(ch)) + } + m.appendTail(buf) + return buf.toString() + } + + fun formatHtml(html: String): String { + return if (TextUtils.isEmpty(html)) "" else html.replace("(?i)<(br[\\s/]*|/*p.*?|/*div.*?)>".toRegex(), "\n")// 替换特定标签为换行符 + .replace("<[script>]*.*?>| ".toRegex(), "")// 删除script标签对和空格转义符 + .replace("\\s*\\n+\\s*".toRegex(), "\n  ")// 移除空行,并增加段前缩进2个汉字 + .replace("^[\\n\\s]+".toRegex(), "  ")//移除开头空行,并增加段前缩进2个汉字 + .replace("[\\n\\s]+$".toRegex(), "") //移除尾部空行 + } +} diff --git a/app/src/main/java/io/legado/app/utils/SystemUtils.kt b/app/src/main/java/io/legado/app/utils/SystemUtils.kt new file mode 100644 index 000000000..91121c000 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/SystemUtils.kt @@ -0,0 +1,45 @@ +package io.legado.app.utils + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.Context.POWER_SERVICE +import android.content.Intent +import android.net.Uri +import android.os.PowerManager +import android.provider.Settings + +object SystemUtils { + + fun getScreenOffTime(context: Context): Int { + var screenOffTime = 0 + try { + screenOffTime = Settings.System.getInt( + context.contentResolver, + Settings.System.SCREEN_OFF_TIMEOUT + ) + } catch (e: Exception) { + e.printStackTrace() + } + + return screenOffTime + } + + fun ignoreBatteryOptimization(activity: Activity) { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.M) return + + val powerManager = activity.getSystemService(POWER_SERVICE) as PowerManager + val hasIgnored = powerManager.isIgnoringBatteryOptimizations(activity.packageName) + // 判断当前APP是否有加入电池优化的白名单,如果没有,弹出加入电池优化的白名单的设置对话框。 + if (!hasIgnored) { + try { + @SuppressLint("BatteryLife") + val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) + intent.data = Uri.parse("package:" + activity.packageName) + activity.startActivity(intent) + } catch (ignored: Throwable) { + } + + } + } +} diff --git a/app/src/main/java/io/legado/app/utils/Toasts.kt b/app/src/main/java/io/legado/app/utils/Toasts.kt new file mode 100644 index 000000000..16c032fa0 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/Toasts.kt @@ -0,0 +1,35 @@ +package io.legado.app.utils + +import android.widget.Toast +import androidx.fragment.app.Fragment +import org.jetbrains.anko.longToast +import org.jetbrains.anko.toast + + +/** + * Display the simple Toast message with the [Toast.LENGTH_SHORT] duration. + * + * @param message the message text resource. + */ +inline fun Fragment.toast(message: Int) = requireActivity().toast(message) + +/** + * Display the simple Toast message with the [Toast.LENGTH_SHORT] duration. + * + * @param message the message text. + */ +inline fun Fragment.toast(message: CharSequence) = requireActivity().toast(message) + +/** + * Display the simple Toast message with the [Toast.LENGTH_LONG] duration. + * + * @param message the message text resource. + */ +inline fun Fragment.longToast(message: Int) = requireActivity().longToast(message) + +/** + * Display the simple Toast message with the [Toast.LENGTH_LONG] duration. + * + * @param message the message text. + */ +inline fun Fragment.longToast(message: CharSequence) = requireActivity().longToast(message) diff --git a/app/src/main/java/io/legado/app/utils/ViewExtensions.kt b/app/src/main/java/io/legado/app/utils/ViewExtensions.kt new file mode 100644 index 000000000..81cdafe81 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/ViewExtensions.kt @@ -0,0 +1,64 @@ +package io.legado.app.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.Build +import android.view.View +import android.view.View.* +import android.view.inputmethod.InputMethodManager +import android.widget.SeekBar +import androidx.appcompat.app.AppCompatActivity +import io.legado.app.App + + +private tailrec fun getCompatActivity(context: Context?): AppCompatActivity? { + return when (context) { + is AppCompatActivity -> context + is androidx.appcompat.view.ContextThemeWrapper -> getCompatActivity(context.baseContext) + is android.view.ContextThemeWrapper -> getCompatActivity(context.baseContext) + else -> null + } +} + +val View.activity: AppCompatActivity? + get() = getCompatActivity(context) + +inline fun View.hideSoftInput() = run { + val imm = App.INSTANCE.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.let { + imm.hideSoftInputFromWindow(this.windowToken, 0) + } +} + +fun View.disableAutoFill() = run { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + this.importantForAutofill = IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS + } +} + +fun View.gone() { + visibility = GONE +} + +fun View.invisible() { + visibility = INVISIBLE +} + +fun View.visible() { + visibility = VISIBLE +} + +fun View.screenshot(): Bitmap? { + return runCatching { + val screenshot = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val c = Canvas(screenshot) + c.translate(-scrollX.toFloat(), -scrollY.toFloat()) + draw(c) + screenshot + }.getOrNull() +} + +fun SeekBar.progressAdd(int: Int) { + progress += int +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/ViewModelExtensions.kt b/app/src/main/java/io/legado/app/utils/ViewModelKt.kt similarity index 82% rename from app/src/main/java/io/legado/app/utils/ViewModelExtensions.kt rename to app/src/main/java/io/legado/app/utils/ViewModelKt.kt index ed963ec8e..b777c8bbf 100644 --- a/app/src/main/java/io/legado/app/utils/ViewModelExtensions.kt +++ b/app/src/main/java/io/legado/app/utils/ViewModelKt.kt @@ -9,4 +9,8 @@ fun AppCompatActivity.getViewModel(clazz: Class) = ViewModelP fun Fragment.getViewModel(clazz: Class) = ViewModelProviders.of(this).get(clazz) -fun Fragment.getViewModelOfActivity(clazz: Class) = ViewModelProviders.of(requireActivity()).get(clazz) +/** + * 与activity数据同步 + */ +fun Fragment.getViewModelOfActivity(clazz: Class) = + ViewModelProviders.of(requireActivity()).get(clazz) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/ZipUtils.kt b/app/src/main/java/io/legado/app/utils/ZipUtils.kt new file mode 100644 index 000000000..eab9951c7 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/ZipUtils.kt @@ -0,0 +1,420 @@ +package io.legado.app.utils + +import android.util.Log +import java.io.* +import java.util.* +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream + +/** + *

+ * author: Blankj
+ * blog  : http://blankj.com
+ * time  : 2016/08/27
+ * desc  : utils about zip
+
* + */ +object ZipUtils { + + /** + * Zip the files. + * + * @param srcFiles The source of files. + * @param zipFilePath The path of ZIP file. + * @return `true`: success

`false`: fail + * @throws IOException if an I/O error has occurred + */ + @Throws(IOException::class) + fun zipFiles( + srcFiles: Collection, + zipFilePath: String + ): Boolean { + return zipFiles(srcFiles, zipFilePath, null) + } + + /** + * Zip the files. + * + * @param srcFilePaths The paths of source files. + * @param zipFilePath The path of ZIP file. + * @param comment The comment. + * @return `true`: success

`false`: fail + * @throws IOException if an I/O error has occurred + */ + @Throws(IOException::class) + fun zipFiles( + srcFilePaths: Collection?, + zipFilePath: String?, + comment: String? + ): Boolean { + if (srcFilePaths == null || zipFilePath == null) return false + var zos: ZipOutputStream? = null + try { + zos = ZipOutputStream(FileOutputStream(zipFilePath)) + for (srcFile in srcFilePaths) { + if (!zipFile(getFileByPath(srcFile)!!, "", zos, comment)) return false + } + return true + } finally { + if (zos != null) { + zos.finish() + zos.close() + } + } + } + + /** + * Zip the files. + * + * @param srcFiles The source of files. + * @param zipFile The ZIP file. + * @param comment The comment. + * @return `true`: success

`false`: fail + * @throws IOException if an I/O error has occurred + */ + @Throws(IOException::class) + @JvmOverloads + fun zipFiles( + srcFiles: Collection?, + zipFile: File?, + comment: String? = null + ): Boolean { + if (srcFiles == null || zipFile == null) return false + var zos: ZipOutputStream? = null + try { + zos = ZipOutputStream(FileOutputStream(zipFile)) + for (srcFile in srcFiles) { + if (!zipFile(srcFile, "", zos, comment)) return false + } + return true + } finally { + if (zos != null) { + zos.finish() + zos.close() + } + } + } + + /** + * Zip the file. + * + * @param srcFilePath The path of source file. + * @param zipFilePath The path of ZIP file. + * @return `true`: success

`false`: fail + * @throws IOException if an I/O error has occurred + */ + @Throws(IOException::class) + fun zipFile( + srcFilePath: String, + zipFilePath: String + ): Boolean { + return zipFile(getFileByPath(srcFilePath), getFileByPath(zipFilePath), null) + } + + /** + * Zip the file. + * + * @param srcFilePath The path of source file. + * @param zipFilePath The path of ZIP file. + * @param comment The comment. + * @return `true`: success

`false`: fail + * @throws IOException if an I/O error has occurred + */ + @Throws(IOException::class) + fun zipFile( + srcFilePath: String, + zipFilePath: String, + comment: String + ): Boolean { + return zipFile(getFileByPath(srcFilePath), getFileByPath(zipFilePath), comment) + } + + /** + * Zip the file. + * + * @param srcFile The source of file. + * @param zipFile The ZIP file. + * @param comment The comment. + * @return `true`: success

`false`: fail + * @throws IOException if an I/O error has occurred + */ + @Throws(IOException::class) + @JvmOverloads + fun zipFile( + srcFile: File?, + zipFile: File?, + comment: String? = null + ): Boolean { + if (srcFile == null || zipFile == null) return false + ZipOutputStream(FileOutputStream(zipFile)).use { zos -> + return zipFile( + srcFile, + "", + zos, + comment + ) + } + } + + @Throws(IOException::class) + private fun zipFile( + srcFile: File, + rootPath: String, + zos: ZipOutputStream, + comment: String? + ): Boolean { + var rootPath1 = rootPath + if (!srcFile.exists()) return true + rootPath1 = rootPath1 + (if (isSpace(rootPath1)) "" else File.separator) + srcFile.name + if (srcFile.isDirectory) { + val fileList = srcFile.listFiles() + if (fileList == null || fileList.isEmpty()) { + val entry = ZipEntry("$rootPath1/") + entry.comment = comment + zos.putNextEntry(entry) + zos.closeEntry() + } else { + for (file in fileList) { + if (!zipFile(file, rootPath1, zos, comment)) return false + } + } + } else { + BufferedInputStream(FileInputStream(srcFile)).use { `is` -> + val entry = ZipEntry(rootPath1) + entry.comment = comment + zos.putNextEntry(entry) + zos.write(`is`.readBytes()) + zos.closeEntry() + } + } + return true + } + + /** + * Unzip the file. + * + * @param zipFilePath The path of ZIP file. + * @param destDirPath The path of destination directory. + * @return the unzipped files + * @throws IOException if unzip unsuccessfully + */ + @Throws(IOException::class) + fun unzipFile(zipFilePath: String, destDirPath: String): List? { + return unzipFileByKeyword(zipFilePath, destDirPath, null) + } + + /** + * Unzip the file. + * + * @param zipFile The ZIP file. + * @param destDir The destination directory. + * @return the unzipped files + * @throws IOException if unzip unsuccessfully + */ + @Throws(IOException::class) + fun unzipFile( + zipFile: File, + destDir: File + ): List? { + return unzipFileByKeyword(zipFile, destDir, null) + } + + /** + * Unzip the file by keyword. + * + * @param zipFilePath The path of ZIP file. + * @param destDirPath The path of destination directory. + * @param keyword The keyboard. + * @return the unzipped files + * @throws IOException if unzip unsuccessfully + */ + @Throws(IOException::class) + fun unzipFileByKeyword( + zipFilePath: String, + destDirPath: String, + keyword: String? + ): List? { + return unzipFileByKeyword( + getFileByPath(zipFilePath), + getFileByPath(destDirPath), + keyword + ) + } + + /** + * Unzip the file by keyword. + * + * @param zipFile The ZIP file. + * @param destDir The destination directory. + * @param keyword The keyboard. + * @return the unzipped files + * @throws IOException if unzip unsuccessfully + */ + @Throws(IOException::class) + fun unzipFileByKeyword( + zipFile: File?, + destDir: File?, + keyword: String? + ): List? { + if (zipFile == null || destDir == null) return null + val files = ArrayList() + val zip = ZipFile(zipFile) + val entries = zip.entries() + try { + if (isSpace(keyword)) { + while (entries.hasMoreElements()) { + val entry = entries.nextElement() as ZipEntry + val entryName = entry.name + if (entryName.contains("../")) { + Log.e("ZipUtils", "entryName: $entryName is dangerous!") + continue + } + if (!unzipChildFile(destDir, files, zip, entry, entryName)) return files + } + } else { + while (entries.hasMoreElements()) { + val entry = entries.nextElement() as ZipEntry + val entryName = entry.name + if (entryName.contains("../")) { + Log.e("ZipUtils", "entryName: $entryName is dangerous!") + continue + } + if (entryName.contains(keyword!!)) { + if (!unzipChildFile(destDir, files, zip, entry, entryName)) return files + } + } + } + } finally { + zip.close() + } + return files + } + + @Throws(IOException::class) + private fun unzipChildFile( + destDir: File, + files: MutableList, + zip: ZipFile, + entry: ZipEntry, + name: String + ): Boolean { + val file = File(destDir, name) + files.add(file) + if (entry.isDirectory) { + return createOrExistsDir(file) + } else { + if (!createOrExistsFile(file)) return false + BufferedInputStream(zip.getInputStream(entry)).use { `in` -> + BufferedOutputStream(FileOutputStream(file)).use { out -> + out.write(`in`.readBytes()) + } + } + } + return true + } + + /** + * Return the files' path in ZIP file. + * + * @param zipFilePath The path of ZIP file. + * @return the files' path in ZIP file + * @throws IOException if an I/O error has occurred + */ + @Throws(IOException::class) + fun getFilesPath(zipFilePath: String): List? { + return getFilesPath(getFileByPath(zipFilePath)) + } + + /** + * Return the files' path in ZIP file. + * + * @param zipFile The ZIP file. + * @return the files' path in ZIP file + * @throws IOException if an I/O error has occurred + */ + @Throws(IOException::class) + fun getFilesPath(zipFile: File?): List? { + if (zipFile == null) return null + val paths = ArrayList() + val zip = ZipFile(zipFile) + val entries = zip.entries() + while (entries.hasMoreElements()) { + val entryName = (entries.nextElement() as ZipEntry).name + if (entryName.contains("../")) { + Log.e("ZipUtils", "entryName: $entryName is dangerous!") + paths.add(entryName) + } else { + paths.add(entryName) + } + } + zip.close() + return paths + } + + /** + * Return the files' comment in ZIP file. + * + * @param zipFilePath The path of ZIP file. + * @return the files' comment in ZIP file + * @throws IOException if an I/O error has occurred + */ + @Throws(IOException::class) + fun getComments(zipFilePath: String): List? { + return getComments(getFileByPath(zipFilePath)) + } + + /** + * Return the files' comment in ZIP file. + * + * @param zipFile The ZIP file. + * @return the files' comment in ZIP file + * @throws IOException if an I/O error has occurred + */ + @Throws(IOException::class) + fun getComments(zipFile: File?): List? { + if (zipFile == null) return null + val comments = ArrayList() + val zip = ZipFile(zipFile) + val entries = zip.entries() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() as ZipEntry + comments.add(entry.comment) + } + zip.close() + return comments + } + + private fun createOrExistsDir(file: File?): Boolean { + return file != null && if (file.exists()) file.isDirectory else file.mkdirs() + } + + private fun createOrExistsFile(file: File?): Boolean { + if (file == null) return false + if (file.exists()) return file.isFile + if (!createOrExistsDir(file.parentFile)) return false + return try { + file.createNewFile() + } catch (e: IOException) { + e.printStackTrace() + false + } + } + + private fun getFileByPath(filePath: String): File? { + return if (isSpace(filePath)) null else File(filePath) + } + + private fun isSpace(s: String?): Boolean { + if (s == null) return true + var i = 0 + val len = s.length + while (i < len) { + if (!Character.isWhitespace(s[i])) { + return false + } + ++i + } + return true + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/web/HttpServer.kt b/app/src/main/java/io/legado/app/web/HttpServer.kt new file mode 100644 index 000000000..4f28db10b --- /dev/null +++ b/app/src/main/java/io/legado/app/web/HttpServer.kt @@ -0,0 +1,74 @@ +package io.legado.app.web + +import com.google.gson.Gson +import fi.iki.elonen.NanoHTTPD +import io.legado.app.web.controller.BookshelfController +import io.legado.app.web.controller.SourceController +import io.legado.app.web.utils.AssetsWeb +import io.legado.app.web.utils.ReturnData +import java.util.* + +class HttpServer(port: Int) : NanoHTTPD(port) { + private val assetsWeb = AssetsWeb("web") + + + override fun serve(session: IHTTPSession): Response { + var returnData: ReturnData? = null + var uri = session.uri + + try { + when (session.method.name) { + "OPTIONS" -> { + val response = newFixedLengthResponse("") + response.addHeader("Access-Control-Allow-Methods", "POST") + response.addHeader("Access-Control-Allow-Headers", "content-type") + response.addHeader("Access-Control-Allow-Origin", session.headers["origin"]) + //response.addHeader("Access-Control-Max-Age", "3600"); + return response + } + + "POST" -> { + val files = HashMap() + session.parseBody(files) + val postData = files["postData"] + + when (uri) { + "/saveSource" -> returnData = SourceController().saveSource(postData) + "/saveSources" -> returnData = SourceController().saveSources(postData) + "/saveBook" -> returnData = BookshelfController().saveBook(postData) + "/deleteSources" -> returnData = SourceController().deleteSources(postData) + } + } + + "GET" -> { + val parameters = session.parameters + + when (uri) { + "/getSource" -> returnData = SourceController().getSource(parameters) + "/getSources" -> returnData = SourceController().sources + "/getBookshelf" -> returnData = BookshelfController().bookshelf + "/getChapterList" -> returnData = + BookshelfController().getChapterList(parameters) + "/getBookContent" -> returnData = + BookshelfController().getBookContent(parameters) + } + } + } + + if (returnData == null) { + if (uri.endsWith("/")) + uri += "index.html" + return assetsWeb.getResponse(uri) + } + + val response = newFixedLengthResponse(Gson().toJson(returnData)) + response.addHeader("Access-Control-Allow-Methods", "GET, POST") + response.addHeader("Access-Control-Allow-Origin", session.headers["origin"]) + return response + } catch (e: Exception) { + return newFixedLengthResponse(e.message) + } + + } + +} diff --git a/app/src/main/java/io/legado/app/web/ReadMe.md b/app/src/main/java/io/legado/app/web/ReadMe.md new file mode 100644 index 000000000..cd5544c3e --- /dev/null +++ b/app/src/main/java/io/legado/app/web/ReadMe.md @@ -0,0 +1 @@ +# web服务 \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/web/WebSocketServer.kt b/app/src/main/java/io/legado/app/web/WebSocketServer.kt new file mode 100644 index 000000000..c885a1e0a --- /dev/null +++ b/app/src/main/java/io/legado/app/web/WebSocketServer.kt @@ -0,0 +1,13 @@ +package io.legado.app.web + +import fi.iki.elonen.NanoWSD +import io.legado.app.web.controller.SourceDebugWebSocket + +class WebSocketServer(port: Int) : NanoWSD(port) { + + override fun openWebSocket(handshake: IHTTPSession): WebSocket? { + return if (handshake.uri == "/sourceDebug") { + SourceDebugWebSocket(handshake) + } else null + } +} diff --git a/app/src/main/java/io/legado/app/web/controller/BookshelfController.kt b/app/src/main/java/io/legado/app/web/controller/BookshelfController.kt new file mode 100644 index 000000000..d41d4117c --- /dev/null +++ b/app/src/main/java/io/legado/app/web/controller/BookshelfController.kt @@ -0,0 +1,56 @@ +package io.legado.app.web.controller + +import io.legado.app.App +import io.legado.app.data.entities.Book +import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonObject +import io.legado.app.web.utils.ReturnData + +class BookshelfController { + + val bookshelf: ReturnData + get() { + val books = App.db.bookDao().allBooks + val returnData = ReturnData() + return if (books.isEmpty()) { + returnData.setErrorMsg("还没有添加小说") + } else returnData.setData(books) + } + + fun getChapterList(parameters: Map>): ReturnData { + val strings = parameters["url"] + val returnData = ReturnData() + if (strings == null) { + return returnData.setErrorMsg("参数url不能为空,请指定书籍地址") + } + val chapterList = App.db.bookChapterDao().getChapterList(strings[0]) + return returnData.setData(chapterList) + } + + fun getBookContent(parameters: Map>): ReturnData { + val strings = parameters["url"] + val returnData = ReturnData() + if (strings == null) { + return returnData.setErrorMsg("参数url不能为空,请指定内容地址") + } + val book = App.db.bookDao().getBook(strings[0]) + val chapter = App.db.bookChapterDao().getChapter(strings[0], strings[1].toInt()) + if (book == null || chapter == null) { + returnData.setErrorMsg("未找到") + } else { + + } + return returnData + } + + fun saveBook(postData: String?): ReturnData { + val book = GSON.fromJsonObject(postData) + val returnData = ReturnData() + if (book != null) { + App.db.bookDao().insert(book) + return returnData.setData("") + } + return returnData.setErrorMsg("格式不对") + } + +} diff --git a/app/src/main/java/io/legado/app/web/controller/SourceController.kt b/app/src/main/java/io/legado/app/web/controller/SourceController.kt new file mode 100644 index 000000000..3711092fc --- /dev/null +++ b/app/src/main/java/io/legado/app/web/controller/SourceController.kt @@ -0,0 +1,79 @@ +package io.legado.app.web.controller + + +import android.text.TextUtils +import io.legado.app.App +import io.legado.app.data.entities.BookSource +import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonArray +import io.legado.app.utils.fromJsonObject +import io.legado.app.web.utils.ReturnData + +class SourceController { + + val sources: ReturnData + get() { + val bookSources = App.db.bookSourceDao().all + val returnData = ReturnData() + return if (bookSources.isEmpty()) { + returnData.setErrorMsg("设备书源列表为空") + } else returnData.setData(bookSources) + } + + fun saveSource(postData: String?): ReturnData { + val returnData = ReturnData() + try { + val bookSource = GSON.fromJsonObject(postData) + if (bookSource != null) { + if (TextUtils.isEmpty(bookSource.bookSourceName) || TextUtils.isEmpty(bookSource.bookSourceUrl)) { + returnData.setErrorMsg("书源名称和URL不能为空") + } else { + App.db.bookSourceDao().insert(bookSource) + returnData.setData("") + } + } else { + returnData.setErrorMsg("转换书源失败") + } + } catch (e: Exception) { + returnData.setErrorMsg(e.localizedMessage) + } + return returnData + } + + fun saveSources(postData: String?): ReturnData { + val okSources = arrayListOf() + kotlin.runCatching { + val bookSources = GSON.fromJsonArray(postData) + if (bookSources != null) { + for (bookSource in bookSources) { + if (bookSource.bookSourceName.isBlank() || bookSource.bookSourceUrl.isBlank()) { + continue + } + App.db.bookSourceDao().insert(bookSource) + okSources.add(bookSource) + } + } + } + return ReturnData().setData(okSources) + } + + fun getSource(parameters: Map>): ReturnData { + val strings = parameters["url"] + val returnData = ReturnData() + if (strings == null) { + return returnData.setErrorMsg("参数url不能为空,请指定书源地址") + } + val bookSource = App.db.bookSourceDao().getBookSource(strings[0]) + ?: return returnData.setErrorMsg("未找到书源,请检查书源地址") + return returnData.setData(bookSource) + } + + fun deleteSources(postData: String?): ReturnData { + kotlin.runCatching { + GSON.fromJsonArray(postData)?.let { + App.db.bookSourceDao().delete(*it.toTypedArray()) + } + } + return ReturnData().setData("已执行"/*okSources*/) + } +} diff --git a/app/src/main/java/io/legado/app/web/controller/SourceDebugWebSocket.kt b/app/src/main/java/io/legado/app/web/controller/SourceDebugWebSocket.kt new file mode 100644 index 000000000..999ae7872 --- /dev/null +++ b/app/src/main/java/io/legado/app/web/controller/SourceDebugWebSocket.kt @@ -0,0 +1,82 @@ +package io.legado.app.web.controller + + +import fi.iki.elonen.NanoHTTPD +import fi.iki.elonen.NanoWSD +import io.legado.app.App +import io.legado.app.R +import io.legado.app.model.WebBook +import io.legado.app.model.webbook.SourceDebug +import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonObject +import io.legado.app.utils.isJson +import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers.IO +import java.io.IOException + + +class SourceDebugWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) : + NanoWSD.WebSocket(handshakeRequest), + CoroutineScope by MainScope(), + SourceDebug.Callback { + + + override fun onOpen() { + launch(IO) { + do { + delay(30000) + runCatching { + ping(byteArrayOf("ping".toByte())) + } + } while (isOpen) + } + } + + override fun onClose( + code: NanoWSD.WebSocketFrame.CloseCode, + reason: String, + initiatedByRemote: Boolean + ) { + cancel() + SourceDebug.cancelDebug(true) + } + + override fun onMessage(message: NanoWSD.WebSocketFrame) { + if (!message.textPayload.isJson()) return + kotlin.runCatching { + val debugBean = GSON.fromJsonObject>(message.textPayload) + if (debugBean != null) { + val tag = debugBean["tag"] + val key = debugBean["key"] + if (tag.isNullOrBlank() || key.isNullOrBlank()) { + kotlin.runCatching { + send(App.INSTANCE.getString(R.string.cannot_empty)) + close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false) + } + return + } + App.db.bookSourceDao().getBookSource(tag)?.let { + SourceDebug(WebBook(it), this).startDebug(key) + } + } + } + } + + override fun onPong(pong: NanoWSD.WebSocketFrame) { + + } + + override fun onException(exception: IOException) { + SourceDebug.cancelDebug(true) + } + + override fun printLog(state: Int, msg: String) { + kotlin.runCatching { + send(msg) + if (state == -1 || state == 1000) { + SourceDebug.cancelDebug(true) + } + } + } + +} diff --git a/app/src/main/java/io/legado/app/web/utils/AssetsWeb.kt b/app/src/main/java/io/legado/app/web/utils/AssetsWeb.kt new file mode 100644 index 000000000..503f7aad5 --- /dev/null +++ b/app/src/main/java/io/legado/app/web/utils/AssetsWeb.kt @@ -0,0 +1,44 @@ +package io.legado.app.web.utils + +import android.content.res.AssetManager +import android.text.TextUtils +import fi.iki.elonen.NanoHTTPD +import io.legado.app.App +import java.io.File +import java.io.IOException + + +class AssetsWeb(rootPath: String) { + private val assetManager: AssetManager = App.INSTANCE.assets + private var rootPath = "web" + + init { + if (!TextUtils.isEmpty(rootPath)) { + this.rootPath = rootPath + } + } + + @Throws(IOException::class) + fun getResponse(path: String): NanoHTTPD.Response { + var path1 = path + path1 = (rootPath + path1).replace("/+".toRegex(), File.separator) + val inputStream = assetManager.open(path1) + return NanoHTTPD.newChunkedResponse( + NanoHTTPD.Response.Status.OK, + getMimeType(path1), + inputStream + ) + } + + private fun getMimeType(path: String): String { + val suffix = path.substring(path.lastIndexOf(".")) + return when { + suffix.equals(".html", ignoreCase = true) + || suffix.equals(".htm", ignoreCase = true) -> "text/html" + suffix.equals(".js", ignoreCase = true) -> "text/javascript" + suffix.equals(".css", ignoreCase = true) -> "text/css" + suffix.equals(".ico", ignoreCase = true) -> "image/x-icon" + else -> "text/html" + } + } +} diff --git a/app/src/main/java/io/legado/app/web/utils/ReturnData.kt b/app/src/main/java/io/legado/app/web/utils/ReturnData.kt new file mode 100644 index 000000000..5ea07243f --- /dev/null +++ b/app/src/main/java/io/legado/app/web/utils/ReturnData.kt @@ -0,0 +1,39 @@ +package io.legado.app.web.utils + + +class ReturnData { + + var isSuccess: Boolean = false + + var errorCode: Int = 0 + + private var errorMsg: String? = null + + private var data: Any? = null + + init { + this.isSuccess = false + this.errorMsg = "未知错误,请联系开发者!" + } + + fun getErrorMsg(): String? { + return errorMsg + } + + fun setErrorMsg(errorMsg: String): ReturnData { + this.isSuccess = false + this.errorMsg = errorMsg + return this + } + + fun getData(): Any? { + return data + } + + fun setData(data: Any): ReturnData { + this.isSuccess = true + this.errorMsg = "" + this.data = data + return this + } +} diff --git a/app/src/main/res/anim/anim_none.xml b/app/src/main/res/anim/anim_none.xml new file mode 100644 index 000000000..fe9ddac3b --- /dev/null +++ b/app/src/main/res/anim/anim_none.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/anim_readbook_bottom_in.xml b/app/src/main/res/anim/anim_readbook_bottom_in.xml new file mode 100644 index 000000000..48fe0c0aa --- /dev/null +++ b/app/src/main/res/anim/anim_readbook_bottom_in.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/anim_readbook_bottom_out.xml b/app/src/main/res/anim/anim_readbook_bottom_out.xml new file mode 100644 index 000000000..2e91beb95 --- /dev/null +++ b/app/src/main/res/anim/anim_readbook_bottom_out.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/anim_readbook_top_in.xml b/app/src/main/res/anim/anim_readbook_top_in.xml new file mode 100644 index 000000000..e36360bc0 --- /dev/null +++ b/app/src/main/res/anim/anim_readbook_top_in.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/anim_readbook_top_out.xml b/app/src/main/res/anim/anim_readbook_top_out.xml new file mode 100644 index 000000000..0b8acbceb --- /dev/null +++ b/app/src/main/res/anim/anim_readbook_top_out.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/moprogress_bottom_in.xml b/app/src/main/res/anim/moprogress_bottom_in.xml new file mode 100644 index 000000000..59904f594 --- /dev/null +++ b/app/src/main/res/anim/moprogress_bottom_in.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/moprogress_bottom_out.xml b/app/src/main/res/anim/moprogress_bottom_out.xml new file mode 100644 index 000000000..2348594fc --- /dev/null +++ b/app/src/main/res/anim/moprogress_bottom_out.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/moprogress_in.xml b/app/src/main/res/anim/moprogress_in.xml new file mode 100644 index 000000000..8e922153b --- /dev/null +++ b/app/src/main/res/anim/moprogress_in.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/moprogress_in_bottom_right.xml b/app/src/main/res/anim/moprogress_in_bottom_right.xml new file mode 100644 index 000000000..93348f5a8 --- /dev/null +++ b/app/src/main/res/anim/moprogress_in_bottom_right.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/moprogress_in_top_right.xml b/app/src/main/res/anim/moprogress_in_top_right.xml new file mode 100644 index 000000000..81bb2e919 --- /dev/null +++ b/app/src/main/res/anim/moprogress_in_top_right.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/moprogress_out.xml b/app/src/main/res/anim/moprogress_out.xml new file mode 100644 index 000000000..546619cc2 --- /dev/null +++ b/app/src/main/res/anim/moprogress_out.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/moprogress_out_bottom_right.xml b/app/src/main/res/anim/moprogress_out_bottom_right.xml new file mode 100644 index 000000000..d0d1eaffa --- /dev/null +++ b/app/src/main/res/anim/moprogress_out_bottom_right.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/moprogress_out_top_right.xml b/app/src/main/res/anim/moprogress_out_top_right.xml new file mode 100644 index 000000000..310b29d7e --- /dev/null +++ b/app/src/main/res/anim/moprogress_out_top_right.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml index 6348baae3..c7bd21dbd 100644 --- a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -1,34 +1,34 @@ + xmlns:aapt="http://schemas.android.com/aapt" + android:width="108dp" + android:height="108dp" + android:viewportHeight="108" + android:viewportWidth="108"> + android:fillType="evenOdd" + android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z" + android:strokeColor="#00000000" + android:strokeWidth="1"> + android:endX="78.5885" + android:endY="90.9159" + android:startX="48.7653" + android:startY="61.0927" + android:type="linear"> + android:color="#44000000" + android:offset="0.0" /> + android:color="#00000000" + android:offset="1.0" /> + android:fillColor="#FFFFFF" + android:fillType="nonZero" + android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z" + android:strokeColor="#00000000" + android:strokeWidth="1" /> diff --git a/app/src/main/res/drawable/bg_chapter_item_divider.xml b/app/src/main/res/drawable/bg_chapter_item_divider.xml new file mode 100644 index 000000000..38db10eaf --- /dev/null +++ b/app/src/main/res/drawable/bg_chapter_item_divider.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_edit.xml b/app/src/main/res/drawable/bg_edit.xml new file mode 100644 index 000000000..9653cca5e --- /dev/null +++ b/app/src/main/res/drawable/bg_edit.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_ib_pre_round.xml b/app/src/main/res/drawable/bg_ib_pre_round.xml deleted file mode 100644 index a370bb64b..000000000 --- a/app/src/main/res/drawable/bg_ib_pre_round.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_popup_menu.xml b/app/src/main/res/drawable/bg_popup_menu.xml new file mode 100644 index 000000000..b51849bcd --- /dev/null +++ b/app/src/main/res/drawable/bg_popup_menu.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_shadow_bottom.png b/app/src/main/res/drawable/bg_shadow_bottom.png new file mode 100644 index 000000000..ac0124b67 Binary files /dev/null and b/app/src/main/res/drawable/bg_shadow_bottom.png differ diff --git a/app/src/main/res/drawable/bg_shadow_bottom_night.png b/app/src/main/res/drawable/bg_shadow_bottom_night.png new file mode 100644 index 000000000..c3036b036 Binary files /dev/null and b/app/src/main/res/drawable/bg_shadow_bottom_night.png differ diff --git a/app/src/main/res/drawable/bg_shadow_top.png b/app/src/main/res/drawable/bg_shadow_top.png new file mode 100644 index 000000000..cb8fee03d Binary files /dev/null and b/app/src/main/res/drawable/bg_shadow_top.png differ diff --git a/app/src/main/res/drawable/bg_shadow_top_night.png b/app/src/main/res/drawable/bg_shadow_top_night.png new file mode 100644 index 000000000..7984c35db Binary files /dev/null and b/app/src/main/res/drawable/bg_shadow_top_night.png differ diff --git a/app/src/main/res/drawable/bg_textfield_search.xml b/app/src/main/res/drawable/bg_textfield_search.xml new file mode 100644 index 000000000..8069a07d8 --- /dev/null +++ b/app/src/main/res/drawable/bg_textfield_search.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/fastscroll_bubble.xml b/app/src/main/res/drawable/fastscroll_bubble.xml new file mode 100644 index 000000000..f72b616ae --- /dev/null +++ b/app/src/main/res/drawable/fastscroll_bubble.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/fastscroll_handle.xml b/app/src/main/res/drawable/fastscroll_handle.xml new file mode 100644 index 000000000..8671a01cf --- /dev/null +++ b/app/src/main/res/drawable/fastscroll_handle.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/fastscroll_track.xml b/app/src/main/res/drawable/fastscroll_track.xml new file mode 100644 index 000000000..854768787 --- /dev/null +++ b/app/src/main/res/drawable/fastscroll_track.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_about.xml b/app/src/main/res/drawable/ic_about.xml new file mode 100644 index 000000000..3f2b1987a --- /dev/null +++ b/app/src/main/res/drawable/ic_about.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 000000000..c7542ba87 --- /dev/null +++ b/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add_online.xml b/app/src/main/res/drawable/ic_add_online.xml new file mode 100644 index 000000000..8a3cc9b8b --- /dev/null +++ b/app/src/main/res/drawable/ic_add_online.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_arrange.xml b/app/src/main/res/drawable/ic_arrange.xml new file mode 100644 index 000000000..ce2d50152 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrange.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_arrow_back.xml b/app/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 000000000..2d68f797b --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_drop_down.xml b/app/src/main/res/drawable/ic_arrow_drop_down.xml new file mode 100644 index 000000000..df5ff10d0 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_drop_down.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_drop_up.xml b/app/src/main/res/drawable/ic_arrow_drop_up.xml new file mode 100644 index 000000000..ed31816be --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_drop_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_author.xml b/app/src/main/res/drawable/ic_author.xml new file mode 100644 index 000000000..00d53f351 --- /dev/null +++ b/app/src/main/res/drawable/ic_author.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_auto_page.xml b/app/src/main/res/drawable/ic_auto_page.xml new file mode 100644 index 000000000..bec2a48f1 --- /dev/null +++ b/app/src/main/res/drawable/ic_auto_page.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_auto_page_stop.xml b/app/src/main/res/drawable/ic_auto_page_stop.xml new file mode 100644 index 000000000..6a6c74535 --- /dev/null +++ b/app/src/main/res/drawable/ic_auto_page_stop.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_back_last.xml b/app/src/main/res/drawable/ic_back_last.xml new file mode 100644 index 000000000..f1355c878 --- /dev/null +++ b/app/src/main/res/drawable/ic_back_last.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_backup.xml b/app/src/main/res/drawable/ic_backup.xml new file mode 100644 index 000000000..200bb7081 --- /dev/null +++ b/app/src/main/res/drawable/ic_backup.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_book_has.xml b/app/src/main/res/drawable/ic_book_has.xml new file mode 100644 index 000000000..a920e50ae --- /dev/null +++ b/app/src/main/res/drawable/ic_book_has.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_book_last.xml b/app/src/main/res/drawable/ic_book_last.xml new file mode 100644 index 000000000..82b0842d6 --- /dev/null +++ b/app/src/main/res/drawable/ic_book_last.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_book_source_manage.xml b/app/src/main/res/drawable/ic_book_source_manage.xml new file mode 100644 index 000000000..11fb46c9e --- /dev/null +++ b/app/src/main/res/drawable/ic_book_source_manage.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_bookmark.xml b/app/src/main/res/drawable/ic_bookmark.xml new file mode 100644 index 000000000..551bee2c8 --- /dev/null +++ b/app/src/main/res/drawable/ic_bookmark.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_brightness.xml b/app/src/main/res/drawable/ic_brightness.xml new file mode 100644 index 000000000..ac37ba7d5 --- /dev/null +++ b/app/src/main/res/drawable/ic_brightness.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_brightness_auto.xml b/app/src/main/res/drawable/ic_brightness_auto.xml new file mode 100644 index 000000000..6ea7b953e --- /dev/null +++ b/app/src/main/res/drawable/ic_brightness_auto.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_bug_report.xml b/app/src/main/res/drawable/ic_bug_report.xml new file mode 100644 index 000000000..ac83c8c90 --- /dev/null +++ b/app/src/main/res/drawable/ic_bug_report.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_cancel.xml b/app/src/main/res/drawable/ic_cancel.xml new file mode 100644 index 000000000..21715879d --- /dev/null +++ b/app/src/main/res/drawable/ic_cancel.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_chapter_list.xml b/app/src/main/res/drawable/ic_chapter_list.xml new file mode 100644 index 000000000..d64d58dbf --- /dev/null +++ b/app/src/main/res/drawable/ic_chapter_list.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 000000000..620bc9133 --- /dev/null +++ b/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_check_source.xml b/app/src/main/res/drawable/ic_check_source.xml new file mode 100644 index 000000000..cbd10fd70 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_source.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_copy.xml b/app/src/main/res/drawable/ic_copy.xml new file mode 100644 index 000000000..cdf136c9c --- /dev/null +++ b/app/src/main/res/drawable/ic_copy.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_daytime.xml b/app/src/main/res/drawable/ic_daytime.xml new file mode 100644 index 000000000..60921c1dc --- /dev/null +++ b/app/src/main/res/drawable/ic_daytime.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_disclaimer.xml b/app/src/main/res/drawable/ic_disclaimer.xml new file mode 100644 index 000000000..6cc4ea597 --- /dev/null +++ b/app/src/main/res/drawable/ic_disclaimer.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_divider.xml b/app/src/main/res/drawable/ic_divider.xml new file mode 100644 index 000000000..21a88f21a --- /dev/null +++ b/app/src/main/res/drawable/ic_divider.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_donate.xml b/app/src/main/res/drawable/ic_donate.xml new file mode 100644 index 000000000..1cf58b952 --- /dev/null +++ b/app/src/main/res/drawable/ic_donate.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_download.xml b/app/src/main/res/drawable/ic_download.xml new file mode 100644 index 000000000..bf296eceb --- /dev/null +++ b/app/src/main/res/drawable/ic_download.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_download_line.xml b/app/src/main/res/drawable/ic_download_line.xml new file mode 100644 index 000000000..3b0ae72ea --- /dev/null +++ b/app/src/main/res/drawable/ic_download_line.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_exchange.xml b/app/src/main/res/drawable/ic_exchange.xml new file mode 100644 index 000000000..00fa9cc95 --- /dev/null +++ b/app/src/main/res/drawable/ic_exchange.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_exchange_order.xml b/app/src/main/res/drawable/ic_exchange_order.xml new file mode 100644 index 000000000..36c2bcefc --- /dev/null +++ b/app/src/main/res/drawable/ic_exchange_order.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_expand_less_24dp.xml b/app/src/main/res/drawable/ic_expand_less_24dp.xml new file mode 100644 index 000000000..f70feed92 --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_less_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_expand_more_24dp.xml b/app/src/main/res/drawable/ic_expand_more_24dp.xml new file mode 100644 index 000000000..5d10f3768 --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_more_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_explore_black_24dp.xml b/app/src/main/res/drawable/ic_explore_black_24dp.xml new file mode 100644 index 000000000..d841826b0 --- /dev/null +++ b/app/src/main/res/drawable/ic_explore_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_faq.xml b/app/src/main/res/drawable/ic_faq.xml new file mode 100644 index 000000000..b6d620eb5 --- /dev/null +++ b/app/src/main/res/drawable/ic_faq.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_find_replace.xml b/app/src/main/res/drawable/ic_find_replace.xml new file mode 100644 index 000000000..07bbfc39c --- /dev/null +++ b/app/src/main/res/drawable/ic_find_replace.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_folder.xml b/app/src/main/res/drawable/ic_folder.xml new file mode 100644 index 000000000..cb2a84ca4 --- /dev/null +++ b/app/src/main/res/drawable/ic_folder.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_format_line_spacing.xml b/app/src/main/res/drawable/ic_format_line_spacing.xml new file mode 100644 index 000000000..3308038da --- /dev/null +++ b/app/src/main/res/drawable/ic_format_line_spacing.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_groups.xml b/app/src/main/res/drawable/ic_groups.xml new file mode 100644 index 000000000..0ebcd4526 --- /dev/null +++ b/app/src/main/res/drawable/ic_groups.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_history.xml b/app/src/main/res/drawable/ic_history.xml new file mode 100644 index 000000000..095e0f7ab --- /dev/null +++ b/app/src/main/res/drawable/ic_history.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_image.xml b/app/src/main/res/drawable/ic_image.xml new file mode 100644 index 000000000..bfd8e5c65 --- /dev/null +++ b/app/src/main/res/drawable/ic_image.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_import.xml b/app/src/main/res/drawable/ic_import.xml new file mode 100644 index 000000000..b9929a808 --- /dev/null +++ b/app/src/main/res/drawable/ic_import.xml @@ -0,0 +1,32 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_interface_setting.xml b/app/src/main/res/drawable/ic_interface_setting.xml new file mode 100644 index 000000000..139b00724 --- /dev/null +++ b/app/src/main/res/drawable/ic_interface_setting.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_last_read.xml b/app/src/main/res/drawable/ic_last_read.xml new file mode 100644 index 000000000..9a2791b42 --- /dev/null +++ b/app/src/main/res/drawable/ic_last_read.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_launch.xml b/app/src/main/res/drawable/ic_launch.xml new file mode 100644 index 000000000..6b423cdc7 --- /dev/null +++ b/app/src/main/res/drawable/ic_launch.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index a0ad202f9..d81b113f1 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,74 +1,170 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_library_books_black_24dp.xml b/app/src/main/res/drawable/ic_library_books_black_24dp.xml new file mode 100644 index 000000000..06deda209 --- /dev/null +++ b/app/src/main/res/drawable/ic_library_books_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_list.xml b/app/src/main/res/drawable/ic_list.xml new file mode 100644 index 000000000..4a1b0d561 --- /dev/null +++ b/app/src/main/res/drawable/ic_list.xml @@ -0,0 +1,26 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_live_help_black_24dp.xml b/app/src/main/res/drawable/ic_live_help_black_24dp.xml new file mode 100644 index 000000000..8b9c1ac46 --- /dev/null +++ b/app/src/main/res/drawable/ic_live_help_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_mail.xml b/app/src/main/res/drawable/ic_mail.xml new file mode 100644 index 000000000..cbe92189a --- /dev/null +++ b/app/src/main/res/drawable/ic_mail.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_menu_camera.xml b/app/src/main/res/drawable/ic_menu_camera.xml index 41688d5c0..634fe9221 100644 --- a/app/src/main/res/drawable/ic_menu_camera.xml +++ b/app/src/main/res/drawable/ic_menu_camera.xml @@ -1,12 +1,12 @@ + android:width="24dp" + android:height="24dp" + android:viewportWidth="24.0" + android:viewportHeight="24.0"> + android:fillColor="#FF000000" + android:pathData="M12,12m-3.2,0a3.2,3.2 0,1 1,6.4 0a3.2,3.2 0,1 1,-6.4 0" /> + android:fillColor="#FF000000" + android:pathData="M9,2L7.17,4H4c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V6c0,-1.1 -0.9,-2 -2,-2h-3.17L15,2H9zm3,15c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5z" /> diff --git a/app/src/main/res/drawable/ic_menu_gallery.xml b/app/src/main/res/drawable/ic_menu_gallery.xml index ff8ce529f..03c77099f 100644 --- a/app/src/main/res/drawable/ic_menu_gallery.xml +++ b/app/src/main/res/drawable/ic_menu_gallery.xml @@ -1,9 +1,9 @@ + android:width="24dp" + android:height="24dp" + android:viewportWidth="24.0" + android:viewportHeight="24.0"> + android:fillColor="#FF000000" + android:pathData="M22,16V4c0,-1.1 -0.9,-2 -2,-2H8c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2zm-11,-4l2.03,2.71L16,11l4,5H8l3,-4zM2,6v14c0,1.1 0.9,2 2,2h14v-2H4V6H2z" /> diff --git a/app/src/main/res/drawable/ic_menu_manage.xml b/app/src/main/res/drawable/ic_menu_manage.xml index a0e423cd0..aeb047d02 100644 --- a/app/src/main/res/drawable/ic_menu_manage.xml +++ b/app/src/main/res/drawable/ic_menu_manage.xml @@ -1,9 +1,9 @@ + android:width="24dp" + android:height="24dp" + android:viewportWidth="24.0" + android:viewportHeight="24.0"> + android:fillColor="#FF000000" + android:pathData="M22.7,19l-9.1,-9.1c0.9,-2.3 0.4,-5 -1.5,-6.9 -2,-2 -5,-2.4 -7.4,-1.3L9,6 6,9 1.6,4.7C0.4,7.1 0.9,10.1 2.9,12.1c1.9,1.9 4.6,2.4 6.9,1.5l9.1,9.1c0.4,0.4 1,0.4 1.4,0l2.3,-2.3c0.5,-0.4 0.5,-1.1 0.1,-1.4z" /> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_menu_send.xml b/app/src/main/res/drawable/ic_menu_send.xml index 9745066f1..fdf1c9009 100644 --- a/app/src/main/res/drawable/ic_menu_send.xml +++ b/app/src/main/res/drawable/ic_menu_send.xml @@ -1,9 +1,9 @@ + android:width="24dp" + android:height="24dp" + android:viewportWidth="24.0" + android:viewportHeight="24.0"> + android:fillColor="#FF000000" + android:pathData="M2.01,21L23,12 2.01,3 2,10l15,2 -15,2z" /> diff --git a/app/src/main/res/drawable/ic_menu_share.xml b/app/src/main/res/drawable/ic_menu_share.xml index b3e39e222..338d95ad5 100644 --- a/app/src/main/res/drawable/ic_menu_share.xml +++ b/app/src/main/res/drawable/ic_menu_share.xml @@ -1,9 +1,9 @@ + android:width="24dp" + android:height="24dp" + android:viewportWidth="24.0" + android:viewportHeight="24.0"> + android:fillColor="#FF000000" + android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z" /> diff --git a/app/src/main/res/drawable/ic_menu_slideshow.xml b/app/src/main/res/drawable/ic_menu_slideshow.xml index ae51e494a..5e9e163a5 100644 --- a/app/src/main/res/drawable/ic_menu_slideshow.xml +++ b/app/src/main/res/drawable/ic_menu_slideshow.xml @@ -1,9 +1,9 @@ + android:width="24dp" + android:height="24dp" + android:viewportWidth="24.0" + android:viewportHeight="24.0"> + android:fillColor="#FF000000" + android:pathData="M4,6H2v14c0,1.1 0.9,2 2,2h14v-2H4V6zm16,-4H8c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zm-8,12.5v-9l6,4.5 -6,4.5z" /> diff --git a/app/src/main/res/drawable/ic_more_vert.xml b/app/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 000000000..7b7f19554 --- /dev/null +++ b/app/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_network_check.xml b/app/src/main/res/drawable/ic_network_check.xml new file mode 100644 index 000000000..9d88ef1ae --- /dev/null +++ b/app/src/main/res/drawable/ic_network_check.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_pause_24dp.xml b/app/src/main/res/drawable/ic_pause_24dp.xml new file mode 100644 index 000000000..193030b12 --- /dev/null +++ b/app/src/main/res/drawable/ic_pause_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_pause_outline_24dp.xml b/app/src/main/res/drawable/ic_pause_outline_24dp.xml new file mode 100644 index 000000000..11f30c38d --- /dev/null +++ b/app/src/main/res/drawable/ic_pause_outline_24dp.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_person_black_24dp.xml b/app/src/main/res/drawable/ic_person_black_24dp.xml new file mode 100644 index 000000000..9f1acc792 --- /dev/null +++ b/app/src/main/res/drawable/ic_person_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_play_24dp.xml b/app/src/main/res/drawable/ic_play_24dp.xml new file mode 100644 index 000000000..4250d72cb --- /dev/null +++ b/app/src/main/res/drawable/ic_play_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_play_outline_24dp.xml b/app/src/main/res/drawable/ic_play_outline_24dp.xml new file mode 100644 index 000000000..deb25e9f2 --- /dev/null +++ b/app/src/main/res/drawable/ic_play_outline_24dp.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_qq_group.xml b/app/src/main/res/drawable/ic_qq_group.xml new file mode 100644 index 000000000..81d4c8597 --- /dev/null +++ b/app/src/main/res/drawable/ic_qq_group.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_read_aloud.xml b/app/src/main/res/drawable/ic_read_aloud.xml new file mode 100644 index 000000000..7978d2196 --- /dev/null +++ b/app/src/main/res/drawable/ic_read_aloud.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_refresh_black_24dp.xml b/app/src/main/res/drawable/ic_refresh_black_24dp.xml new file mode 100644 index 000000000..fc7f629d8 --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh_black_24dp.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_refresh_white_24dp.xml b/app/src/main/res/drawable/ic_refresh_white_24dp.xml new file mode 100644 index 000000000..9e4f8dfcc --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_remove.xml b/app/src/main/res/drawable/ic_remove.xml new file mode 100644 index 000000000..90eec6f30 --- /dev/null +++ b/app/src/main/res/drawable/ic_remove.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_restore.xml b/app/src/main/res/drawable/ic_restore.xml new file mode 100644 index 000000000..d9f75ea6d --- /dev/null +++ b/app/src/main/res/drawable/ic_restore.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_rss_feed.xml b/app/src/main/res/drawable/ic_rss_feed.xml new file mode 100644 index 000000000..ed6228cc2 --- /dev/null +++ b/app/src/main/res/drawable/ic_rss_feed.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_save.xml b/app/src/main/res/drawable/ic_save.xml new file mode 100644 index 000000000..042962ee5 --- /dev/null +++ b/app/src/main/res/drawable/ic_save.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_scan.xml b/app/src/main/res/drawable/ic_scan.xml new file mode 100644 index 000000000..3285a9327 --- /dev/null +++ b/app/src/main/res/drawable/ic_scan.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_scoring.xml b/app/src/main/res/drawable/ic_scoring.xml new file mode 100644 index 000000000..683b68107 --- /dev/null +++ b/app/src/main/res/drawable/ic_scoring.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_screen.xml b/app/src/main/res/drawable/ic_screen.xml new file mode 100644 index 000000000..4a641e517 --- /dev/null +++ b/app/src/main/res/drawable/ic_screen.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 000000000..921e00d7a --- /dev/null +++ b/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,17 @@ + + + + diff --git a/app/src/main/res/drawable/ic_select_all.xml b/app/src/main/res/drawable/ic_select_all.xml new file mode 100644 index 000000000..7211a6157 --- /dev/null +++ b/app/src/main/res/drawable/ic_select_all.xml @@ -0,0 +1,23 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 000000000..0424ce07a --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 000000000..0022d3b38 --- /dev/null +++ b/app/src/main/res/drawable/ic_share.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_skip_next.xml b/app/src/main/res/drawable/ic_skip_next.xml new file mode 100644 index 000000000..eae4da1bf --- /dev/null +++ b/app/src/main/res/drawable/ic_skip_next.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_skip_previous.xml b/app/src/main/res/drawable/ic_skip_previous.xml new file mode 100644 index 000000000..abb9944fc --- /dev/null +++ b/app/src/main/res/drawable/ic_skip_previous.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_star.xml b/app/src/main/res/drawable/ic_star.xml new file mode 100644 index 000000000..a2ebf3532 --- /dev/null +++ b/app/src/main/res/drawable/ic_star.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_star_border.xml b/app/src/main/res/drawable/ic_star_border.xml new file mode 100644 index 000000000..c452e47ec --- /dev/null +++ b/app/src/main/res/drawable/ic_star_border.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_stop_black_24dp.xml b/app/src/main/res/drawable/ic_stop_black_24dp.xml new file mode 100644 index 000000000..025a8b82e --- /dev/null +++ b/app/src/main/res/drawable/ic_stop_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_storage_black_24dp.xml b/app/src/main/res/drawable/ic_storage_black_24dp.xml new file mode 100644 index 000000000..b11623929 --- /dev/null +++ b/app/src/main/res/drawable/ic_storage_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_swap_outline_24dp.xml b/app/src/main/res/drawable/ic_swap_outline_24dp.xml new file mode 100644 index 000000000..f9026522d --- /dev/null +++ b/app/src/main/res/drawable/ic_swap_outline_24dp.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_theme.xml b/app/src/main/res/drawable/ic_theme.xml new file mode 100644 index 000000000..90da16498 --- /dev/null +++ b/app/src/main/res/drawable/ic_theme.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_time_add_24dp.xml b/app/src/main/res/drawable/ic_time_add_24dp.xml new file mode 100644 index 000000000..b3960a060 --- /dev/null +++ b/app/src/main/res/drawable/ic_time_add_24dp.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_timer_black_24dp.xml b/app/src/main/res/drawable/ic_timer_black_24dp.xml new file mode 100644 index 000000000..df5ad4e8a --- /dev/null +++ b/app/src/main/res/drawable/ic_timer_black_24dp.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_toc.xml b/app/src/main/res/drawable/ic_toc.xml new file mode 100644 index 000000000..62bb997db --- /dev/null +++ b/app/src/main/res/drawable/ic_toc.xml @@ -0,0 +1,26 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_top_source.xml b/app/src/main/res/drawable/ic_top_source.xml new file mode 100644 index 000000000..0b19c9cb4 --- /dev/null +++ b/app/src/main/res/drawable/ic_top_source.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_translate.xml b/app/src/main/res/drawable/ic_translate.xml new file mode 100644 index 000000000..ae2a4dde7 --- /dev/null +++ b/app/src/main/res/drawable/ic_translate.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_tune.xml b/app/src/main/res/drawable/ic_tune.xml new file mode 100644 index 000000000..cdc9858e1 --- /dev/null +++ b/app/src/main/res/drawable/ic_tune.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_update.xml b/app/src/main/res/drawable/ic_update.xml new file mode 100644 index 000000000..2490a577d --- /dev/null +++ b/app/src/main/res/drawable/ic_update.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_version.xml b/app/src/main/res/drawable/ic_version.xml new file mode 100644 index 000000000..551bee2c8 --- /dev/null +++ b/app/src/main/res/drawable/ic_version.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_view_quilt.xml b/app/src/main/res/drawable/ic_view_quilt.xml new file mode 100644 index 000000000..a0fb3d300 --- /dev/null +++ b/app/src/main/res/drawable/ic_view_quilt.xml @@ -0,0 +1,26 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_volume_up.xml b/app/src/main/res/drawable/ic_volume_up.xml new file mode 100644 index 000000000..5d604f823 --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_web_outline.xml b/app/src/main/res/drawable/ic_web_outline.xml new file mode 100644 index 000000000..adaeb5644 --- /dev/null +++ b/app/src/main/res/drawable/ic_web_outline.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_web_service_noti.xml b/app/src/main/res/drawable/ic_web_service_noti.xml new file mode 100644 index 000000000..ab93b7528 --- /dev/null +++ b/app/src/main/res/drawable/ic_web_service_noti.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_web_service_phone.xml b/app/src/main/res/drawable/ic_web_service_phone.xml new file mode 100644 index 000000000..f48dd2c8d --- /dev/null +++ b/app/src/main/res/drawable/ic_web_service_phone.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/icon_read_book.png b/app/src/main/res/drawable/icon_read_book.png new file mode 100644 index 000000000..016b8cf16 Binary files /dev/null and b/app/src/main/res/drawable/icon_read_book.png differ diff --git a/app/src/main/res/drawable/image_cover_default.jpg b/app/src/main/res/drawable/image_cover_default.jpg new file mode 100644 index 000000000..6504a4e7e Binary files /dev/null and b/app/src/main/res/drawable/image_cover_default.jpg differ diff --git a/app/src/main/res/drawable/image_cover_gs.jpg b/app/src/main/res/drawable/image_cover_gs.jpg new file mode 100644 index 000000000..e4b21cd73 Binary files /dev/null and b/app/src/main/res/drawable/image_cover_gs.jpg differ diff --git a/app/src/main/res/drawable/image_rss.jpg b/app/src/main/res/drawable/image_rss.jpg new file mode 100644 index 000000000..a152df506 Binary files /dev/null and b/app/src/main/res/drawable/image_rss.jpg differ diff --git a/app/src/main/res/drawable/image_welcome.xml b/app/src/main/res/drawable/image_welcome.xml new file mode 100644 index 000000000..d9d1c13a9 --- /dev/null +++ b/app/src/main/res/drawable/image_welcome.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/recyclerview_item_divider.xml b/app/src/main/res/drawable/recyclerview_item_divider.xml new file mode 100644 index 000000000..cf8d2882a --- /dev/null +++ b/app/src/main/res/drawable/recyclerview_item_divider.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selector_btn_accent_bg.xml b/app/src/main/res/drawable/selector_btn_accent_bg.xml new file mode 100644 index 000000000..a888e77e0 --- /dev/null +++ b/app/src/main/res/drawable/selector_btn_accent_bg.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selector_common_bg.xml b/app/src/main/res/drawable/selector_common_bg.xml new file mode 100644 index 000000000..2174a7333 --- /dev/null +++ b/app/src/main/res/drawable/selector_common_bg.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selector_fillet_btn_bg.xml b/app/src/main/res/drawable/selector_fillet_btn_bg.xml new file mode 100644 index 000000000..431a579d3 --- /dev/null +++ b/app/src/main/res/drawable/selector_fillet_btn_bg.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selector_tv_black.xml b/app/src/main/res/drawable/selector_tv_black.xml new file mode 100644 index 000000000..fbbeed245 --- /dev/null +++ b/app/src/main/res/drawable/selector_tv_black.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_card_view.xml b/app/src/main/res/drawable/shape_card_view.xml new file mode 100644 index 000000000..d49a2d5c1 --- /dev/null +++ b/app/src/main/res/drawable/shape_card_view.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_fillet_btn.xml b/app/src/main/res/drawable/shape_fillet_btn.xml new file mode 100644 index 000000000..10dac7541 --- /dev/null +++ b/app/src/main/res/drawable/shape_fillet_btn.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_fillet_btn_press.xml b/app/src/main/res/drawable/shape_fillet_btn_press.xml new file mode 100644 index 000000000..b569081a4 --- /dev/null +++ b/app/src/main/res/drawable/shape_fillet_btn_press.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_pop_checkaddshelf_bg.xml b/app/src/main/res/drawable/shape_pop_checkaddshelf_bg.xml new file mode 100644 index 000000000..215f52826 --- /dev/null +++ b/app/src/main/res/drawable/shape_pop_checkaddshelf_bg.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_radius_10dp.xml b/app/src/main/res/drawable/shape_radius_10dp.xml new file mode 100644 index 000000000..9e72a3a38 --- /dev/null +++ b/app/src/main/res/drawable/shape_radius_10dp.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_radius_1dp.xml b/app/src/main/res/drawable/shape_radius_1dp.xml new file mode 100644 index 000000000..f032285f7 --- /dev/null +++ b/app/src/main/res/drawable/shape_radius_1dp.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_space_divider.xml b/app/src/main/res/drawable/shape_space_divider.xml new file mode 100644 index 000000000..954360298 --- /dev/null +++ b/app/src/main/res/drawable/shape_space_divider.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_text_cursor.xml b/app/src/main/res/drawable/shape_text_cursor.xml new file mode 100644 index 000000000..71871e0e0 --- /dev/null +++ b/app/src/main/res/drawable/shape_text_cursor.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/side_nav_bar.xml b/app/src/main/res/drawable/side_nav_bar.xml index a33798b60..6d81870b0 100644 --- a/app/src/main/res/drawable/side_nav_bar.xml +++ b/app/src/main/res/drawable/side_nav_bar.xml @@ -1,9 +1,9 @@ + android:shape="rectangle"> + android:angle="135" + android:centerColor="#009688" + android:endColor="#00695C" + android:startColor="#4DB6AC" + android:type="linear" /> \ No newline at end of file diff --git a/app/src/main/res/layout-land/activity_book_info.xml b/app/src/main/res/layout-land/activity_book_info.xml new file mode 100644 index 000000000..a284835eb --- /dev/null +++ b/app/src/main/res/layout-land/activity_book_info.xml @@ -0,0 +1,330 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_about.xml b/app/src/main/res/layout/activity_about.xml index de5ac8e26..65bacf179 100644 --- a/app/src/main/res/layout/activity_about.xml +++ b/app/src/main/res/layout/activity_about.xml @@ -1,9 +1,49 @@ - + + + + + android:layout_height="wrap_content" + android:layout_margin="6dp" + app:cardBackgroundColor="@color/background_card"> + + - + + + + + + + + + diff --git a/app/src/main/res/layout/activity_audio_play.xml b/app/src/main/res/layout/activity_audio_play.xml new file mode 100644 index 000000000..ec6bbd556 --- /dev/null +++ b/app/src/main/res/layout/activity_audio_play.xml @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_book_info.xml b/app/src/main/res/layout/activity_book_info.xml new file mode 100644 index 000000000..9c2887c68 --- /dev/null +++ b/app/src/main/res/layout/activity_book_info.xml @@ -0,0 +1,318 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_book_info_edit.xml b/app/src/main/res/layout/activity_book_info_edit.xml new file mode 100644 index 000000000..12328a069 --- /dev/null +++ b/app/src/main/res/layout/activity_book_info_edit.xml @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_book_read.xml b/app/src/main/res/layout/activity_book_read.xml new file mode 100644 index 000000000..f82b1e06c --- /dev/null +++ b/app/src/main/res/layout/activity_book_read.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_book_search.xml b/app/src/main/res/layout/activity_book_search.xml new file mode 100644 index 000000000..ebb7a3186 --- /dev/null +++ b/app/src/main/res/layout/activity_book_search.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_book_source.xml b/app/src/main/res/layout/activity_book_source.xml new file mode 100644 index 000000000..839d228fb --- /dev/null +++ b/app/src/main/res/layout/activity_book_source.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_book_source_edit.xml b/app/src/main/res/layout/activity_book_source_edit.xml new file mode 100644 index 000000000..4ca5face7 --- /dev/null +++ b/app/src/main/res/layout/activity_book_source_edit.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_chapter_list.xml b/app/src/main/res/layout/activity_chapter_list.xml new file mode 100644 index 000000000..159f77324 --- /dev/null +++ b/app/src/main/res/layout/activity_chapter_list.xml @@ -0,0 +1,19 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_config.xml b/app/src/main/res/layout/activity_config.xml new file mode 100644 index 000000000..f0312ee9c --- /dev/null +++ b/app/src/main/res/layout/activity_config.xml @@ -0,0 +1,21 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_donate.xml b/app/src/main/res/layout/activity_donate.xml new file mode 100644 index 000000000..0956dd71b --- /dev/null +++ b/app/src/main/res/layout/activity_donate.xml @@ -0,0 +1,238 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_explore_show.xml b/app/src/main/res/layout/activity_explore_show.xml new file mode 100644 index 000000000..9bfab0f58 --- /dev/null +++ b/app/src/main/res/layout/activity_explore_show.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 92789a7fb..a124291b5 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,29 +1,27 @@ - - - - - + - + - + + + - - diff --git a/app/src/main/res/layout/activity_qrcode_capture.xml b/app/src/main/res/layout/activity_qrcode_capture.xml new file mode 100644 index 000000000..62337c255 --- /dev/null +++ b/app/src/main/res/layout/activity_qrcode_capture.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_replace_rule.xml b/app/src/main/res/layout/activity_replace_rule.xml index 773e22242..cefeba9d0 100644 --- a/app/src/main/res/layout/activity_replace_rule.xml +++ b/app/src/main/res/layout/activity_replace_rule.xml @@ -1,30 +1,27 @@ - + + android:id="@+id/title_bar" + android:layout_width="match_parent" + android:layout_height="wrap_content" + app:contentLayout="@layout/view_search" + app:layout_constraintTop_toTopOf="parent" + app:title="@string/replace_purify" /> + android:id="@+id/recycler_view" + android:layout_width="match_parent" + android:layout_height="0dp" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toBottomOf="@+id/title_bar" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintVertical_bias="0.0" /> diff --git a/app/src/main/res/layout/activity_rss_artivles.xml b/app/src/main/res/layout/activity_rss_artivles.xml new file mode 100644 index 000000000..39678114e --- /dev/null +++ b/app/src/main/res/layout/activity_rss_artivles.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_rss_read.xml b/app/src/main/res/layout/activity_rss_read.xml new file mode 100644 index 000000000..5d403f5fe --- /dev/null +++ b/app/src/main/res/layout/activity_rss_read.xml @@ -0,0 +1,16 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_rss_source.xml b/app/src/main/res/layout/activity_rss_source.xml new file mode 100644 index 000000000..e3c5bd53e --- /dev/null +++ b/app/src/main/res/layout/activity_rss_source.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_rss_source_edit.xml b/app/src/main/res/layout/activity_rss_source_edit.xml new file mode 100644 index 000000000..2f78c1006 --- /dev/null +++ b/app/src/main/res/layout/activity_rss_source_edit.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_search.xml b/app/src/main/res/layout/activity_search.xml deleted file mode 100644 index 9e2a9e07c..000000000 --- a/app/src/main/res/layout/activity_search.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_source_debug.xml b/app/src/main/res/layout/activity_source_debug.xml new file mode 100644 index 000000000..1b79a6058 --- /dev/null +++ b/app/src/main/res/layout/activity_source_debug.xml @@ -0,0 +1,35 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_welcome.xml b/app/src/main/res/layout/activity_welcome.xml new file mode 100644 index 000000000..e8e320279 --- /dev/null +++ b/app/src/main/res/layout/activity_welcome.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/app_bar_main.xml b/app/src/main/res/layout/app_bar_main.xml deleted file mode 100644 index ae86cf117..000000000 --- a/app/src/main/res/layout/app_bar_main.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/content_main.xml b/app/src/main/res/layout/content_main.xml deleted file mode 100644 index 10ee1e73e..000000000 --- a/app/src/main/res/layout/content_main.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_change_source.xml b/app/src/main/res/layout/dialog_change_source.xml new file mode 100644 index 000000000..21683d69d --- /dev/null +++ b/app/src/main/res/layout/dialog_change_source.xml @@ -0,0 +1,31 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_edit_text.xml b/app/src/main/res/layout/dialog_edit_text.xml new file mode 100644 index 000000000..2cf3da5dd --- /dev/null +++ b/app/src/main/res/layout/dialog_edit_text.xml @@ -0,0 +1,18 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_font_select.xml b/app/src/main/res/layout/dialog_font_select.xml new file mode 100644 index 000000000..08e33c0a9 --- /dev/null +++ b/app/src/main/res/layout/dialog_font_select.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_read_aloud.xml b/app/src/main/res/layout/dialog_read_aloud.xml new file mode 100644 index 000000000..c1816be96 --- /dev/null +++ b/app/src/main/res/layout/dialog_read_aloud.xml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_read_bg_text.xml b/app/src/main/res/layout/dialog_read_bg_text.xml new file mode 100644 index 000000000..e5618b955 --- /dev/null +++ b/app/src/main/res/layout/dialog_read_bg_text.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_read_book_style.xml b/app/src/main/res/layout/dialog_read_book_style.xml new file mode 100644 index 000000000..d246417c6 --- /dev/null +++ b/app/src/main/res/layout/dialog_read_book_style.xml @@ -0,0 +1,418 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_read_padding.xml b/app/src/main/res/layout/dialog_read_padding.xml new file mode 100644 index 000000000..22bb10e50 --- /dev/null +++ b/app/src/main/res/layout/dialog_read_padding.xml @@ -0,0 +1,206 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_recycler_view.xml b/app/src/main/res/layout/dialog_recycler_view.xml new file mode 100644 index 000000000..6ac7d8c01 --- /dev/null +++ b/app/src/main/res/layout/dialog_recycler_view.xml @@ -0,0 +1,17 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_replace_edit.xml b/app/src/main/res/layout/dialog_replace_edit.xml new file mode 100644 index 000000000..4f6a23c6e --- /dev/null +++ b/app/src/main/res/layout/dialog_replace_edit.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_bookmark.xml b/app/src/main/res/layout/fragment_bookmark.xml new file mode 100644 index 000000000..fab36a60b --- /dev/null +++ b/app/src/main/res/layout/fragment_bookmark.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_books.xml b/app/src/main/res/layout/fragment_books.xml new file mode 100644 index 000000000..3eb83f71d --- /dev/null +++ b/app/src/main/res/layout/fragment_books.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_bookshelf.xml b/app/src/main/res/layout/fragment_bookshelf.xml new file mode 100644 index 000000000..aa76e7563 --- /dev/null +++ b/app/src/main/res/layout/fragment_bookshelf.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_chapter_list.xml b/app/src/main/res/layout/fragment_chapter_list.xml new file mode 100644 index 000000000..8964c91c6 --- /dev/null +++ b/app/src/main/res/layout/fragment_chapter_list.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_find_book.xml b/app/src/main/res/layout/fragment_find_book.xml new file mode 100644 index 000000000..8609db403 --- /dev/null +++ b/app/src/main/res/layout/fragment_find_book.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_my_config.xml b/app/src/main/res/layout/fragment_my_config.xml new file mode 100644 index 000000000..6cb3cdf4c --- /dev/null +++ b/app/src/main/res/layout/fragment_my_config.xml @@ -0,0 +1,21 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_rss.xml b/app/src/main/res/layout/fragment_rss.xml new file mode 100644 index 000000000..f1469fbd7 --- /dev/null +++ b/app/src/main/res/layout/fragment_rss.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_1line_text_and_del.xml b/app/src/main/res/layout/item_1line_text_and_del.xml new file mode 100644 index 000000000..1b43e8d27 --- /dev/null +++ b/app/src/main/res/layout/item_1line_text_and_del.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_bg_image.xml b/app/src/main/res/layout/item_bg_image.xml new file mode 100644 index 000000000..57c74b182 --- /dev/null +++ b/app/src/main/res/layout/item_bg_image.xml @@ -0,0 +1,23 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_book_source.xml b/app/src/main/res/layout/item_book_source.xml new file mode 100644 index 000000000..7d9513d98 --- /dev/null +++ b/app/src/main/res/layout/item_book_source.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_bookmark.xml b/app/src/main/res/layout/item_bookmark.xml new file mode 100644 index 000000000..bd0ec0e11 --- /dev/null +++ b/app/src/main/res/layout/item_bookmark.xml @@ -0,0 +1,21 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_bookshelf_list.xml b/app/src/main/res/layout/item_bookshelf_list.xml new file mode 100644 index 000000000..b1b0f45a9 --- /dev/null +++ b/app/src/main/res/layout/item_bookshelf_list.xml @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_bookshelf_list_add.xml b/app/src/main/res/layout/item_bookshelf_list_add.xml new file mode 100644 index 000000000..87c630d65 --- /dev/null +++ b/app/src/main/res/layout/item_bookshelf_list_add.xml @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_change_source.xml b/app/src/main/res/layout/item_change_source.xml new file mode 100644 index 000000000..cc1364b64 --- /dev/null +++ b/app/src/main/res/layout/item_change_source.xml @@ -0,0 +1,40 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_chapter_list.xml b/app/src/main/res/layout/item_chapter_list.xml new file mode 100644 index 000000000..96bd703f0 --- /dev/null +++ b/app/src/main/res/layout/item_chapter_list.xml @@ -0,0 +1,16 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_find_book.xml b/app/src/main/res/layout/item_find_book.xml new file mode 100644 index 000000000..6a2b53f3a --- /dev/null +++ b/app/src/main/res/layout/item_find_book.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_font.xml b/app/src/main/res/layout/item_font.xml new file mode 100644 index 000000000..24c9be27e --- /dev/null +++ b/app/src/main/res/layout/item_font.xml @@ -0,0 +1,27 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_group_manage.xml b/app/src/main/res/layout/item_group_manage.xml new file mode 100644 index 000000000..d7d85630a --- /dev/null +++ b/app/src/main/res/layout/item_group_manage.xml @@ -0,0 +1,31 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_icon_preference.xml b/app/src/main/res/layout/item_icon_preference.xml new file mode 100644 index 000000000..b8231284c --- /dev/null +++ b/app/src/main/res/layout/item_icon_preference.xml @@ -0,0 +1,33 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_log.xml b/app/src/main/res/layout/item_log.xml new file mode 100644 index 000000000..7beaede03 --- /dev/null +++ b/app/src/main/res/layout/item_log.xml @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_relace_rule.xml b/app/src/main/res/layout/item_relace_rule.xml deleted file mode 100644 index 80295a529..000000000 --- a/app/src/main/res/layout/item_relace_rule.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/layout/item_replace_rule.xml b/app/src/main/res/layout/item_replace_rule.xml new file mode 100644 index 000000000..1233922e0 --- /dev/null +++ b/app/src/main/res/layout/item_replace_rule.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_rss.xml b/app/src/main/res/layout/item_rss.xml new file mode 100644 index 000000000..b06e2e37b --- /dev/null +++ b/app/src/main/res/layout/item_rss.xml @@ -0,0 +1,27 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_rss_article.xml b/app/src/main/res/layout/item_rss_article.xml new file mode 100644 index 000000000..293ae0ce3 --- /dev/null +++ b/app/src/main/res/layout/item_rss_article.xml @@ -0,0 +1,48 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_rss_source.xml b/app/src/main/res/layout/item_rss_source.xml new file mode 100644 index 000000000..d0ecd8788 --- /dev/null +++ b/app/src/main/res/layout/item_rss_source.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_search.xml b/app/src/main/res/layout/item_search.xml index 4fe3215d2..940315b98 100644 --- a/app/src/main/res/layout/item_search.xml +++ b/app/src/main/res/layout/item_search.xml @@ -1,19 +1,127 @@ - + + + + + + + + + + + + + + + + + + + + + android:ellipsize="end" + android:lines="1" + android:text="@string/last_read" + android:textColor="@color/tv_text_default" + android:textSize="12sp" /> + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_source_edit.xml b/app/src/main/res/layout/item_source_edit.xml new file mode 100644 index 000000000..078e525d0 --- /dev/null +++ b/app/src/main/res/layout/item_source_edit.xml @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_text.xml b/app/src/main/res/layout/item_text.xml new file mode 100644 index 000000000..87687a713 --- /dev/null +++ b/app/src/main/res/layout/item_text.xml @@ -0,0 +1,17 @@ + + diff --git a/app/src/main/res/layout/nav_header_main.xml b/app/src/main/res/layout/nav_header_main.xml deleted file mode 100644 index 92ca611a4..000000000 --- a/app/src/main/res/layout/nav_header_main.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/layout/popup_keyboard_tool.xml b/app/src/main/res/layout/popup_keyboard_tool.xml new file mode 100644 index 000000000..649534d76 --- /dev/null +++ b/app/src/main/res/layout/popup_keyboard_tool.xml @@ -0,0 +1,8 @@ + + diff --git a/app/src/main/res/layout/view_book_page.xml b/app/src/main/res/layout/view_book_page.xml new file mode 100644 index 000000000..eb27614ad --- /dev/null +++ b/app/src/main/res/layout/view_book_page.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_error.xml b/app/src/main/res/layout/view_error.xml index 844a5a969..8648d04cf 100644 --- a/app/src/main/res/layout/view_error.xml +++ b/app/src/main/res/layout/view_error.xml @@ -8,7 +8,7 @@ + android:layout_height="wrap_content" /> + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_icon.xml b/app/src/main/res/layout/view_icon.xml new file mode 100644 index 000000000..989476275 --- /dev/null +++ b/app/src/main/res/layout/view_icon.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_load_more.xml b/app/src/main/res/layout/view_load_more.xml new file mode 100644 index 000000000..a55372d30 --- /dev/null +++ b/app/src/main/res/layout/view_load_more.xml @@ -0,0 +1,28 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_loading.xml b/app/src/main/res/layout/view_loading.xml index db49f3211..73781d947 100644 --- a/app/src/main/res/layout/view_loading.xml +++ b/app/src/main/res/layout/view_loading.xml @@ -1,19 +1,19 @@ + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:gravity="center_horizontal" + android:orientation="vertical"> + android:id="@+id/loading_progress" + android:layout_width="48dp" + android:layout_height="48dp" + style="@style/Widget.AppCompat.ProgressBar" /> + android:id="@+id/tv_loading_message" + style="@style/Style.Text.Second.Normal.Wrap" + android:paddingTop="16dp" + android:text="@string/dynamic_loading" /> \ No newline at end of file diff --git a/app/src/main/res/layout/view_read_menu.xml b/app/src/main/res/layout/view_read_menu.xml new file mode 100644 index 000000000..7ffd97ee2 --- /dev/null +++ b/app/src/main/res/layout/view_read_menu.xml @@ -0,0 +1,380 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_refresh_recycler.xml b/app/src/main/res/layout/view_refresh_recycler.xml new file mode 100644 index 000000000..2f2685c4e --- /dev/null +++ b/app/src/main/res/layout/view_refresh_recycler.xml @@ -0,0 +1,16 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_titlebar.xml b/app/src/main/res/layout/view_search.xml similarity index 55% rename from app/src/main/res/layout/view_titlebar.xml rename to app/src/main/res/layout/view_search.xml index 82f286582..5fd67be63 100644 --- a/app/src/main/res/layout/view_titlebar.xml +++ b/app/src/main/res/layout/view_search.xml @@ -1,9 +1,10 @@ - + android:imeOptions="actionSearch" + app:defaultQueryHint="搜索"/> \ No newline at end of file diff --git a/app/src/main/res/layout/view_tab_layout.xml b/app/src/main/res/layout/view_tab_layout.xml new file mode 100644 index 000000000..80cfe13e4 --- /dev/null +++ b/app/src/main/res/layout/view_tab_layout.xml @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_title_bar.xml b/app/src/main/res/layout/view_title_bar.xml new file mode 100644 index 000000000..3b7641388 --- /dev/null +++ b/app/src/main/res/layout/view_title_bar.xml @@ -0,0 +1,8 @@ + + diff --git a/app/src/main/res/menu/about.xml b/app/src/main/res/menu/about.xml new file mode 100644 index 000000000..7dd8f1e86 --- /dev/null +++ b/app/src/main/res/menu/about.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/activity_main_drawer.xml b/app/src/main/res/menu/activity_main_drawer.xml deleted file mode 100644 index 3a491985e..000000000 --- a/app/src/main/res/menu/activity_main_drawer.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/menu/book_group_manage.xml b/app/src/main/res/menu/book_group_manage.xml new file mode 100644 index 000000000..4c887905d --- /dev/null +++ b/app/src/main/res/menu/book_group_manage.xml @@ -0,0 +1,27 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/book_info.xml b/app/src/main/res/menu/book_info.xml new file mode 100644 index 000000000..382762455 --- /dev/null +++ b/app/src/main/res/menu/book_info.xml @@ -0,0 +1,17 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/book_info_edit.xml b/app/src/main/res/menu/book_info_edit.xml new file mode 100644 index 000000000..652d0076e --- /dev/null +++ b/app/src/main/res/menu/book_info_edit.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/book_search.xml b/app/src/main/res/menu/book_search.xml new file mode 100644 index 000000000..0a8ce4992 --- /dev/null +++ b/app/src/main/res/menu/book_search.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/menu/book_source.xml b/app/src/main/res/menu/book_source.xml new file mode 100644 index 000000000..7569e2fe9 --- /dev/null +++ b/app/src/main/res/menu/book_source.xml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/bookshelf.xml b/app/src/main/res/menu/bookshelf.xml new file mode 100644 index 000000000..a913d9925 --- /dev/null +++ b/app/src/main/res/menu/bookshelf.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/content_select_action.xml b/app/src/main/res/menu/content_select_action.xml new file mode 100644 index 000000000..d83b82ef3 --- /dev/null +++ b/app/src/main/res/menu/content_select_action.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/group_manage.xml b/app/src/main/res/menu/group_manage.xml new file mode 100644 index 000000000..90784e23e --- /dev/null +++ b/app/src/main/res/menu/group_manage.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/main.xml b/app/src/main/res/menu/main.xml deleted file mode 100644 index d579f6feb..000000000 --- a/app/src/main/res/menu/main.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - diff --git a/app/src/main/res/menu/main_bnv.xml b/app/src/main/res/menu/main_bnv.xml new file mode 100644 index 000000000..fb34daff3 --- /dev/null +++ b/app/src/main/res/menu/main_bnv.xml @@ -0,0 +1,23 @@ + + + + + + + + + diff --git a/app/src/main/res/menu/main_bookshelf.xml b/app/src/main/res/menu/main_bookshelf.xml new file mode 100644 index 000000000..45b586ca8 --- /dev/null +++ b/app/src/main/res/menu/main_bookshelf.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/main_my.xml b/app/src/main/res/menu/main_my.xml new file mode 100644 index 000000000..db1161c28 --- /dev/null +++ b/app/src/main/res/menu/main_my.xml @@ -0,0 +1,25 @@ + + + + + + + + + + diff --git a/app/src/main/res/menu/main_rss.xml b/app/src/main/res/menu/main_rss.xml new file mode 100644 index 000000000..4403621bd --- /dev/null +++ b/app/src/main/res/menu/main_rss.xml @@ -0,0 +1,19 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/qr_code_scan.xml b/app/src/main/res/menu/qr_code_scan.xml new file mode 100644 index 000000000..00bb7ec15 --- /dev/null +++ b/app/src/main/res/menu/qr_code_scan.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/read_book.xml b/app/src/main/res/menu/read_book.xml new file mode 100644 index 000000000..d8ea10f3d --- /dev/null +++ b/app/src/main/res/menu/read_book.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/replace_edit.xml b/app/src/main/res/menu/replace_edit.xml new file mode 100644 index 000000000..652d0076e --- /dev/null +++ b/app/src/main/res/menu/replace_edit.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/replace_rule.xml b/app/src/main/res/menu/replace_rule.xml new file mode 100644 index 000000000..1efeaa4f7 --- /dev/null +++ b/app/src/main/res/menu/replace_rule.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/rss_articles.xml b/app/src/main/res/menu/rss_articles.xml new file mode 100644 index 000000000..03d187bcd --- /dev/null +++ b/app/src/main/res/menu/rss_articles.xml @@ -0,0 +1,15 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/rss_read.xml b/app/src/main/res/menu/rss_read.xml new file mode 100644 index 000000000..29f3087ba --- /dev/null +++ b/app/src/main/res/menu/rss_read.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/rss_source.xml b/app/src/main/res/menu/rss_source.xml new file mode 100644 index 000000000..f46fd512c --- /dev/null +++ b/app/src/main/res/menu/rss_source.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/search_view.xml b/app/src/main/res/menu/search_view.xml new file mode 100644 index 000000000..0b897362d --- /dev/null +++ b/app/src/main/res/menu/search_view.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/source_debug.xml b/app/src/main/res/menu/source_debug.xml new file mode 100644 index 000000000..5b8b467f2 --- /dev/null +++ b/app/src/main/res/menu/source_debug.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/source_edit.xml b/app/src/main/res/menu/source_edit.xml new file mode 100644 index 000000000..ef909ad3b --- /dev/null +++ b/app/src/main/res/menu/source_edit.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index bbd3e0212..c9ad5f98f 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,5 +1,5 @@ - - + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index bbd3e0212..c9ad5f98f 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,5 +1,5 @@ - - + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png index 898f3ed59..80723f59b 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..3395b0f61 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png index dffca3601..9e96002d2 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png index 64ba76f75..40accb5f5 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..2acbeea1c Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png index dae5e0823..e27af4a03 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png index e5ed46597..afdcc42eb 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..bbcfb61a3 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png index 14ed0af35..9d6a0e1ac 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index b0907cac3..3e9fdc29a 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..9a126a90a Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index d8ae03154..eac246932 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 2c18de9e6..a51e958cf 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..ba2513cc6 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index beed3cdd2..6fe033b04 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/raw/silent_sound.mp3 b/app/src/main/res/raw/silent_sound.mp3 new file mode 100644 index 000000000..48755f29c Binary files /dev/null and b/app/src/main/res/raw/silent_sound.mp3 differ diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 000000000..f9c4c4f7c --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,34 @@ + + + @color/md_blue_grey_600 + @color/md_blue_grey_700 + @color/md_deep_orange_800 + + @color/md_grey_800 + #353535 + #282828 + + #69000000 + + #30ffffff + + #363636 + #804D4D4D + #80686868 + #80C7C7C7 + #66666666 + + #737373 + #565656 + + #EEEEEE + #D5D5D5 + #B3B3B3 + #b7b7b7 + + + #303030 + + + #222222 + diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml new file mode 100644 index 000000000..c6d88fbc5 --- /dev/null +++ b/app/src/main/res/values-night/styles.xml @@ -0,0 +1,19 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-v21/styles.xml b/app/src/main/res/values-v21/styles.xml deleted file mode 100644 index e54680451..000000000 --- a/app/src/main/res/values-v21/styles.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml new file mode 100644 index 000000000..78554f9eb --- /dev/null +++ b/app/src/main/res/values-zh/strings.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml new file mode 100644 index 000000000..323b10384 --- /dev/null +++ b/app/src/main/res/values/arrays.xml @@ -0,0 +1,112 @@ + + + + 普通女声 + 普通男声 + 度逍遥 + 百度丫丫 + 百度骚男 + 百度暖女 + 百度评书 + 百度主持 + + + + 0 + 1 + 3 + 4 + 11 + 5 + 6 + 9 + + + + @string/layout_list + @string/layout_grid3 + @string/layout_grid4 + + + + @string/indent_0 + @string/indent_1 + @string/indent_2 + @string/indent_3 + @string/indent_4 + + + + .txt + .json + .xml + + + + @string/jf_convert_o + @string/jf_convert_j + @string/jf_convert_f + + + + 自动 + 黑色 + 白色 + 跟随背景 + + + + 默认 + 1分钟 + 2分钟 + 3分钟 + 常亮 + + + + 0 + 60 + 120 + 180 + -1 + + + + @string/screen_unspecified + @string/screen_portrait + @string/screen_landscape + @string/screen_sensor + + + + @string/bookshelf_px_0 + @string/bookshelf_px_1 + @string/bookshelf_px_2 + + + + 0 + 1 + 2 + + + + + + + + + + + + + icon1 + icon2 + + + + @string/icon_main + @string/icon_book + + + \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index fc861ecca..f121f58ae 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -1,48 +1,137 @@ - + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 8194c2ece..59b1bea6c 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,10 +1,104 @@ - #008577 - #00574B - #D81B60 - #222222 + @color/md_light_blue_600 + @color/md_light_blue_700 + @color/md_pink_800 #66666666 + #eb4333 + #439b53 + #00000000 + + @color/md_grey_100 + #dedede + #fcfcfc + #00000000 + + #30000000 + + #d3321b + + #80ACACAC + #80858585 + #802C2C2C + + #737373 + #adadad + + #0E0E0E + #2C2C2C + #8A2C2C2C + #dfdfdf + #383838 + + + #efefef + + #23000000 + #EEEEEE + #aaaaaaaa + + + #8fe0e0e0 + + #19000000 + #f4f4f4 + + + #99343434 + #000000 + #ffffff + + + #ffffffff + #efe0e0e0 + + + + + + #1F000000 + #1F000000 + + #43000000 + #43000000 + #43000000 + + #61000000 + + #8A000000 + #8A000000 + #8A000000 + + #DE000000 + + #FFFAFAFA + + #FFBDBDBD + + #E8E8E8 + + + + #1AFFFFFF + + #1F000000 + + #4DFFFFFF + #4DFFFFFF + #4DFFFFFF + #4DFFFFFF + + #B3FFFFFF + #B3FFFFFF + #B3FFFFFF + + #FFFFFFFF + + #FFBDBDBD + + #FF424242 + + #202020 diff --git a/app/src/main/res/values/colors_material_design.xml b/app/src/main/res/values/colors_material_design.xml new file mode 100644 index 000000000..a00ddac9e --- /dev/null +++ b/app/src/main/res/values/colors_material_design.xml @@ -0,0 +1,338 @@ + + + + + + + @color/md_grey_50 + + #1F000000 + + #61000000 + + #8A000000 + #8A000000 + + #DE000000 + + @color/md_grey_300 + @color/md_grey_100 + @color/md_white_1000 + @color/md_white_1000 + + + @color/md_grey_850 + + #1FFFFFFF + + #4DFFFFFF + + #B3FFFFFF + #B3FFFFFF + + #FFFFFFFF + + @color/md_black_1000 + @color/md_grey_900 + @color/md_grey_800 + @color/md_grey_800 + + + #FFEBEE + #FFCDD2 + #EF9A9A + #E57373 + #EF5350 + #F44336 + #E53935 + #D32F2F + #C62828 + #B71C1C + #FF8A80 + #FF5252 + #FF1744 + #D50000 + + + #FCE4EC + #F8BBD0 + #F48FB1 + #F06292 + #EC407A + #E91E63 + #D81B60 + #C2185B + #AD1457 + #880E4F + #FF80AB + #FF4081 + #F50057 + #C51162 + + + #F3E5F5 + #E1BEE7 + #CE93D8 + #BA68C8 + #AB47BC + #9C27B0 + #8E24AA + #7B1FA2 + #6A1B9A + #4A148C + #EA80FC + #E040FB + #D500F9 + #AA00FF + + + #EDE7F6 + #D1C4E9 + #B39DDB + #9575CD + #7E57C2 + #673AB7 + #5E35B1 + #512DA8 + #4527A0 + #311B92 + #B388FF + #7C4DFF + #651FFF + #6200EA + + + #E8EAF6 + #C5CAE9 + #9FA8DA + #7986CB + #5C6BC0 + #3F51B5 + #3949AB + #303F9F + #283593 + #1A237E + #8C9EFF + #536DFE + #3D5AFE + #304FFE + + + #E3F2FD + #BBDEFB + #90CAF9 + #64B5F6 + #42A5F5 + #2196F3 + #1E88E5 + #1976D2 + #1565C0 + #0D47A1 + #82B1FF + #448AFF + #2979FF + #2962FF + + + #E1F5FE + #B3E5FC + #81D4FA + #4FC3F7 + #29B6F6 + #03A9F4 + #039BE5 + #0288D1 + #0277BD + #01579B + #80D8FF + #40C4FF + #00B0FF + #0091EA + + + #E0F7FA + #B2EBF2 + #80DEEA + #4DD0E1 + #26C6DA + #00BCD4 + #00ACC1 + #0097A7 + #00838F + #006064 + #84FFFF + #18FFFF + #00E5FF + #00B8D4 + + + #E0F2F1 + #B2DFDB + #80CBC4 + #4DB6AC + #26A69A + #009688 + #00897B + #00796B + #00695C + #004D40 + #A7FFEB + #64FFDA + #1DE9B6 + #00BFA5 + + + #E8F5E9 + #C8E6C9 + #A5D6A7 + #81C784 + #66BB6A + #4CAF50 + #43A047 + #388E3C + #2E7D32 + #1B5E20 + #B9F6CA + #69F0AE + #00E676 + #00C853 + + + #F1F8E9 + #DCEDC8 + #C5E1A5 + #AED581 + #9CCC65 + #8BC34A + #7CB342 + #689F38 + #558B2F + #33691E + #CCFF90 + #B2FF59 + #76FF03 + #64DD17 + + + #F9FBE7 + #F0F4C3 + #E6EE9C + #DCE775 + #D4E157 + #CDDC39 + #C0CA33 + #AFB42B + #9E9D24 + #827717 + #F4FF81 + #EEFF41 + #C6FF00 + #AEEA00 + + + #FFFDE7 + #FFF9C4 + #FFF59D + #FFF176 + #FFEE58 + #FFEB3B + #FDD835 + #FBC02D + #F9A825 + #F57F17 + #FFFF8D + #FFFF00 + #FFEA00 + #FFD600 + + + #FFF8E1 + #FFECB3 + #FFE082 + #FFD54F + #FFCA28 + #FFC107 + #FFB300 + #FFA000 + #FF8F00 + #FF6F00 + #FFE57F + #FFD740 + #FFC400 + #FFAB00 + + + #FFF3E0 + #FFE0B2 + #FFCC80 + #FFB74D + #FFA726 + #FF9800 + #FB8C00 + #F57C00 + #EF6C00 + #E65100 + #FFD180 + #FFAB40 + #FF9100 + #FF6D00 + + + #FBE9E7 + #FFCCBC + #FFAB91 + #FF8A65 + #FF7043 + #FF5722 + #F4511E + #E64A19 + #D84315 + #BF360C + #FF9E80 + #FF6E40 + #FF3D00 + #DD2C00 + + + #EFEBE9 + #D7CCC8 + #BCAAA4 + #A1887F + #8D6E63 + #795548 + #6D4C41 + #5D4037 + #4E342E + #3E2723 + + + #FAFAFA + #F5F5F5 + #EEEEEE + #E0E0E0 + #BDBDBD + #9E9E9E + #757575 + #616161 + #424242 + #303030 + #212121 + + + #ECEFF1 + #CFD8DC + #B0BEC5 + #90A4AE + #78909C + #607D8B + #546E7A + #455A64 + #37474F + #263238 + + + #000000 + #FFFFFF + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 68b3b25a3..9f8946337 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -10,4 +10,29 @@ 14sp 16sp 18sp + + 24dp + + 0.8dp + 10dp + + 18sp + + 4dp + + 44dp + 88dp + 48sp + 16dp + + 40dp + 8dp + 0dp + + 2dp + + 8dp + 8dp + 8dp + 8dp \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 000000000..08f52ee5c --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #EC5436 + \ No newline at end of file diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml new file mode 100644 index 000000000..016946f36 --- /dev/null +++ b/app/src/main/res/values/ids.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/pref_key_value.xml b/app/src/main/res/values/pref_key_value.xml new file mode 100644 index 000000000..091d276ad --- /dev/null +++ b/app/src/main/res/values/pref_key_value.xml @@ -0,0 +1,27 @@ + + + auto_refresh + list_screen_direction + full_screen + threads_num + user_agent + bookshelf_px + read_type + expandGroupFind + defaultToRead + autoDownload + downloadPath + checkUpdate + + ic_launcher_round + book_launcher_round + + Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.2357.134 Safari/537.36 + + https://gedoor.github.io/MyBookshelf/sourcerule.html + https://github.com/gedoor/legado + https://gedoor.github.io/MyBookshelf/disclaimer.html + https://gedoor.github.io/MyBookshelf/ + https://github.com/gedoor/MyBookshelf/releases/latest + https://api.github.com/repos/gedoor/MyBookshelf/releases/latest + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 05315f8a9..47d72c6a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,16 +8,531 @@ Settings Home - 导入 + 恢复 导入阅读数据 导入Github数据 净化替换 Send + 提示 + 取消 + 确定 + 去设置 + + 无法跳转至设置界面 + 阅读需要访问存储卡权限,请前往“设置”—“应用权限”—打开所需权限 + 点击重试 正在加载 - 阅读需要访问存储卡权限: + 编辑 删除 + 替换 + 替换净化 + 暂无 + 启用 + 替换净化-搜索 + 书架 + 收藏 + 已收藏 + 未收藏 + 订阅 + 全部 + 最近阅读 + 最后阅读 + 让阅读成为一种习惯。 + 更新日志 + 阅读·搜索 + 书架还空着,先去添加吧! + 搜索 + 下载任务 + 列表视图 + 网格视图三列 + 网格视图四列 + 书架布局 + 书城 + 添加本地 + 书源 + 书源管理 + 设置 + 主题设置 + 关于 + 捐赠 + 退出 + 还未保存,是否继续编辑 + 阅读样式设置 + 版本 + 本地 + 搜索 + 来源: %s + 最近: %s + 书名 + 最新: %s + 是否将《%s》放入书架? + 共%s个Text文件 + 加载中… + 重试 + web服务 + web编辑书源 + http://%s:%d + 离线下载 + 离线下载 + 下载选择的章节到本地 + 换源 + + \u3000\u3000这是一款开源的阅读软件,你可以fork我们的代码自己编译APK。欢迎提交代码帮助改善应用。\n\u3000\u3000公众号[开源阅读软件]! + + Version %s + 自动刷新 + 打开软件时自动更新书籍 + 自动下载最新章节 + 更新书籍时自动下载最新章节 + 备份 + 恢复 + 备份请给与存储权限 + 恢复请给与存储权限 + 确认 + 取消 + 确认备份吗? + 新备份会替换原有备份。\n备份文件夹YueDu + 确认恢复吗? + 恢复书架会覆盖现有书架。 + 备份成功 + 备份失败 + 正在恢复 + 恢复成功 + 恢复失败 + 屏幕方向 + 跟随传感器 + 横向 + 竖向 + 跟随系统 + 免责声明 + 共%d章 + 界面 + 亮度 + 目录 + 下一章 + 上一章 + 隐藏状态栏 + 阅读界面隐藏状态栏 + 阅读行数调整 + 阅读行数减一行,如阅读界面显示不全可启用 + 朗读 + 正在朗读 + 点击打开阅读界面 + 返回 + 刷新 + 开始 + 停止 + 暂停 + 继续 + 定时 + 朗读暂停 + 正在朗读(还剩%d分钟) + 阅读界面隐藏虚拟按键 + 隐藏导航栏 + 导航栏颜色 + GitHub + 评分 + 发送邮件 + 无法打开 + 无章节 + 添加网址 + 添加书籍网址 + 背景 + 作者 + 作者:%s + 朗读停止 + 清除缓存 + 保存 + 编辑源 + 编辑书源 + 禁用书源 + 新建书源 + 新建订阅源 + 添加书籍 + 扫描 + 拷贝源 + 粘贴源 + 源规则说明 + 检查更新 + 扫描二维码 + 扫描本地图片 + 规则说明 + 分享 + 软件分享 + 跟随系统 + 添加 + 导入书源 + 本地导入 + 网络导入 + 替换净化 + 替换规则编辑 + 替换规则 + 替换为 + 封面 + + 音量键翻页 + 点击翻页 + 点击总是翻下一页 + 翻页动画 + 屏幕超时 + 返回 + 菜单 + 调节 + 滚动条 + 清除缓存会删除所有已保存章节,是否确认删除? + 书源共享 + 替换规则名称 + 选择操作 + 全选 + 夜间模式 + 启动页 + 开始下载 + 取消下载 + 暂无任务 + 导入选择书籍 + 更新和搜索线程数,如感觉卡顿请减小线程数,量力而行 + 切换图标 + 删除书籍 + 开始阅读 + 加载数据中… + 加载失败,点击重试 + 内容简介 + 简介:%s + 打开外部书籍 + 来源:%s + 本地导入 + 网络导入 + 书架排序 + 检查更新间隔 + 按阅读时间排序 + 按更新时间排序 + 手动排序 + 阅读方式 + 删除所选 + 是否确认删除? + 默认字体 + 发现 + 发现管理 + 没有内容,去书源里自定义吧! + 删除所有 + 搜索历史 + 清除 + 正文显示标题 + 书源同步 + 无最新章节信息 + 显示时间和电量 + 显示分隔线 + 深色状态栏图标 + 内容 + 拷贝内容 + 一键缓存 + 这是一段测试文字\n\u3000\u3000只是让你看看效果的 + 文字颜色和背景(长按自定义) + 沉浸式状态栏 + 还剩%d章未下载 + 长按输入颜色值 + 加载中… + 追更区 + 养肥区 + 书签 + 添加书签 + 删除 + 加载超时 + 关注:%s + 已拷贝 + 整理书架 + 这将会删除所有书籍,请谨慎操作。 + 搜索书源 + 搜索订阅源 + 搜索(共%d个书源) + 目录(%d) + 加粗 + 字体 + 文字 + 软件主页 + + + + + 边距 + 上边距 + 下边距 + 左边距 + 右边距 + 校验书源 + 校验所选 + 进度 %d/%d + 请安装并选择中文TTS! + TTS初始化失败! + 简繁转换 + 关闭 + 简转繁 + 繁转简 + 翻页模式 + %1$d 项 + 存储卡: + 加入书架 + 加入书架(%1$d) + 成功添加%1$d本书 + 请将字体文件放到SD根目录Fonts文件夹下重新选择 + 默认字体 + 选择字体 + 字号 + 行距 + 段距 + 置顶 + 自动展开发现 + 默认展开第一组发现 + 当前线程数 %s + 朗读语速 + 自动翻页 + 停止自动翻页 + 自动翻页间隔 + 书籍信息 + 书籍信息编辑 + 默认打开书架 + 自动跳转最近阅读 + 替换范围,选填书名或者源名 + 分组 + 内容缓存路径 + 系统文件选择器 + 新版本 + 下载更新 + 朗读时音量键翻页 + Tip边距跟随边距调整 + 禁止更新 + 允许更新 + 反转选择 + 搜索书名、作者 + 书名、作者、URL + 常见问题 + 显示所有发现 + 关闭则只显示勾选源的发现 + 更新目录 + txt目录正则 + 设置编码 + 倒序-顺序 + 排序 + 智能排序 + 手动排序 + 拼音排序 + 滚动到顶部 + 滚动到底部 + 已读: %s + 追更 + 养肥 + 完结 + 所有书籍 + 追更书籍 + 养肥书籍 + 完结书籍 + 本地书籍 + 状态栏颜色透明 + 导航栏变色 + 导航栏根据夜间模式变化 + 放入书架 + 继续阅读 + 封面地址 + 覆盖 + 滑动 + 仿真 + 滚动 + 无动画 + 此书源使用了高级功能,请到捐赠里点击支付宝红包搜索码领取红包开启。 + 后台更新换源最新章节 + 开启则会在软件打开1分钟后开始更新 + 书架ToolBar自动隐藏 + 滚动书架时ToolBar自动隐藏与显示 + 登录 + 登录%s + 成功 + 当前源没有配置登陆地址 + + + 书源名称(bookSourceName) + 书源URL(bookSourceUrl) + 书源分组(bookSourceGroup) + 登录URL(loginUrl) + 搜索地址(url) + 发现地址规则(url) + 书籍列表规则(bookList) + 书名规则(name) + 书籍url规则(bookUrl) + 作者规则(author) + 分类规则(kind) + 简介规则(intro) + 封面规则(coverUrl) + 最新章节规则(lastChapter) + 字数规则(wordCount) + 书籍URL正则(bookUrlPattern) + 预处理规则(bookInfoInit) + 目录URL规则(tocUrl) + 目录下一页规则(nextTocUrl) + 目录列表规则(chapterList) + 章节名称规则(ruleChapterName) + 章节URL规则(chapterUrl) + 正文规则(content) + 正文下一页URL规则(nextContentUrl) + + + + 名称(sourceName) + url(sourceUrl) + 图标(sourceIcon) + 分组(sourceGroup) + 列表规则(ruleArticles) + 标题规则(ruleTitle) + 作者规则(ruleAuthor) + guid规则(ruleGuid) + 时间规则(rulePubDate) + 类别规则(ruleCategories) + 描述规则(ruleDescription) + 图片url规则(ruleImage) + 内容规则(ruleContent) + 链接规则(ruleLink) + + + + 没有书源 + 书籍信息获取失败 + 内容获取失败 + 目录获取失败 + 访问网站失败:%s + 文件读取失败 + 加载目录失败 + 获取数据失败! + 加载失败\n%s + 没有网络 + 网络连接超时 + 数据解析失败 + + + header + 调试源 + 二维码导入 + 扫描二维码 + 选中时点击可弹出菜单 + 主题 + 默认主题 + 恢复主题为默认配色 + 加入QQ群 + 获取背景图片需存储权限 + 输入书源网址 + 删除文件 + 删除文件成功 + 确定删除文件吗? + 手机目录 + 智能导入 + 发现 + 切换显示样式 + 导入本地书籍需存储权限 + 点击可切换到白天模式 + 点击可切换到夜间模式 + 本软件需要存储权限来存储备份书籍信息 + 再按一次退出程序 + 导入本地书籍需存储权限 + 网络连接不可用 + + + 是否删除全部书籍? + 是否同时删除已下载的书籍目录? + 扫描二维码需相机权限 + 朗读正在运行,不能自动翻页 + 输入编码 + TXT目录规则 + 打开外部书籍需获取存储权限 + 未获取到书名 + 输入替换规则网址 + 搜索列表获取成功%d + 书源名称和URL不能为空 + 图库 + 领支付宝红包 + 没有获取到更新地址 + 正在打开首页,成功自动返回主界面 + 登录成功后请点击右上角图标进行首页访问测试 + + 章节: + + 使用正则表达式 + 缩进 + 无缩进 + 一字符缩进 + 二字符缩进 + 三字符缩进 + 四字符缩进 + 选择SD卡 + 没有发现,可以在书源里添加。 + 恢复默认 + 自定义缓存路径需要存储权限 + 黑色 + 文章内容为空 + 正在换源请等待… + 目录列表为空 + 正文边距 + Tip边距 + 字距 + + 基本 + 搜索 + 发现 + 详情 + 目录 + 内容 + + E-Ink 模式 + 去除动画,优化电纸书使用体验 + Web服务 + web端口 + 当前端口 %s + 二维码分享 + wifi分享 + 请给于存储权限 + 上一个 + 下一个 + 音乐 + 音频 + 启用 + 启用JS + 加载BaseUrl + 全部书源 + 输入不能为空 + 清空发现缓存 + 编辑发现 + 切换软件显示在桌面的图标 + 帮助 + 我的 + + ]]> + 阅读 + %d%% + %d分钟 + 自动亮度%s + 按页朗读 + 在线朗读 + 背景图片 + 背景颜色 + 文字颜色 + 选择图片 + 分组管理 + 编辑分组 + 添加分组 + 新建替换 + 分组 + 启用所选 + 禁用所选 + TTS + 输入你的WebDav授权密码 + 输入你的服务器地址 + 输入你的WebDav账号 + 订阅源 + 编辑订阅源 + 筛选 + 筛选发现 + 当前位置: + 精准搜索 + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 09ffdee29..ad6aa5784 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,19 +1,105 @@ - + + + //**************************************************************Theme******************************************************************************// + + + + + + + + + + + + - - - + + + + + + + + + + + + + + + //**************************************************************Widget + Style******************************************************************************// + + + + + + +