This commit is contained in:
HapeLee committed 2025-06-04 13:33:51 +08:00
1 parent b86d90021c
commit 6c0757c91b
972 files changed
+6885 -7321

No files matched your search

@@ -0,0 +1,7 @@
# 放置一些copy过来的库
* dialogs 弹出框
* icu4j 编码识别库
* permission 权限申请库
* theme 主题
* webDav 网络存储
@@ -0,0 +1,10 @@
package io.legato.kazusa.lib.aliyun
object ALiYun {
fun getToken() {
}
}
@@ -0,0 +1,491 @@
package io.legato.kazusa.lib.cronet
import androidx.annotation.Keep
import io.legato.kazusa.help.coroutine.Coroutine
import io.legato.kazusa.help.http.CookieManager
import io.legato.kazusa.help.http.CookieManager.cookieJarHeader
import io.legato.kazusa.help.http.okHttpClient
import io.legato.kazusa.utils.DebugLog
import io.legato.kazusa.utils.asIOException
import io.legato.kazusa.utils.splitNotBlank
import kotlinx.coroutines.delay
import okhttp3.*
import okhttp3.EventListener
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.ResponseBody.Companion.asResponseBody
import okhttp3.internal.http.HttpMethod
import okhttp3.internal.http.StatusLine
import okio.Buffer
import okio.Source
import okio.Timeout
import okio.buffer
import org.chromium.net.CronetException
import org.chromium.net.UrlRequest
import org.chromium.net.UrlResponseInfo
import java.io.IOException
import java.net.ProtocolException
import java.nio.ByteBuffer
import java.util.*
import java.util.concurrent.ArrayBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
@Keep
abstract class AbsCallBack(
var originalRequest: Request,
val mCall: Call,
var readTimeoutMillis: Int,
private val eventListener: EventListener? = null,
private val responseCallback: Callback? = null
) : UrlRequest.Callback() {
var mResponse: Response
private var followCount = 0
private var request: UrlRequest? = null
private var finished = AtomicBoolean(false)
private val canceled = AtomicBoolean(false)
private val callbackResults = ArrayBlockingQueue<CallbackResult>(2)
private val urlResponseInfoChain = arrayListOf<UrlResponseInfo>()
private var cancelJob: Coroutine<*>? = null
private var followRedirect = false
private var enableCookieJar = false
private var redirectRequest: Request? = null
init {
if (readTimeoutMillis == 0) {
readTimeoutMillis = Int.MAX_VALUE
}
if (originalRequest.header(cookieJarHeader) != null) {
enableCookieJar = true
originalRequest = originalRequest.newBuilder()
.removeHeader(cookieJarHeader).build()
}
}
@Throws(IOException::class)
abstract fun waitForDone(urlRequest: UrlRequest): Response
/**
* 当发生错误时,通知子类终止阻塞抛出错误
* @param error
*/
abstract fun onError(error: IOException)
/**
* 请求成功后,通知子类结束阻塞,返回response
* @param response
*/
abstract fun onSuccess(response: Response)
override fun onRedirectReceived(
request: UrlRequest,
info: UrlResponseInfo,
newLocationUrl: String
) {
if (followCount > MAX_FOLLOW_COUNT) {
request.cancel()
onError(IOException("Too many redirect"))
return
}
if (mCall.isCanceled()) {
onError(IOException("Cronet Request Canceled"))
request.cancel()
return
}
followCount += 1
urlResponseInfoChain.add(info)
val client = okHttpClient
if (originalRequest.url.isHttps && newLocationUrl.startsWith("http://") && client.followSslRedirects) {
followRedirect = true
} else if (!originalRequest.url.isHttps && newLocationUrl.startsWith("https://") && client.followSslRedirects) {
followRedirect = true
} else if (okHttpClient.followRedirects) {
followRedirect = true
}
if (!followRedirect) {
onError(IOException("Too many redirect"))
} else {
val response = toResponse(originalRequest, info, urlResponseInfoChain)
if (enableCookieJar) {
CookieManager.saveResponse(response)
}
redirectRequest = buildRedirectRequest(response, originalRequest.method, newLocationUrl)
}
request.cancel()
}
override fun onResponseStarted(request: UrlRequest, info: UrlResponseInfo) {
this.request = request
val response: Response
try {
response = toResponse(originalRequest, info, urlResponseInfoChain, CronetBodySource())
} catch (e: IOException) {
request.cancel()
cancelJob?.cancel()
onError(e)
return
}
if (enableCookieJar) {
CookieManager.saveResponse(response)
}
mResponse = response
onSuccess(response)
//打印协议,用于调试
val msg = "onResponseStarted[${info.negotiatedProtocol}][${info.httpStatusCode}]${info.url}"
DebugLog.i(javaClass.simpleName, msg)
if (eventListener != null) {
eventListener.responseHeadersEnd(mCall, response)
eventListener.responseBodyStart(mCall)
}
try {
responseCallback?.onResponse(mCall, response)
} catch (e: IOException) {
// Pass?
}
}
@Throws(IOException::class)
override fun onReadCompleted(
request: UrlRequest,
info: UrlResponseInfo,
byteBuffer: ByteBuffer
) {
callbackResults.add(CallbackResult(CallbackStep.ON_READ_COMPLETED, byteBuffer))
}
override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) {
callbackResults.add(CallbackResult(CallbackStep.ON_SUCCESS))
cancelJob?.cancel()
eventListener?.responseBodyEnd(mCall, info.receivedByteCount)
//DebugLog.i(javaClass.simpleName, "end[${info.negotiatedProtocol}]${info.url}")
eventListener?.callEnd(mCall)
}
//UrlResponseInfo可能为null
override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: CronetException) {
callbackResults.add(CallbackResult(CallbackStep.ON_FAILED, null, error))
cancelJob?.cancel()
DebugLog.e(javaClass.name, error.message.toString())
onError(error.asIOException())
eventListener?.callFailed(mCall, error)
responseCallback?.onFailure(mCall, error)
}
override fun onCanceled(request: UrlRequest?, info: UrlResponseInfo?) {
if (followRedirect) {
followRedirect = false
if (enableCookieJar) {
val newRequest = CookieManager.loadRequest(redirectRequest!!)
buildRequest(newRequest, this)?.start()
} else {
buildRequest(redirectRequest!!, this)?.start()
}
return
}
canceled.set(true)
callbackResults.add(CallbackResult(CallbackStep.ON_CANCELED))
cancelJob?.cancel()
//DebugLog.i(javaClass.simpleName, "cancel[${info?.negotiatedProtocol}]${info?.url}")
eventListener?.callEnd(mCall)
onError(IOException("Cronet Request Canceled"))
}
fun startCheckCancelJob(request: UrlRequest) {
cancelJob = Coroutine.async {
while (!mCall.isCanceled()) {
delay(1000)
}
request.cancel()
}
}
init {
mResponse = Response.Builder()
.sentRequestAtMillis(System.currentTimeMillis())
.request(originalRequest)
.protocol(Protocol.HTTP_1_0)
.code(0)
.message("")
.build()
}
companion object {
const val MAX_FOLLOW_COUNT = 20
private val encodingsHandledByCronet = setOf("br", "deflate", "gzip", "x-gzip")
private fun protocolFromNegotiatedProtocol(responseInfo: UrlResponseInfo): Protocol {
val negotiatedProtocol = responseInfo.negotiatedProtocol.lowercase(Locale.getDefault())
return when {
negotiatedProtocol.contains("h3") -> {
Protocol.QUIC
}
negotiatedProtocol.contains("quic") -> {
Protocol.QUIC
}
negotiatedProtocol.contains("spdy") -> {
@Suppress("DEPRECATION")
Protocol.SPDY_3
}
negotiatedProtocol.contains("h2") -> {
Protocol.HTTP_2
}
negotiatedProtocol.contains("1.1") -> {
Protocol.HTTP_1_1
}
else -> {
Protocol.HTTP_1_0
}
}
}
private fun headersFromResponse(
responseInfo: UrlResponseInfo,
keepEncodingAffectedHeaders: Boolean
): Headers {
val headers = responseInfo.allHeadersAsList
return Headers.Builder().apply {
for ((key, value) in headers) {
try {
if (!keepEncodingAffectedHeaders
&& (key.equals("content-encoding", ignoreCase = true)
|| key.equals("Content-Length", ignoreCase = true))
) {
// Strip all content encoding headers as decoding is done handled by cronet
continue
}
add(key, value)
} catch (e: Exception) {
DebugLog.w(javaClass.name, "Invalid HTTP header/value: $key$value")
// Ignore that header
}
}
}.build()
}
@Throws(IOException::class)
private fun createResponse(
request: Request,
responseInfo: UrlResponseInfo,
bodySource: Source? = null
): Response.Builder {
val protocol = protocolFromNegotiatedProtocol(responseInfo)
val contentEncodingHeaders =
responseInfo.allHeaders.getOrDefault("content-encoding", emptyList())
val contentEncodingItems = contentEncodingHeaders.flatMap {
it.splitNotBlank(",").toList()
}
val keepEncodingAffectedHeaders = contentEncodingItems.isEmpty()
|| !encodingsHandledByCronet.containsAll(contentEncodingItems)
val headers = headersFromResponse(responseInfo, keepEncodingAffectedHeaders)
val contentLength = if (keepEncodingAffectedHeaders) {
responseInfo.allHeaders["Content-Length"]?.lastOrNull()
} else null
val contentType = responseInfo.allHeaders["content-type"]?.lastOrNull()
?: "text/plain; charset=\"utf-8\""
val responseBody = bodySource?.let {
createResponseBody(
request,
responseInfo.httpStatusCode,
contentType,
contentLength,
bodySource
)
}
return Response.Builder()
.request(request)
.receivedResponseAtMillis(System.currentTimeMillis())
.protocol(protocol)
.code(responseInfo.httpStatusCode)
.message(responseInfo.httpStatusText)
.headers(headers)
.body(responseBody)
}
private fun buildPriorResponse(
request: Request,
redirectResponseInfos: List<UrlResponseInfo>,
): Response? {
var priorResponse: Response? = null
if (redirectResponseInfos.isNotEmpty()) {
for (i in redirectResponseInfos.indices) {
val url = redirectResponseInfos[i].url
val redirectedRequest = request.newBuilder().url(url).build()
priorResponse = createResponse(redirectedRequest, redirectResponseInfos[i])
.priorResponse(priorResponse)
.build()
}
}
return priorResponse
}
@Throws(IOException::class)
private fun createResponseBody(
request: Request,
httpStatusCode: Int,
contentType: String?,
contentLengthString: String?,
bodySource: Source
): ResponseBody {
// Ignore content-length header for HEAD requests (consistency with OkHttp)
val contentLength: Long = if (request.method == "HEAD") {
0
} else {
contentLengthString?.toLongOrNull() ?: -1
}
// Check for absence of body in No Content / Reset Content responses (OkHttp consistency)
if ((httpStatusCode == 204 || httpStatusCode == 205) && contentLength > 0) {
throw ProtocolException(
"HTTP $httpStatusCode had non-zero Content-Length: $contentLengthString"
)
}
return bodySource.buffer()
.asResponseBody(contentType?.toMediaTypeOrNull(), contentLength)
}
private fun buildRedirectRequest(
userResponse: Response,
method: String,
newLocationUrl: String
): Request {
// Most redirects don't include a request body.
val requestBuilder = userResponse.request.newBuilder()
if (HttpMethod.permitsRequestBody(method)) {
val responseCode = userResponse.code
val maintainBody = HttpMethod.redirectsWithBody(method) ||
responseCode == StatusLine.HTTP_PERM_REDIRECT ||
responseCode == StatusLine.HTTP_TEMP_REDIRECT
if (HttpMethod.redirectsToGet(method) && responseCode != StatusLine.HTTP_PERM_REDIRECT && responseCode != StatusLine.HTTP_TEMP_REDIRECT) {
requestBuilder.method("GET", null)
} else {
val requestBody = if (maintainBody) userResponse.request.body else null
requestBuilder.method(method, requestBody)
}
if (!maintainBody) {
requestBuilder.removeHeader("Transfer-Encoding")
requestBuilder.removeHeader("Content-Length")
requestBuilder.removeHeader("Content-Type")
}
}
return requestBuilder.url(newLocationUrl).build()
}
private fun toResponse(
request: Request,
responseInfo: UrlResponseInfo,
redirectResponseInfos: List<UrlResponseInfo>,
bodySource: Source? = null
): Response {
val responseBuilder = createResponse(request, responseInfo, bodySource)
val newRequest = request.newBuilder().url(responseInfo.url).build()
return responseBuilder
.request(newRequest)
.priorResponse(buildPriorResponse(request, redirectResponseInfos))
.build()
}
}
inner class CronetBodySource : Source {
private var buffer = ByteBuffer.allocateDirect(32 * 1024)
private var closed = false
private val timeout = readTimeoutMillis.toLong()
override fun close() {
cancelJob?.cancel()
if (closed) {
return
}
closed = true
if (!finished.get()) {
request?.cancel()
}
}
@Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS")
override fun read(sink: Buffer, byteCount: Long): Long {
if (canceled.get()) {
throw IOException("Cronet Request Canceled")
}
require(byteCount >= 0L) { "byteCount < 0: $byteCount" }
check(!closed) { "closed" }
if (finished.get()) {
return -1
}
if (byteCount < buffer.limit()) {
buffer.limit(byteCount.toInt())
}
request?.read(buffer)
val result = callbackResults.poll(timeout, TimeUnit.MILLISECONDS)
if (result == null) {
request?.cancel()
throw IOException("Cronet request body read timeout after wait $timeout ms")
}
return when (result.callbackStep) {
CallbackStep.ON_FAILED -> {
finished.set(true)
buffer = null
throw IOException(result.exception)
}
CallbackStep.ON_SUCCESS -> {
finished.set(true)
buffer = null
-1
}
CallbackStep.ON_CANCELED -> {
buffer = null
throw IOException("Request Canceled")
}
CallbackStep.ON_READ_COMPLETED -> {
result.buffer!!.flip()
val bytesWritten = sink.write(result.buffer)
result.buffer.clear()
bytesWritten.toLong()
}
}
}
override fun timeout(): Timeout {
return mCall.timeout()
}
}
}
@@ -0,0 +1,67 @@
package io.legato.kazusa.lib.cronet
import androidx.annotation.Keep
import okhttp3.RequestBody
import okio.Buffer
import org.chromium.net.UploadDataProvider
import org.chromium.net.UploadDataSink
import java.io.IOException
import java.nio.ByteBuffer
@Keep
class BodyUploadProvider(private val body: RequestBody) : UploadDataProvider(), AutoCloseable {
private val buffer = Buffer()
@Volatile
private var filled: Boolean = false
init {
fillBuffer()
}
private fun fillBuffer() {
try {
buffer.clear()
filled = true
body.writeTo(buffer)
buffer.flush()
} catch (e: IOException) {
e.printStackTrace()
}
}
@Throws(IOException::class)
override fun getLength(): Long {
return body.contentLength()
}
@Throws(IOException::class)
override fun read(uploadDataSink: UploadDataSink, byteBuffer: ByteBuffer) {
if (!filled) {
fillBuffer()
}
check(byteBuffer.hasRemaining()) { "Cronet passed a buffer with no bytes remaining" }
var read: Int
var bytesRead = 0
while (bytesRead == 0) {
read = buffer.read(byteBuffer)
bytesRead += read
}
uploadDataSink.onReadSucceeded(false)
}
@Throws(IOException::class)
override fun rewind(uploadDataSink: UploadDataSink) {
check(body.isOneShot()) { "Okhttp RequestBody is oneShot" }
filled = false
fillBuffer()
uploadDataSink.onRewindSucceeded()
}
@Throws(IOException::class)
override fun close() {
buffer.close()
super.close()
}
}
@@ -0,0 +1,12 @@
package io.legato.kazusa.lib.cronet
import org.chromium.net.CronetException
import java.nio.ByteBuffer
data class CallbackResult(
val callbackStep: CallbackStep,
val buffer: ByteBuffer? = null,
val exception: CronetException? = null
)
@@ -0,0 +1,8 @@
package io.legato.kazusa.lib.cronet
enum class CallbackStep {
ON_READ_COMPLETED,
ON_SUCCESS,
ON_FAILED,
ON_CANCELED
}
@@ -0,0 +1,121 @@
package io.legato.kazusa.lib.cronet
import androidx.annotation.Keep
import io.legato.kazusa.utils.printOnDebug
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import okhttp3.Call
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import okhttp3.internal.http.receiveHeaders
import org.chromium.net.UrlRequest
import org.chromium.net.UrlResponseInfo
import java.io.IOException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@Keep
@Suppress("unused")
class CronetCoroutineInterceptor(private val cookieJar: CookieJar) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
if (chain.call().isCanceled()) {
throw IOException("Canceled")
}
val original: Request = chain.request()
//Cronet未初始化
return if (!CronetLoader.install() || cronetEngine == null) {
chain.proceed(original)
} else try {
val builder: Request.Builder = original.newBuilder()
//移除Keep-Alive,手动设置会导致400 BadRequest
builder.removeHeader("Keep-Alive")
builder.removeHeader("Accept-Encoding")
if (cookieJar != CookieJar.NO_COOKIES) {
val cookieStr = getCookie(original.url)
//设置Cookie
if (cookieStr.length > 3) {
builder.addHeader("Cookie", cookieStr)
}
}
val newReq = builder.build()
val timeout = chain.call().timeout().timeoutNanos() / 1000000
runBlocking() {
if (timeout > 0) {
withTimeout(timeout) {
proceedWithCronet(newReq, chain.call(), chain.readTimeoutMillis()).also { response ->
cookieJar.receiveHeaders(newReq.url, response.headers)
}
}
} else {
proceedWithCronet(newReq, chain.call(), chain.readTimeoutMillis()).also { response ->
cookieJar.receiveHeaders(newReq.url, response.headers)
}
}
}
} catch (e: Exception) {
//不能抛出错误,抛出错误会导致应用崩溃
//遇到Cronet处理有问题时的情况,如证书过期等等,回退到okhttp处理
if (!e.message.toString().contains("ERR_CERT_", true)
&& !e.message.toString().contains("ERR_SSL_", true)
) {
e.printOnDebug()
}
chain.proceed(original)
}
}
private suspend fun proceedWithCronet(
request: Request,
call: Call,
readTimeoutMillis: Int
): Response =
suspendCancellableCoroutine<Response> { coroutine ->
val callBack = object : AbsCallBack(request, call, readTimeoutMillis) {
override fun waitForDone(urlRequest: UrlRequest): Response {
TODO("Not yet implemented")
}
override fun onError(error: IOException) {
coroutine.resumeWithException(error)
}
override fun onSuccess(response: Response) {
coroutine.resume(response)
}
override fun onCanceled(request: UrlRequest?, info: UrlResponseInfo?) {
super.onCanceled(request, info)
coroutine.cancel()
}
}
val req = buildRequest(request, callBack)?.also { it.start() }
coroutine.invokeOnCancellation {
req?.cancel()
}
}
/** Returns a 'Cookie' HTTP request header with all cookies, like `a=b; c=d`. */
private fun getCookie(url: HttpUrl): String = buildString {
val cookies = cookieJar.loadForRequest(url)
cookies.forEachIndexed { index, cookie ->
if (index > 0) append("; ")
append(cookie.name).append('=').append(cookie.value)
}
}
}
@@ -0,0 +1,121 @@
@file:Keep
@file:Suppress("DEPRECATION")
package io.legato.kazusa.lib.cronet
import androidx.annotation.Keep
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.help.http.CookieManager.cookieJarHeader
import io.legato.kazusa.help.http.SSLHelper
import io.legato.kazusa.help.http.okHttpClient
import io.legato.kazusa.utils.DebugLog
import io.legato.kazusa.utils.externalCache
import okhttp3.Headers
import okhttp3.MediaType
import okhttp3.Request
import org.chromium.net.CronetEngine.Builder.HTTP_CACHE_DISK
import org.chromium.net.ExperimentalCronetEngine
import org.chromium.net.UploadDataProvider
import org.chromium.net.UrlRequest
import org.chromium.net.X509Util
import org.json.JSONObject
import splitties.init.appCtx
internal const val BUFFER_SIZE = 32 * 1024
val cronetEngine: ExperimentalCronetEngine? by lazy {
CronetLoader.preDownload()
disableCertificateVerify()
val builder = ExperimentalCronetEngine.Builder(appCtx).apply {
if (CronetLoader.install()) {
setLibraryLoader(CronetLoader)//设置自定义so库加载
}
setStoragePath(appCtx.externalCache.absolutePath)//设置缓存路径
enableHttpCache(HTTP_CACHE_DISK, (1024 * 1024 * 50).toLong())//设置50M的磁盘缓存
enableQuic(true)//设置支持http/3
enableHttp2(true) //设置支持http/2
enablePublicKeyPinningBypassForLocalTrustAnchors(true)
enableBrotli(true)//Brotli压缩
setExperimentalOptions(options)
}
try {
val engine = builder.build()
DebugLog.d("Cronet Version:", engine.versionString)
return@lazy engine
} catch (e: Throwable) {
AppLog.put("初始化cronetEngine出错", e)
return@lazy null
}
}
val options by lazy {
val options = JSONObject()
//设置域名映射规则
//MAP hostname ip,MAP hostname ip
// val host = JSONObject()
// host.put("host_resolver_rules","")
// options.put("HostResolverRules", host)
//启用DnsHttpsSvcb更容易迁移到http3
val dnsSvcb = JSONObject()
dnsSvcb.put("enable", true)
dnsSvcb.put("enable_insecure", true)
dnsSvcb.put("use_alpn", true)
options.put("UseDnsHttpsSvcb", dnsSvcb)
options.put("AsyncDNS", JSONObject("{'enable':true}"))
options.toString()
}
fun buildRequest(request: Request, callback: UrlRequest.Callback): UrlRequest? {
val url = request.url.toString()
val headers: Headers = request.headers
val requestBody = request.body
return cronetEngine?.newUrlRequestBuilder(
url,
callback,
okHttpClient.dispatcher.executorService
)?.apply {
setHttpMethod(request.method)//设置
allowDirectExecutor()
headers.forEachIndexed { index, _ ->
if (headers.name(index) == cookieJarHeader) return@forEachIndexed
addHeader(headers.name(index), headers.value(index))
}
if (requestBody != null) {
val contentType: MediaType? = requestBody.contentType()
if (contentType != null) {
addHeader("Content-Type", contentType.toString())
} else {
addHeader("Content-Type", "text/plain")
}
val provider: UploadDataProvider = if (requestBody.contentLength() > BUFFER_SIZE) {
LargeBodyUploadProvider(requestBody, okHttpClient.dispatcher.executorService)
} else {
BodyUploadProvider(requestBody)
}
provider.use {
this.setUploadDataProvider(it, okHttpClient.dispatcher.executorService)
}
}
}?.build()
}
private fun disableCertificateVerify() {
runCatching {
val sDefaultTrustManager = X509Util::class.java.getDeclaredField("sDefaultTrustManager")
sDefaultTrustManager.isAccessible = true
sDefaultTrustManager.set(null, SSLHelper.unsafeTrustManagerExtensions)
}
runCatching {
val sTestTrustManager = X509Util::class.java.getDeclaredField("sTestTrustManager")
sTestTrustManager.isAccessible = true
sTestTrustManager.set(null, SSLHelper.unsafeTrustManagerExtensions)
}
}
@@ -0,0 +1,97 @@
package io.legato.kazusa.lib.cronet
import android.annotation.SuppressLint
import android.os.Build
import androidx.annotation.Keep
import io.legato.kazusa.help.http.CookieManager
import io.legato.kazusa.help.http.CookieManager.cookieJarHeader
import io.legato.kazusa.utils.printOnDebug
import okhttp3.Call
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
@Keep
@Suppress("unused")
class CronetInterceptor(private val cookieJar: CookieJar) : Interceptor {
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
if (chain.call().isCanceled()) {
throw IOException("Canceled")
}
val original: Request = chain.request()
//Cronet未初始化
if (!CronetLoader.install() || cronetEngine == null) {
return chain.proceed(original)
}
val cronetException: Exception
try {
val builder: Request.Builder = original.newBuilder()
//移除Keep-Alive,手动设置会导致400 BadRequest
builder.removeHeader("Keep-Alive")
builder.removeHeader("Accept-Encoding")
// https://github.com/gedoor/legado/issues/5025#issuecomment-2851156500
if (!original.isHttps &&
original.header("User-Agent")?.startsWith("Mozilla", true) == true
) {
val referer = original.header("Referer")
if (referer != null && referer.startsWith("https:", true)) {
builder.header("Referer", "http" + referer.substring(5))
}
}
var newReq = builder.build()
if (newReq.header(cookieJarHeader) != null) {
newReq = CookieManager.loadRequest(newReq)
}
return proceedWithCronet(newReq, chain.call(), chain.readTimeoutMillis())!!
} catch (e: Exception) {
cronetException = e
//不能抛出错误,抛出错误会导致应用崩溃
//遇到Cronet处理有问题时的情况,如证书过期等等,回退到okhttp处理
if (!e.message.toString().contains("ERR_CERT_", true)
&& !e.message.toString().contains("ERR_SSL_", true)
) {
e.printOnDebug()
}
}
try {
return chain.proceed(original)
} catch (e: Exception) {
e.addSuppressed(cronetException)
throw e
}
}
@SuppressLint("ObsoleteSdkInt")
@Throws(IOException::class)
private fun proceedWithCronet(request: Request, call: Call, readTimeoutMillis: Int): Response? {
val callBack = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
NewCallBack(request, call, readTimeoutMillis)
} else {
OldCallback(request, call, readTimeoutMillis)
}
buildRequest(request, callBack)?.let {
return callBack.waitForDone(it)
}
return null
}
/** Returns a 'Cookie' HTTP request header with all cookies, like `a=b; c=d`. */
private fun getCookie(url: HttpUrl): String = buildString {
val cookies = cookieJar.loadForRequest(url)
cookies.forEachIndexed { index, cookie ->
if (index > 0) append("; ")
append(cookie.name).append('=').append(cookie.value)
}
}
}
@@ -0,0 +1,375 @@
package io.legato.kazusa.lib.cronet
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.ApplicationInfo
import android.os.Build
import android.text.TextUtils
import androidx.annotation.Keep
import io.legato.kazusa.BuildConfig
import io.legato.kazusa.help.coroutine.Coroutine
import io.legato.kazusa.help.http.Cronet
import io.legato.kazusa.utils.DebugLog
import io.legato.kazusa.utils.printOnDebug
import org.chromium.net.CronetEngine
import org.json.JSONObject
import splitties.init.appCtx
import java.io.BufferedReader
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.InputStreamReader
import java.io.OutputStream
import java.math.BigInteger
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
import java.util.Objects
@Suppress("ConstPropertyName")
@Keep
object CronetLoader : CronetEngine.Builder.LibraryLoader(), Cronet.LoaderInterface {
//https://storage.googleapis.com/chromium-cronet/android/92.0.4515.159/Release/cronet/libs/arm64-v8a/libcronet.92.0.4515.159.so
private const val soVersion = BuildConfig.Cronet_Version
private const val soName = "libcronet.$soVersion.so"
private val soUrl: String
private val soFile: File
private val downloadFile: File
private var cpuAbi: String? = null
private var md5: String
var download = false
@Volatile
private var cacheInstall = false
init {
soUrl = ("https://storage.googleapis.com/chromium-cronet/android/"
+ soVersion + "/Release/cronet/libs/"
+ getCpuAbi(appCtx) + "/" + soName)
md5 = getMd5(appCtx)
val dir = appCtx.getDir("cronet", Context.MODE_PRIVATE)
soFile = File(dir.toString() + "/" + getCpuAbi(appCtx), soName)
downloadFile = File(appCtx.cacheDir.toString() + "/so_download", soName)
DebugLog.d(javaClass.simpleName, "soName+:$soName")
DebugLog.d(javaClass.simpleName, "destSuccessFile:$soFile")
DebugLog.d(javaClass.simpleName, "tempFile:$downloadFile")
DebugLog.d(javaClass.simpleName, "soUrl:$soUrl")
}
/**
* 判断Cronet是否安装完成
*/
override fun install(): Boolean {
synchronized(this) {
if (cacheInstall) {
return true
}
}
if (md5.length != 32 || !soFile.exists() || md5 != getFileMD5(soFile)) {
cacheInstall = false
return cacheInstall
}
cacheInstall = soFile.exists()
return cacheInstall
}
/**
* 预加载Cronet
*/
override fun preDownload() {
Coroutine.async {
//md5 = getUrlMd5(md5Url)
if (soFile.exists() && md5 == getFileMD5(soFile)) {
DebugLog.d(javaClass.simpleName, "So 库已存在")
} else {
download(soUrl, md5, downloadFile, soFile)
}
DebugLog.d(javaClass.simpleName, soName)
}
}
private fun getMd5(context: Context): String {
val stringBuilder = StringBuilder()
return try {
//获取assets资源管理器
val assetManager = context.assets
//通过管理器打开文件并读取
val bf = BufferedReader(
InputStreamReader(
assetManager.open("cronet.json")
)
)
var line: String?
while (bf.readLine().also { line = it } != null) {
stringBuilder.append(line)
}
JSONObject(stringBuilder.toString()).optString(getCpuAbi(context), "")
} catch (e: java.lang.Exception) {
return ""
}
}
@SuppressLint("UnsafeDynamicallyLoadedCode")
override fun loadLibrary(libName: String) {
DebugLog.d(javaClass.simpleName, "libName:$libName")
val start = System.currentTimeMillis()
@Suppress("SameParameterValue")
try {
//非cronet的so调用系统方法加载
if (!libName.contains("cronet")) {
System.loadLibrary(libName)
return
}
//以下逻辑为cronet加载,优先加载本地,否则从远程加载
//首先调用系统行为进行加载
System.loadLibrary(libName)
DebugLog.d(javaClass.simpleName, "load from system")
} catch (e: Throwable) {
//如果找不到,则从远程下载
//删除历史文件
deleteHistoryFile(Objects.requireNonNull(soFile.parentFile), soFile)
//md5 = getUrlMd5(md5Url)
DebugLog.d(javaClass.simpleName, "soMD5:$md5")
if (md5.length != 32 || soUrl.isEmpty()) {
//如果md5或下载的url为空,则调用系统行为进行加载
System.loadLibrary(libName)
return
}
if (!soFile.exists() || !soFile.isFile) {
soFile.delete()
download(soUrl, md5, downloadFile, soFile)
//如果文件不存在或不是文件,则调用系统行为进行加载
System.loadLibrary(libName)
return
}
if (soFile.exists()) {
//如果文件存在,则校验md5值
val fileMD5 = getFileMD5(soFile)
if (fileMD5 != null && fileMD5.equals(md5, ignoreCase = true)) {
//md5值一样,则加载
System.load(soFile.absolutePath)
DebugLog.d(javaClass.simpleName, "load from:$soFile")
return
}
//md5不一样则删除
soFile.delete()
}
//不存在则下载
download(soUrl, md5, downloadFile, soFile)
//使用系统加载方法
System.loadLibrary(libName)
} finally {
DebugLog.d(javaClass.simpleName, "time:" + (System.currentTimeMillis() - start))
}
}
@SuppressLint("DiscouragedPrivateApi")
private fun getCpuAbi(context: Context): String? {
if (cpuAbi != null) {
return cpuAbi
}
// 5.0以上Application才有primaryCpuAbi字段
try {
val appInfo = context.applicationInfo
val abiField = ApplicationInfo::class.java.getDeclaredField("primaryCpuAbi")
abiField.isAccessible = true
cpuAbi = abiField.get(appInfo) as String?
} catch (e: Exception) {
e.printOnDebug()
}
if (TextUtils.isEmpty(cpuAbi)) {
cpuAbi = Build.SUPPORTED_ABIS[0]
}
return cpuAbi
}
/**
* 删除历史文件
*/
private fun deleteHistoryFile(dir: File, currentFile: File?) {
val files = dir.listFiles()
@Suppress("SameParameterValue")
if (files != null && files.isNotEmpty()) {
for (f in files) {
if (f.exists() && (currentFile == null || f.absolutePath != currentFile.absolutePath)) {
val delete = f.delete()
DebugLog.d(javaClass.simpleName, "delete file: $f result: $delete")
if (!delete) {
f.deleteOnExit()
}
}
}
}
}
/**
* 下载文件
*/
private fun downloadFileIfNotExist(url: String, destFile: File): Boolean {
var inputStream: InputStream? = null
var outputStream: OutputStream? = null
try {
val connection = URL(url).openConnection() as HttpURLConnection
inputStream = connection.inputStream
if (destFile.exists()) {
return true
}
destFile.parentFile!!.mkdirs()
destFile.createNewFile()
outputStream = FileOutputStream(destFile)
val buffer = ByteArray(32768)
var read: Int
while (inputStream.read(buffer).also { read = it } != -1) {
outputStream.write(buffer, 0, read)
outputStream.flush()
}
return true
} catch (e: Throwable) {
e.printOnDebug()
if (destFile.exists() && !destFile.delete()) {
destFile.deleteOnExit()
}
} finally {
if (inputStream != null) {
try {
inputStream.close()
} catch (e: IOException) {
e.printOnDebug()
}
}
if (outputStream != null) {
try {
outputStream.close()
} catch (e: IOException) {
e.printOnDebug()
}
}
}
return false
}
/**
* 下载并拷贝文件
*/
@Suppress("SameParameterValue")
@Synchronized
private fun download(
url: String,
md5: String?,
downloadTempFile: File,
destSuccessFile: File
) {
if (download) {
return
}
download = true
Coroutine.async {
val result = downloadFileIfNotExist(url, downloadTempFile)
DebugLog.d(javaClass.simpleName, "download result:$result")
//文件md5再次校验
val fileMD5 = getFileMD5(downloadTempFile)
if (md5 != null && !md5.equals(fileMD5, ignoreCase = true)) {
val delete = downloadTempFile.delete()
if (!delete) {
downloadTempFile.deleteOnExit()
}
download = false
return@async
}
DebugLog.d(javaClass.simpleName, "download success, copy to $destSuccessFile")
//下载成功拷贝文件
copyFile(downloadTempFile, destSuccessFile)
cacheInstall = false
val parentFile = downloadTempFile.parentFile
@Suppress("SameParameterValue")
(deleteHistoryFile(parentFile!!, null))
}
}
/**
* 拷贝文件
*/
private fun copyFile(source: File?, dest: File?): Boolean {
if (source == null || !source.exists() || !source.isFile || dest == null) {
return false
}
if (source.absolutePath == dest.absolutePath) {
return true
}
var fileInputStream: FileInputStream? = null
var os: FileOutputStream? = null
val parent = dest.parentFile
if (parent != null && !parent.exists()) {
val mkdirs = parent.mkdirs()
if (!mkdirs) {
parent.mkdirs()
}
}
try {
fileInputStream = FileInputStream(source)
os = FileOutputStream(dest, false)
val buffer = ByteArray(1024 * 512)
var length: Int
while (fileInputStream.read(buffer).also { length = it } > 0) {
os.write(buffer, 0, length)
}
return true
} catch (e: Exception) {
e.printOnDebug()
} finally {
if (fileInputStream != null) {
try {
fileInputStream.close()
} catch (e: Exception) {
e.printOnDebug()
}
}
if (os != null) {
try {
os.close()
} catch (e: Exception) {
e.printOnDebug()
}
}
}
return false
}
/**
* 获得文件md5
*/
private fun getFileMD5(file: File): String? {
var fileInputStream: FileInputStream? = null
try {
fileInputStream = FileInputStream(file)
val md5 = MessageDigest.getInstance("MD5")
val buffer = ByteArray(1024)
var numRead: Int
while (fileInputStream.read(buffer).also { numRead = it } > 0) {
md5.update(buffer, 0, numRead)
}
return String.format("%032x", BigInteger(1, md5.digest())).lowercase()
} catch (e: Exception) {
e.printOnDebug()
} catch (e: OutOfMemoryError) {
e.printOnDebug()
} finally {
if (fileInputStream != null) {
try {
fileInputStream.close()
} catch (e: Exception) {
e.printOnDebug()
}
}
}
return null
}
}
@@ -0,0 +1,75 @@
package io.legato.kazusa.lib.cronet
import androidx.annotation.Keep
import okhttp3.RequestBody
import okio.BufferedSource
import okio.Pipe
import okio.buffer
import org.chromium.net.UploadDataProvider
import org.chromium.net.UploadDataSink
import java.io.IOException
import java.nio.ByteBuffer
import java.util.concurrent.ExecutorService
/**
* 用于上传大型文件
*
* @property body
* @property executorService
*/
@Keep
class LargeBodyUploadProvider(
private val body: RequestBody,
private val executorService: ExecutorService
) : UploadDataProvider(), AutoCloseable {
private val pipe = Pipe(BUFFER_SIZE.toLong())
private var source: BufferedSource = pipe.source.buffer()
@Volatile
private var filled: Boolean = false
override fun getLength(): Long {
return body.contentLength()
}
override fun read(uploadDataSink: UploadDataSink, byteBuffer: ByteBuffer) {
if (!filled) {
fillBuffer()
}
check(byteBuffer.hasRemaining()) { "Cronet passed a buffer with no bytes remaining" }
var read: Int
var bytesRead = 0
while (bytesRead <= 0) {
read = source.read(byteBuffer)
bytesRead += read
}
uploadDataSink.onReadSucceeded(false)
}
@Synchronized
private fun fillBuffer() {
executorService.submit {
try {
val writeSink = pipe.sink.buffer()
filled = true
body.writeTo(writeSink)
writeSink.flush()
} catch (e: IOException) {
e.printStackTrace()
}
}
}
override fun rewind(p0: UploadDataSink?) {
check(body.isOneShot()) { "Okhttp RequestBody is OneShot" }
filled = false
fillBuffer()
}
override fun close() {
// pipe.cancel()
// source.close()
super.close()
}
}
@@ -0,0 +1,53 @@
package io.legato.kazusa.lib.cronet
import android.annotation.SuppressLint
import android.os.Build
import androidx.annotation.Keep
import androidx.annotation.RequiresApi
import okhttp3.Call
import okhttp3.Request
import okhttp3.Response
import org.chromium.net.UrlRequest
import java.io.IOException
import java.util.concurrent.CompletableFuture
import java.util.concurrent.TimeUnit
@SuppressLint("ObsoleteSdkInt")
@Keep
@RequiresApi(api = Build.VERSION_CODES.N)
class NewCallBack(originalRequest: Request, mCall: Call, readTimeoutMillis: Int) :
AbsCallBack(originalRequest, mCall, readTimeoutMillis) {
private val responseFuture = CompletableFuture<Response>()
@Throws(IOException::class)
override fun waitForDone(urlRequest: UrlRequest): Response {
urlRequest.start()
startCheckCancelJob(urlRequest)
//DebugLog.i(javaClass.simpleName, "start ${originalRequest.method} ${originalRequest.url}")
return if (mCall.timeout().timeoutNanos() > 0) {
responseFuture.get(mCall.timeout().timeoutNanos(), TimeUnit.NANOSECONDS)
} else {
return responseFuture.get()
}
}
/**
* 当发生错误时,通知子类终止阻塞抛出错误
* @param error
*/
override fun onError(error: IOException) {
responseFuture.completeExceptionally(error)
}
/**
* 请求成功后,通知子类结束阻塞,返回response
* @param response
*/
override fun onSuccess(response: Response) {
responseFuture.complete(response)
}
}
@@ -0,0 +1,59 @@
package io.legato.kazusa.lib.cronet
import android.os.ConditionVariable
import androidx.annotation.Keep
import okhttp3.Call
import okhttp3.Request
import okhttp3.Response
import org.chromium.net.UrlRequest
import java.io.IOException
@Keep
class OldCallback(originalRequest: Request, mCall: Call, readTimeoutMillis: Int) :
AbsCallBack(originalRequest, mCall, readTimeoutMillis) {
private val mResponseCondition = ConditionVariable()
private var mException: IOException? = null
@Throws(IOException::class)
override fun waitForDone(urlRequest: UrlRequest): Response {
//获取okhttp call的完整请求的超时时间
val timeOutMs: Long = mCall.timeout().timeoutNanos() / 1000000
urlRequest.start()
startCheckCancelJob(urlRequest)
if (timeOutMs > 0) {
mResponseCondition.block(timeOutMs)
} else {
mResponseCondition.block()
}
//ConditionVariable 正常open或者超时open后,检查urlRequest是否完成
if (!urlRequest.isDone) {
urlRequest.cancel()
mException = IOException("Cronet timeout after wait " + timeOutMs + "ms")
}
if (mException != null) {
throw mException as IOException
}
return mResponse
}
/**
* 当发生错误时,通知子类终止阻塞抛出错误
* @param error
*/
override fun onError(error: IOException) {
mException = error
mResponseCondition.open()
}
/**
* 请求成功后,通知子类结束阻塞,返回response
* @param response
*/
override fun onSuccess(response: Response) {
mResponseCondition.open()
}
}
@@ -0,0 +1,106 @@
@file:Suppress("unused")
package io.legato.kazusa.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 androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import io.legato.kazusa.R
@SuppressLint("SupportAnnotationUsage")
interface AlertBuilder<out D : DialogInterface> {
val ctx: Context
fun setTitle(title: CharSequence)
fun setTitle(titleResource: Int)
fun setMessage(message: CharSequence)
fun setMessage(messageResource: Int)
fun setIcon(icon: Drawable)
fun setIcon(@DrawableRes iconResource: Int)
fun setCustomTitle(customTitle: View)
fun setCustomView(customView: View)
fun setCancelable(isCancelable: Boolean)
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 onDismiss(handler: (dialog: DialogInterface) -> Unit)
fun items(
items: List<CharSequence>,
onItemSelected: (dialog: DialogInterface, index: Int) -> Unit
)
fun <T> items(
items: List<T>,
onItemSelected: (dialog: DialogInterface, item: T, index: Int) -> Unit
)
fun multiChoiceItems(
items: Array<String>,
checkedItems: BooleanArray,
onClick: (dialog: DialogInterface, which: Int, isChecked: Boolean) -> Unit
)
fun singleChoiceItems(
items: Array<String>,
checkedItem: Int = 0,
onClick: ((dialog: DialogInterface, which: Int) -> Unit)? = null
)
fun build(): D
fun show(): D
fun customTitle(view: () -> View) {
setCustomTitle(view())
}
fun customView(view: () -> View) {
setCustomView(view())
}
fun okButton(handler: ((dialog: DialogInterface) -> Unit)? = null) =
positiveButton(android.R.string.ok, handler)
fun cancelButton(handler: ((dialog: DialogInterface) -> Unit)? = null) =
negativeButton(android.R.string.cancel, handler)
fun yesButton(handler: ((dialog: DialogInterface) -> Unit)? = null) =
positiveButton(R.string.yes, handler)
fun noButton(handler: ((dialog: DialogInterface) -> Unit)? = null) =
negativeButton(R.string.no, handler)
}
@@ -0,0 +1,171 @@
package io.legato.kazusa.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 com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.legato.kazusa.R
import io.legato.kazusa.help.config.AppConfig
internal class AndroidAlertBuilder(override val ctx: Context) : AlertBuilder<AlertDialog> {
private val builder = MaterialAlertDialogBuilder(ctx)
override fun setTitle(title: CharSequence) {
builder.setTitle(title)
}
override fun setTitle(titleResource: Int) {
builder.setTitle(titleResource)
}
override fun setMessage(message: CharSequence) {
builder.setMessage(message)
}
override fun setMessage(messageResource: Int) {
builder.setMessage(messageResource)
}
override fun setIcon(icon: Drawable) {
builder.setIcon(icon)
}
override fun setIcon(iconResource: Int) {
builder.setIcon(iconResource)
}
override fun setCustomTitle(customTitle: View) {
builder.setCustomTitle(customTitle)
}
override fun setCustomView(customView: View) {
builder.setView(customView)
}
override fun setCancelable(isCancelable: Boolean) {
builder.setCancelable(isCancelable)
}
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 onDismiss(handler: (dialog: DialogInterface) -> Unit) {
builder.setOnDismissListener(handler)
}
override fun items(
items: List<CharSequence>,
onItemSelected: (dialog: DialogInterface, index: Int) -> Unit
) {
builder.setItems(Array(items.size) { i -> items[i].toString() }) { dialog, which ->
onItemSelected(dialog, which)
}
}
override fun <T> items(
items: List<T>,
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 multiChoiceItems(
items: Array<String>,
checkedItems: BooleanArray,
onClick: (dialog: DialogInterface, which: Int, isChecked: Boolean) -> Unit
) {
builder.setMultiChoiceItems(items, checkedItems) { dialog, which, isChecked ->
onClick(dialog, which, isChecked)
}
}
override fun singleChoiceItems(
items: Array<String>,
checkedItem: Int,
onClick: ((dialog: DialogInterface, which: Int) -> Unit)?
) {
builder.setSingleChoiceItems(items, checkedItem) { dialog, which ->
onClick?.invoke(dialog, which)
}
}
override fun build(): AlertDialog {
var dialog = builder.create()
if (AppConfig.isEInkMode) {
dialog.window?.run {
val attr = attributes
attr.dimAmount = 0f
attr.windowAnimations = 0
attributes = attr
setBackgroundDrawableResource(R.drawable.bg_eink_border_dialog)
}
}
return dialog
}
override fun show(): AlertDialog {
var dialog = builder.show()
if (AppConfig.isEInkMode) {
dialog.window?.run {
val attr = attributes
attr.dimAmount = 0f
attr.windowAnimations = 0
attributes = attr
setBackgroundDrawableResource(R.drawable.bg_eink_border_dialog)
}
}
return dialog
}
}
@@ -0,0 +1,186 @@
@file:Suppress("NOTHING_TO_INLINE", "unused", "DEPRECATION")
package io.legato.kazusa.lib.dialogs
import android.app.ProgressDialog
import android.content.Context
import android.content.DialogInterface
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
fun Context.alert(
title: CharSequence? = null,
message: CharSequence? = null,
init: (AlertBuilder<DialogInterface>.() -> Unit)? = null
): AlertDialog {
return AndroidAlertBuilder(this).apply {
if (title != null) {
this.setTitle(title)
}
if (message != null) {
this.setMessage(message)
}
if (init != null) init()
}.show()
}
inline fun Fragment.alert(
title: CharSequence? = null,
message: CharSequence? = null,
noinline init: (AlertBuilder<DialogInterface>.() -> Unit)? = null
) = requireActivity().alert(title, message, init)
fun Context.alert(
titleResource: Int? = null,
messageResource: Int? = null,
init: (AlertBuilder<DialogInterface>.() -> Unit)? = null
): AlertDialog {
return AndroidAlertBuilder(this).apply {
if (titleResource != null) {
this.setTitle(titleResource)
}
if (messageResource != null) {
this.setMessage(messageResource)
}
if (init != null) init()
}.show()
}
inline fun Fragment.alert(
titleResource: Int? = null,
messageResource: Int? = null,
noinline init: (AlertBuilder<DialogInterface>.() -> Unit)? = null
) = requireActivity().alert(titleResource, messageResource, init)
fun Context.alert(init: AlertBuilder<AlertDialog>.() -> Unit): AlertDialog =
AndroidAlertBuilder(this).apply {
init()
}.show()
inline fun Fragment.alert(noinline init: AlertBuilder<DialogInterface>.() -> Unit) =
requireContext().alert(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()
}
typealias AlertBuilderFactory<D> = (Context) -> AlertBuilder<D>
inline fun <D : DialogInterface> Fragment.alert(
noinline factory: AlertBuilderFactory<D>,
title: String? = null,
message: String? = null,
noinline init: (AlertBuilder<D>.() -> Unit)? = null
) = activity?.alert(factory, title, message, init)
fun <D : DialogInterface> Context.alert(
factory: AlertBuilderFactory<D>,
title: String? = null,
message: String? = null,
init: (AlertBuilder<D>.() -> Unit)? = null
): AlertBuilder<D> {
return factory(this).apply {
if (title != null) {
this.setTitle(title)
}
if (message != null) {
this.setMessage(message)
}
if (init != null) init()
}
}
inline fun <D : DialogInterface> Fragment.alert(
noinline factory: AlertBuilderFactory<D>,
titleResource: Int? = null,
messageResource: Int? = null,
noinline init: (AlertBuilder<D>.() -> Unit)? = null
) = requireActivity().alert(factory, titleResource, messageResource, init)
fun <D : DialogInterface> Context.alert(
factory: AlertBuilderFactory<D>,
titleResource: Int? = null,
messageResource: Int? = null,
init: (AlertBuilder<D>.() -> Unit)? = null
): AlertBuilder<D> {
return factory(this).apply {
if (titleResource != null) {
this.setTitle(titleResource)
}
if (messageResource != null) {
this.setMessage(messageResource)
}
if (init != null) init()
}
}
inline fun <D : DialogInterface> Fragment.alert(
noinline factory: AlertBuilderFactory<D>,
noinline init: AlertBuilder<D>.() -> Unit
) = requireActivity().alert(factory, init)
fun <D : DialogInterface> Context.alert(
factory: AlertBuilderFactory<D>,
init: AlertBuilder<D>.() -> Unit
): AlertBuilder<D> = factory(this).apply { init() }
@@ -0,0 +1,90 @@
/*
* 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("unused")
package io.legato.kazusa.lib.dialogs
import android.content.Context
import android.content.DialogInterface
fun Context.selector(
items: List<CharSequence>,
onClick: (DialogInterface, Int) -> Unit
) {
with(AndroidAlertBuilder(this)) {
items(items, onClick)
show()
}
}
fun <T> Context.selector(
items: List<T>,
onClick: (DialogInterface, T, Int) -> Unit
) {
with(AndroidAlertBuilder(this)) {
items(items, onClick)
show()
}
}
fun Context.selector(
title: CharSequence,
items: List<CharSequence>,
onClick: (DialogInterface, Int) -> Unit
) {
with(AndroidAlertBuilder(this)) {
this.setTitle(title)
items(items, onClick)
show()
}
}
fun <T> Context.selector(
title: CharSequence,
items: List<T>,
onClick: (DialogInterface, T, Int) -> Unit
) {
with(AndroidAlertBuilder(this)) {
this.setTitle(title)
items(items, onClick)
show()
}
}
fun Context.selector(
titleSource: Int,
items: List<CharSequence>,
onClick: (DialogInterface, Int) -> Unit
) {
with(AndroidAlertBuilder(this)) {
this.setTitle(titleSource)
items(items, onClick)
show()
}
}
fun <T> Context.selector(
titleSource: Int,
items: List<T>,
onClick: (DialogInterface, T, Int) -> Unit
) {
with(AndroidAlertBuilder(this)) {
this.setTitle(titleSource)
items(items, onClick)
show()
}
}
@@ -0,0 +1,13 @@
package io.legato.kazusa.lib.dialogs
@Suppress("unused")
data class SelectItem<T>(
val title: String,
val value: T
) {
override fun toString(): String {
return title
}
}
@@ -0,0 +1,587 @@
// © 2016 and later: Unicode, Inc. and others.
// License & terms of use: http://www.unicode.org/copyright.html
/*
******************************************************************************
Copyright (C) 2005-2016, International Business Machines Corporation and *
others. All Rights Reserved. *
******************************************************************************
*/
package io.legato.kazusa.lib.icu4j;
import android.os.ParcelFileDescriptor;
import android.system.OsConstants;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* <code>CharsetDetector</code> provides a facility for detecting the
* charset or encoding of character data in an unknown format.
* The input data can either be from an input stream or an array of bytes.
* The result of the detection operation is a list of possibly matching
* charsets, or, for simple use, you can just ask for a Java Reader that
* will will work over the input data.
* <p>
* Character set detection is at best an imprecise operation. The detection
* process will attempt to identify the charset that best matches the characteristics
* of the byte data, but the process is partly statistical in nature, and
* the results can not be guaranteed to always be correct.
* <p>
* For best accuracy in charset detection, the input data should be primarily
* in a single language, and a minimum of a few hundred bytes worth of plain text
* in the language are needed. The detection process will attempt to
* ignore html or xml style markup that could otherwise obscure the content.
* <p>
*
* @stable ICU 3.4
*/
@SuppressWarnings({"JavaDoc", "unused", "RedundantSuppression"})
public class CharsetDetector {
// Question: Should we have getters corresponding to the setters for input text
// and declared encoding?
// A thought: If we were to create our own type of Java Reader, we could defer
// figuring out an actual charset for data that starts out with too much English
// only ASCII until the user actually read through to something that didn't look
// like 7 bit English. If nothing else ever appeared, we would never need to
// actually choose the "real" charset. All assuming that the application just
// wants the data, and doesn't care about a char set name.
/**
* Constructor
*
* @stable ICU 3.4
*/
public CharsetDetector() {
}
/**
* Set the declared encoding for charset detection.
* The declared encoding of an input text is an encoding obtained
* from an http header or xml declaration or similar source that
* can be provided as additional information to the charset detector.
* A match between a declared encoding and a possible detected encoding
* will raise the quality of that detected encoding by a small delta,
* and will also appear as a "reason" for the match.
* <p>
* A declared encoding that is incompatible with the input data being
* analyzed will not be added to the list of possible encodings.
*
* @param encoding The declared encoding
* @stable ICU 3.4
*/
public CharsetDetector setDeclaredEncoding(String encoding) {
fDeclaredEncoding = encoding;
return this;
}
/**
* Set the input text (byte) data whose charset is to be detected.
*
* @param in the input text of unknown encoding
* @return This CharsetDetector
* @stable ICU 3.4
*/
public CharsetDetector setText(@NonNull byte[] in) {
fRawInput = in;
fRawLength = in.length;
return this;
}
public CharsetDetector setText(@NonNull ParcelFileDescriptor pfd) {
fRawInput = new byte[kBufSize];
try {
android.system.Os.lseek(pfd.getFileDescriptor(), 0, OsConstants.SEEK_SET);
fRawLength = android.system.Os.read(pfd.getFileDescriptor(), fRawInput, 0, fRawInput.length);
android.system.Os.lseek(pfd.getFileDescriptor(), 0, OsConstants.SEEK_SET);
} catch (Exception e) {
throw new RuntimeException(e);
}
return this;
}
private static final int kBufSize = 8000;
/**
* Set the input text (byte) data whose charset is to be detected.
* <p>
* The input stream that supplies the character data must have markSupported()
* == true; the charset detection process will read a small amount of data,
* then return the stream to its original position via
* the InputStream.reset() operation. The exact amount that will
* be read depends on the characteristics of the data itself.
*
* @param in the input text of unknown encoding
* @return This CharsetDetector
* @stable ICU 3.4
*/
public CharsetDetector setText(@NonNull InputStream in) throws IOException {
fInputStream = in;
fInputStream.mark(kBufSize);
fRawInput = new byte[kBufSize]; // Always make a new buffer because the
// previous one may have come from the caller,
// in which case we can't touch it.
fRawLength = 0;
int remainingLength = kBufSize;
while (remainingLength > 0) {
// read() may give data in smallish chunks, esp. for remote sources. Hence, this loop.
int bytesRead = fInputStream.read(fRawInput, fRawLength, remainingLength);
if (bytesRead <= 0) {
break;
}
fRawLength += bytesRead;
remainingLength -= bytesRead;
}
fInputStream.reset();
return this;
}
/**
* Return the charset that best matches the supplied input data.
* <p>
* Note though, that because the detection
* only looks at the start of the input data,
* there is a possibility that the returned charset will fail to handle
* the full set of input data.
* p>
* aise an exception if
* <ul>
* <li>no charset appears to match the data.</li>
* <li>no input text has been provided</li>
* </ul>
*
* @return a CharsetMatch object representing the best matching charset, or
* <code>null</code> if there are no matches.
* @stable ICU 3.4
*/
@Nullable
public CharsetMatch detect() {
// TODO: A better implementation would be to copy the detect loop from
// detectAll(), and cut it short as soon as a match with a high confidence
// is found. This is something to be done later, after things are otherwise
// working.
CharsetMatch[] matches = detectAll();
if (matches == null || matches.length == 0) {
return null;
}
return matches[0];
}
/**
* Return an array of all charsets that appear to be plausible
* matches with the input data. The array is ordered with the
* best quality match first.
* <p>
* aise an exception if
* <ul>
* <li>no charsets appear to match the input data.</li>
* <li>no input text has been provided</li>
* </ul>
*
* @return An array of CharsetMatch objects representing possibly matching charsets.
* @stable ICU 3.4
*/
public CharsetMatch[] detectAll() {
ArrayList<CharsetMatch> matches = new ArrayList<>();
MungeInput(); // Strip html markup, collect byte stats.
// Iterate over all possible charsets, remember all that
// give a match quality > 0.
for (int i = 0; i < ALL_CS_RECOGNIZERS.size(); i++) {
CSRecognizerInfo rcinfo = ALL_CS_RECOGNIZERS.get(i);
boolean active = (fEnabledRecognizers != null) ? fEnabledRecognizers[i] : rcinfo.isDefaultEnabled;
if (active) {
CharsetMatch m = rcinfo.recognizer.match(this);
if (m != null) {
matches.add(m);
}
}
}
Collections.sort(matches); // CharsetMatch compares on confidence
Collections.reverse(matches); // Put best match first.
CharsetMatch[] resultArray = new CharsetMatch[matches.size()];
resultArray = matches.toArray(resultArray);
return resultArray;
}
/**
* Autodetect the charset of an inputStream, and return a Java Reader
* to access the converted input data.
* <p>
* This is a convenience method that is equivalent to
* <code>this.setDeclaredEncoding(declaredEncoding).setText(in).detect().getReader();</code>
* <p>
* For the input stream that supplies the character data, markSupported()
* must be true; the charset detection will read a small amount of data,
* then return the stream to its original position via
* the InputStream.reset() operation. The exact amount that will
* be read depends on the characteristics of the data itself.
* <p>
* Raise an exception if no charsets appear to match the input data.
*
* @param in The source of the byte data in the unknown charset.
* @param declaredEncoding A declared encoding for the data, if available,
* or null or an empty string if none is available.
* @stable ICU 3.4
*/
public Reader getReader(InputStream in, String declaredEncoding) {
fDeclaredEncoding = declaredEncoding;
try {
setText(in);
CharsetMatch match = detect();
if (match == null) {
return null;
}
return match.getReader();
} catch (IOException e) {
return null;
}
}
/**
* Autodetect the charset of an inputStream, and return a String
* containing the converted input data.
* <p>
* This is a convenience method that is equivalent to
* <code>this.setDeclaredEncoding(declaredEncoding).setText(in).detect().getString();</code>
* <p>
* Raise an exception if no charsets appear to match the input data.
*
* @param in The source of the byte data in the unknown charset.
* @param declaredEncoding A declared encoding for the data, if available,
* or null or an empty string if none is available.
* @stable ICU 3.4
*/
public String getString(byte[] in, String declaredEncoding) {
fDeclaredEncoding = declaredEncoding;
try {
setText(in);
CharsetMatch match = detect();
if (match == null) {
return null;
}
return match.getString(-1);
} catch (IOException e) {
return null;
}
}
/**
* Get the names of all charsets supported by <code>CharsetDetector</code> class.
* <p>
* <b>Note:</b> Multiple different charset encodings in a same family may use
* a single shared name in this implementation. For example, this method returns
* an array including "ISO-8859-1" (ISO Latin 1), but not including "windows-1252"
* (Windows Latin 1). However, actual detection result could be "windows-1252"
* when the input data matches Latin 1 code points with any points only available
* in "windows-1252".
*
* @return an array of the names of all charsets supported by
* <code>CharsetDetector</code> class.
* @stable ICU 3.4
*/
public static String[] getAllDetectableCharsets() {
String[] allCharsetNames = new String[ALL_CS_RECOGNIZERS.size()];
for (int i = 0; i < allCharsetNames.length; i++) {
allCharsetNames[i] = ALL_CS_RECOGNIZERS.get(i).recognizer.getName();
}
return allCharsetNames;
}
/**
* Test whether or not input filtering is enabled.
*
* @return <code>true</code> if input text will be filtered.
* @stable ICU 3.4
* @see #enableInputFilter
*/
public boolean inputFilterEnabled() {
return fStripTags;
}
/**
* Enable filtering of input text. If filtering is enabled,
* text within angle brackets ("&lt;" and "&gt;") will be removed
* before detection.
*
* @param filter <code>true</code> to enable input text filtering.
* @return The previous setting.
* @stable ICU 3.4
*/
public boolean enableInputFilter(boolean filter) {
boolean previous = fStripTags;
fStripTags = filter;
return previous;
}
/*
* MungeInput - after getting a set of raw input data to be analyzed, preprocess
* it by removing what appears to be html markup.
*/
private void MungeInput() {
int srci;
int dsti = 0;
byte b;
boolean inMarkup = false;
int openTags = 0;
int badTags = 0;
//
// html / xml markup stripping.
// quick and dirty, not 100% accurate, but hopefully good enough, statistically.
// discard everything within < brackets >
// Count how many total '<' and illegal (nested) '<' occur, so we can make some
// guess as to whether the input was actually marked up at all.
if (fStripTags) {
for (srci = 0; srci < fRawLength && dsti < fInputBytes.length; srci++) {
b = fRawInput[srci];
if (b == (byte) '<') {
if (inMarkup) {
badTags++;
}
inMarkup = true;
openTags++;
}
if (!inMarkup) {
fInputBytes[dsti++] = b;
}
if (b == (byte) '>') {
inMarkup = false;
}
}
fInputLen = dsti;
}
//
// If it looks like this input wasn't marked up, or if it looks like it's
// essentially nothing but markup abandon the markup stripping.
// Detection will have to work on the unstripped input.
//
if (openTags < 5 || openTags / 5 < badTags ||
(fInputLen < 100 && fRawLength > 600)) {
int limit = fRawLength;
if (limit > kBufSize) {
limit = kBufSize;
}
for (srci = 0; srci < limit; srci++) {
fInputBytes[srci] = fRawInput[srci];
}
fInputLen = srci;
}
//
// Tally up the byte occurence statistics.
// These are available for use by the various detectors.
//
Arrays.fill(fByteStats, (short) 0);
for (srci = 0; srci < fInputLen; srci++) {
int val = fInputBytes[srci] & 0x00ff;
fByteStats[val]++;
}
fC1Bytes = false;
for (int i = 0x80; i <= 0x9F; i += 1) {
if (fByteStats[i] != 0) {
fC1Bytes = true;
break;
}
}
}
/*
* The following items are accessed by individual CharsetRecongizers during
* the recognition process
*
*/
byte[] fInputBytes = // The text to be checked. Markup will have been
new byte[kBufSize]; // removed if appropriate.
int fInputLen; // Length of the byte data in fInputBytes.
short[] fByteStats = // byte frequency statistics for the input text.
new short[256]; // Value is percent, not absolute.
// Value is rounded up, so zero really means zero occurences.
boolean fC1Bytes = // True if any bytes in the range 0x80 - 0x9F are in the input;
false;
String fDeclaredEncoding;
byte[] fRawInput; // Original, untouched input bytes.
// If user gave us a byte array, this is it.
// If user gave us a stream, it's read to a
// buffer here.
int fRawLength; // Length of data in fRawInput array.
InputStream fInputStream; // User's input stream, or null if the user
// gave us a byte array.
//
// Stuff private to CharsetDetector
//
private boolean fStripTags = // If true, setText() will strip tags from input text.
false;
private boolean[] fEnabledRecognizers; // If not null, active set of charset recognizers had
// been changed from the default. The array index is
// corresponding to ALL_RECOGNIZER. See setDetectableCharset().
private static class CSRecognizerInfo {
CharsetRecognizer recognizer;
boolean isDefaultEnabled;
CSRecognizerInfo(CharsetRecognizer recognizer, boolean isDefaultEnabled) {
this.recognizer = recognizer;
this.isDefaultEnabled = isDefaultEnabled;
}
}
/*
* List of recognizers for all charsets known to the implementation.
*/
private static final List<CSRecognizerInfo> ALL_CS_RECOGNIZERS;
static {
List<CSRecognizerInfo> list = new ArrayList<>();
list.add(new CSRecognizerInfo(new CharsetRecog_UTF8(), true));
list.add(new CSRecognizerInfo(new CharsetRecog_Unicode.CharsetRecog_UTF_16_BE(), true));
list.add(new CSRecognizerInfo(new CharsetRecog_Unicode.CharsetRecog_UTF_16_LE(), true));
list.add(new CSRecognizerInfo(new CharsetRecog_Unicode.CharsetRecog_UTF_32_BE(), true));
list.add(new CSRecognizerInfo(new CharsetRecog_Unicode.CharsetRecog_UTF_32_LE(), true));
list.add(new CSRecognizerInfo(new CharsetRecog_mbcs.CharsetRecog_sjis(), true));
list.add(new CSRecognizerInfo(new CharsetRecog_2022.CharsetRecog_2022JP(), true));
list.add(new CSRecognizerInfo(new CharsetRecog_2022.CharsetRecog_2022CN(), true));
list.add(new CSRecognizerInfo(new CharsetRecog_2022.CharsetRecog_2022KR(), true));
list.add(new CSRecognizerInfo(new CharsetRecog_mbcs.CharsetRecog_euc.CharsetRecog_gb_18030(), true));
list.add(new CSRecognizerInfo(new CharsetRecog_mbcs.CharsetRecog_euc.CharsetRecog_euc_jp(), true));
list.add(new CSRecognizerInfo(new CharsetRecog_mbcs.CharsetRecog_euc.CharsetRecog_euc_kr(), true));
list.add(new CSRecognizerInfo(new CharsetRecog_mbcs.CharsetRecog_big5(), true));
list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_8859_1(), true));
list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_8859_2(), true));
list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_8859_5_ru(), true));
list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_8859_6_ar(), true));
list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_8859_7_el(), true));
list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_8859_8_I_he(), true));
list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_8859_8_he(), true));
list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_windows_1251(), true));
list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_windows_1256(), true));
list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_KOI8_R(), true));
list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_8859_9_tr(), true));
// IBM 420/424 recognizers are disabled by default
list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_IBM424_he_rtl(), false));
list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_IBM424_he_ltr(), false));
list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_IBM420_ar_rtl(), false));
list.add(new CSRecognizerInfo(new CharsetRecog_sbcs.CharsetRecog_IBM420_ar_ltr(), false));
//noinspection Java9CollectionFactory
ALL_CS_RECOGNIZERS = Collections.unmodifiableList(list);
}
/**
* Get the names of charsets that can be recognized by this CharsetDetector instance.
*
* @return an array of the names of charsets that can be recognized by this CharsetDetector
* instance.
* @internal
* @deprecated This API is ICU internal only.
*/
@Deprecated
public String[] getDetectableCharsets() {
List<String> csnames = new ArrayList<>(ALL_CS_RECOGNIZERS.size());
for (int i = 0; i < ALL_CS_RECOGNIZERS.size(); i++) {
CSRecognizerInfo rcinfo = ALL_CS_RECOGNIZERS.get(i);
boolean active = (fEnabledRecognizers == null) ? rcinfo.isDefaultEnabled : fEnabledRecognizers[i];
if (active) {
csnames.add(rcinfo.recognizer.getName());
}
}
return csnames.toArray(new String[0]);
}
/**
* Enable or disable individual charset encoding.
* A name of charset encoding must be included in the names returned by
* {@link #getAllDetectableCharsets()}.
*
* @param encoding the name of charset encoding.
* @param enabled <code>true</code> to enable, or <code>false</code> to disable the
* charset encoding.
* @return A reference to this <code>CharsetDetector</code>.
* @throws IllegalArgumentException when the name of charset encoding is
* not supported.
* @internal
* @deprecated This API is ICU internal only.
*/
@Deprecated
public CharsetDetector setDetectableCharset(String encoding, boolean enabled) {
int modIdx = -1;
boolean isDefaultVal = false;
for (int i = 0; i < ALL_CS_RECOGNIZERS.size(); i++) {
CSRecognizerInfo csrinfo = ALL_CS_RECOGNIZERS.get(i);
if (csrinfo.recognizer.getName().equals(encoding)) {
modIdx = i;
isDefaultVal = (csrinfo.isDefaultEnabled == enabled);
break;
}
}
if (modIdx < 0) {
// No matching encoding found
throw new IllegalArgumentException("Invalid encoding: " + "\"" + encoding + "\"");
}
if (fEnabledRecognizers == null && !isDefaultVal) {
// Create an array storing the non default setting
fEnabledRecognizers = new boolean[ALL_CS_RECOGNIZERS.size()];
// Initialize the array with default info
for (int i = 0; i < ALL_CS_RECOGNIZERS.size(); i++) {
fEnabledRecognizers[i] = ALL_CS_RECOGNIZERS.get(i).isDefaultEnabled;
}
}
if (fEnabledRecognizers != null) {
fEnabledRecognizers[modIdx] = enabled;
}
return this;
}
}
@@ -0,0 +1,241 @@
// © 2016 and later: Unicode, Inc. and others.
// License & terms of use: http://www.unicode.org/copyright.html
/*
* ******************************************************************************
* Copyright (C) 2005-2016, International Business Machines Corporation and *
* others. All Rights Reserved. *
* ******************************************************************************
*/
package io.legato.kazusa.lib.icu4j;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
/**
* This class represents a charset that has been identified by a CharsetDetector
* as a possible encoding for a set of input data. From an instance of this
* class, you can ask for a confidence level in the charset identification,
* or for Java Reader or String to access the original byte data in Unicode form.
* <p>
* Instances of this class are created only by CharsetDetectors.
* <p>
* Note: this class has a natural ordering that is inconsistent with equals.
* The natural ordering is based on the match confidence value.
*
* @stable ICU 3.4
*/
@SuppressWarnings({"JavaDoc", "unused"})
public class CharsetMatch implements Comparable<CharsetMatch> {
/**
* Create a java.io.Reader for reading the Unicode character data corresponding
* to the original byte data supplied to the Charset detect operation.
* <p>
* CAUTION: if the source of the byte data was an InputStream, a Reader
* can be created for only one matching char set using this method. If more
* than one charset needs to be tried, the caller will need to reset
* the InputStream and create InputStreamReaders itself, based on the charset name.
*
* @return the Reader for the Unicode character data.
* @stable ICU 3.4
*/
public Reader getReader() {
InputStream inputStream = fInputStream;
if (inputStream == null) {
inputStream = new ByteArrayInputStream(fRawInput, 0, fRawLength);
}
try {
inputStream.reset();
return new InputStreamReader(inputStream, getName());
} catch (IOException e) {
return null;
}
}
/**
* Create a Java String from Unicode character data corresponding
* to the original byte data supplied to the Charset detect operation.
*
* @return a String created from the converted input data.
* @stable ICU 3.4
*/
public String getString() throws java.io.IOException {
return getString(-1);
}
/**
* Create a Java String from Unicode character data corresponding
* to the original byte data supplied to the Charset detect operation.
* The length of the returned string is limited to the specified size;
* the string will be trunctated to this length if necessary. A limit value of
* zero or less is ignored, and treated as no limit.
*
* @param maxLength The maximium length of the String to be created when the
* source of the data is an input stream, or -1 for
* unlimited length.
* @return a String created from the converted input data.
* @stable ICU 3.4
*/
public String getString(int maxLength) throws java.io.IOException {
String result;
if (fInputStream != null) {
StringBuilder sb = new StringBuilder();
char[] buffer = new char[1024];
Reader reader = getReader();
int max = maxLength < 0 ? Integer.MAX_VALUE : maxLength;
int bytesRead;
while ((bytesRead = reader.read(buffer, 0, Math.min(max, 1024))) >= 0) {
sb.append(buffer, 0, bytesRead);
max -= bytesRead;
}
reader.close();
return sb.toString();
} else {
String name = getName();
/*
* getName() may return a name with a suffix 'rtl' or 'ltr'. This cannot
* be used to open a charset (e.g. IBM424_rtl). The ending '_rtl' or 'ltr'
* should be stripped off before creating the string.
*/
int startSuffix = !name.contains("_rtl") ? name.indexOf("_ltr") : name.indexOf("_rtl");
if (startSuffix > 0) {
name = name.substring(0, startSuffix);
}
result = new String(fRawInput, name);
}
return result;
}
/**
* Get an indication of the confidence in the charset detected.
* Confidence values range from 0-100, with larger numbers indicating
* a better match of the input data to the characteristics of the
* charset.
*
* @return the confidence in the charset match
* @stable ICU 3.4
*/
public int getConfidence() {
return fConfidence;
}
/**
* Get the name of the detected charset.
* The name will be one that can be used with other APIs on the
* platform that accept charset names. It is the "Canonical name"
* as defined by the class java.nio.charset.Charset; for
* charsets that are registered with the IANA charset registry,
* this is the MIME-preferred registerd name.
*
* @return The name of the charset.
* @stable ICU 3.4
* @see java.nio.charset.Charset
* @see java.io.InputStreamReader
*/
public String getName() {
return fCharsetName;
}
/**
* Get the ISO code for the language of the detected charset.
*
* @return The ISO code for the language or <code>null</code> if the language cannot be determined.
* @stable ICU 3.4
*/
public String getLanguage() {
return fLang;
}
/**
* Compare to other CharsetMatch objects.
* Comparison is based on the match confidence value, which
* allows CharsetDetector.detectAll() to order its results.
*
* @param other the CharsetMatch object to compare against.
* @return a negative integer, zero, or a positive integer as the
* confidence level of this CharsetMatch
* is less than, equal to, or greater than that of
* the argument.
* @throws ClassCastException if the argument is not a CharsetMatch.
* @stable ICU 4.4
*/
@Override
public int compareTo(CharsetMatch other) {
int compareResult = 0;
if (this.fConfidence > other.fConfidence) {
compareResult = 1;
} else if (this.fConfidence < other.fConfidence) {
compareResult = -1;
}
return compareResult;
}
/*
* Constructor. Implementation internal
*/
CharsetMatch(CharsetDetector det, CharsetRecognizer rec, int conf) {
fConfidence = conf;
// The references to the original application input data must be copied out
// of the charset recognizer to here, in case the application resets the
// recognizer before using this CharsetMatch.
if (det.fInputStream == null) {
// We only want the existing input byte data if it came straight from the user,
// not if is just the head of a stream.
fRawInput = det.fRawInput;
fRawLength = det.fRawLength;
}
fInputStream = det.fInputStream;
fCharsetName = rec.getName();
fLang = rec.getLanguage();
}
/*
* Constructor. Implementation internal
*/
CharsetMatch(CharsetDetector det, CharsetRecognizer rec, int conf, String csName, String lang) {
fConfidence = conf;
// The references to the original application input data must be copied out
// of the charset recognizer to here, in case the application resets the
// recognizer before using this CharsetMatch.
if (det.fInputStream == null) {
// We only want the existing input byte data if it came straight from the user,
// not if is just the head of a stream.
fRawInput = det.fRawInput;
fRawLength = det.fRawLength;
}
fInputStream = det.fInputStream;
fCharsetName = csName;
fLang = lang;
}
//
// Private Data
//
private final int fConfidence;
private byte[] fRawInput = null; // Original, untouched input bytes.
// If user gave us a byte array, this is it.
private int fRawLength; // Length of data in fRawInput array.
private final InputStream fInputStream; // User's input stream, or null if the user
// gave us a byte array.
private final String fCharsetName; // The name of the charset this CharsetMatch
// represents. Filled in by the recognizer.
private final String fLang; // The language, if one was determined by
// the recognizer during the detect operation.
}
@@ -0,0 +1,170 @@
// © 2016 and later: Unicode, Inc. and others.
// License & terms of use: http://www.unicode.org/copyright.html
/*
*******************************************************************************
* Copyright (C) 2005 - 2012, International Business Machines Corporation and *
* others. All Rights Reserved. *
*******************************************************************************
*/
package io.legato.kazusa.lib.icu4j;
/**
* class CharsetRecog_2022 part of the ICU charset detection imlementation.
* This is a superclass for the individual detectors for
* each of the detectable members of the ISO 2022 family
* of encodings.
* <p>
* The separate classes are nested within this class.
*/
abstract class CharsetRecog_2022 extends CharsetRecognizer {
/**
* Matching function shared among the 2022 detectors JP, CN and KR
* Counts up the number of legal an unrecognized escape sequences in
* the sample of text, and computes a score based on the total number &
* the proportion that fit the encoding.
*
* @param text the byte buffer containing text to analyse
* @param textLen the size of the text in the byte.
* @param escapeSequences the byte escape sequences to test for.
* @return match quality, in the range of 0-100.
*/
int match(byte[] text, int textLen, byte[][] escapeSequences) {
int i, j;
int escN;
int hits = 0;
int misses = 0;
int shifts = 0;
int quality;
scanInput:
for (i = 0; i < textLen; i++) {
if (text[i] == 0x1b) {
checkEscapes:
for (escN = 0; escN < escapeSequences.length; escN++) {
byte[] seq = escapeSequences[escN];
if ((textLen - i) < seq.length) {
continue;
}
for (j = 1; j < seq.length; j++) {
if (seq[j] != text[i + j]) {
continue checkEscapes;
}
}
hits++;
i += seq.length - 1;
continue scanInput;
}
misses++;
}
if (text[i] == 0x0e || text[i] == 0x0f) {
// Shift in/out
shifts++;
}
}
if (hits == 0) {
return 0;
}
//
// Initial quality is based on relative proportion of recongized vs.
// unrecognized escape sequences.
// All good: quality = 100;
// half or less good: quality = 0;
// linear inbetween.
quality = (100 * hits - 100 * misses) / (hits + misses);
// Back off quality if there were too few escape sequences seen.
// Include shifts in this computation, so that KR does not get penalized
// for having only a single Escape sequence, but many shifts.
if (hits + shifts < 5) {
quality -= (5 - (hits + shifts)) * 10;
}
if (quality < 0) {
quality = 0;
}
return quality;
}
static class CharsetRecog_2022JP extends CharsetRecog_2022 {
private final byte[][] escapeSequences = {
{0x1b, 0x24, 0x28, 0x43}, // KS X 1001:1992
{0x1b, 0x24, 0x28, 0x44}, // JIS X 212-1990
{0x1b, 0x24, 0x40}, // JIS C 6226-1978
{0x1b, 0x24, 0x41}, // GB 2312-80
{0x1b, 0x24, 0x42}, // JIS X 208-1983
{0x1b, 0x26, 0x40}, // JIS X 208 1990, 1997
{0x1b, 0x28, 0x42}, // ASCII
{0x1b, 0x28, 0x48}, // JIS-Roman
{0x1b, 0x28, 0x49}, // Half-width katakana
{0x1b, 0x28, 0x4a}, // JIS-Roman
{0x1b, 0x2e, 0x41}, // ISO 8859-1
{0x1b, 0x2e, 0x46} // ISO 8859-7
};
@Override
String getName() {
return "ISO-2022-JP";
}
@Override
CharsetMatch match(CharsetDetector det) {
int confidence = match(det.fInputBytes, det.fInputLen, escapeSequences);
return confidence == 0 ? null : new CharsetMatch(det, this, confidence);
}
}
static class CharsetRecog_2022KR extends CharsetRecog_2022 {
private final byte[][] escapeSequences = {
{0x1b, 0x24, 0x29, 0x43}
};
@Override
String getName() {
return "ISO-2022-KR";
}
@Override
CharsetMatch match(CharsetDetector det) {
int confidence = match(det.fInputBytes, det.fInputLen, escapeSequences);
return confidence == 0 ? null : new CharsetMatch(det, this, confidence);
}
}
static class CharsetRecog_2022CN extends CharsetRecog_2022 {
private final byte[][] escapeSequences = {
{0x1b, 0x24, 0x29, 0x41}, // GB 2312-80
{0x1b, 0x24, 0x29, 0x47}, // CNS 11643-1992 Plane 1
{0x1b, 0x24, 0x2A, 0x48}, // CNS 11643-1992 Plane 2
{0x1b, 0x24, 0x29, 0x45}, // ISO-IR-165
{0x1b, 0x24, 0x2B, 0x49}, // CNS 11643-1992 Plane 3
{0x1b, 0x24, 0x2B, 0x4A}, // CNS 11643-1992 Plane 4
{0x1b, 0x24, 0x2B, 0x4B}, // CNS 11643-1992 Plane 5
{0x1b, 0x24, 0x2B, 0x4C}, // CNS 11643-1992 Plane 6
{0x1b, 0x24, 0x2B, 0x4D}, // CNS 11643-1992 Plane 7
{0x1b, 0x4e}, // SS2
{0x1b, 0x4f}, // SS3
};
@Override
String getName() {
return "ISO-2022-CN";
}
@Override
CharsetMatch match(CharsetDetector det) {
int confidence = match(det.fInputBytes, det.fInputLen, escapeSequences);
return confidence == 0 ? null : new CharsetMatch(det, this, confidence);
}
}
}
@@ -0,0 +1,99 @@
// © 2016 and later: Unicode, Inc. and others.
// License & terms of use: http://www.unicode.org/copyright.html
/**
* ******************************************************************************
* Copyright (C) 2005 - 2014, International Business Machines Corporation and *
* others. All Rights Reserved. *
* ******************************************************************************
*/
package io.legato.kazusa.lib.icu4j;
/**
* Charset recognizer for UTF-8
*/
class CharsetRecog_UTF8 extends CharsetRecognizer {
@Override
String getName() {
return "UTF-8";
}
/* (non-Javadoc)
* @see com.ibm.icu.text.CharsetRecognizer#match(com.ibm.icu.text.CharsetDetector)
*/
@Override
CharsetMatch match(CharsetDetector det) {
boolean hasBOM = false;
int numValid = 0;
int numInvalid = 0;
byte[] input = det.fRawInput;
int i;
int trailBytes = 0;
int confidence;
if (det.fRawLength >= 3 &&
(input[0] & 0xFF) == 0xef && (input[1] & 0xFF) == 0xbb && (input[2] & 0xFF) == 0xbf) {
hasBOM = true;
}
// Scan for multi-byte sequences
for (i = 0; i < det.fRawLength; i++) {
int b = input[i];
if ((b & 0x80) == 0) {
continue; // ASCII
}
// Hi bit on char found. Figure out how long the sequence should be
if ((b & 0x0e0) == 0x0c0) {
trailBytes = 1;
} else if ((b & 0x0f0) == 0x0e0) {
trailBytes = 2;
} else if ((b & 0x0f8) == 0xf0) {
trailBytes = 3;
} else {
numInvalid++;
continue;
}
// Verify that we've got the right number of trail bytes in the sequence
for (; ; ) {
i++;
if (i >= det.fRawLength) {
break;
}
b = input[i];
if ((b & 0xc0) != 0x080) {
numInvalid++;
break;
}
if (--trailBytes == 0) {
numValid++;
break;
}
}
}
// Cook up some sort of confidence score, based on presense of a BOM
// and the existence of valid and/or invalid multi-byte sequences.
confidence = 0;
if (hasBOM && numInvalid == 0) {
confidence = 100;
} else if (hasBOM && numValid > numInvalid * 10) {
confidence = 80;
} else if (numValid > 3 && numInvalid == 0) {
confidence = 100;
} else if (numValid > 0 && numInvalid == 0) {
confidence = 80;
} else if (numValid == 0 && numInvalid == 0) {
// Plain ASCII. Confidence must be > 10, it's more likely than UTF-16, which
// accepts ASCII with confidence = 10.
// TODO: add plain ASCII as an explicitly detected type.
confidence = 15;
} else if (numValid > numInvalid * 10) {
// Probably corruput utf-8 data. Valid sequences aren't likely by chance.
confidence = 25;
}
return confidence == 0 ? null : new CharsetMatch(det, this, confidence);
}
}
@@ -0,0 +1,198 @@
// © 2016 and later: Unicode, Inc. and others.
// License & terms of use: http://www.unicode.org/copyright.html
/*
*******************************************************************************
* Copyright (C) 1996-2013, International Business Machines Corporation and *
* others. All Rights Reserved. *
*******************************************************************************
*
*/
package io.legato.kazusa.lib.icu4j;
/**
* This class matches UTF-16 and UTF-32, both big- and little-endian. The
* BOM will be used if it is present.
*/
abstract class CharsetRecog_Unicode extends CharsetRecognizer {
/* (non-Javadoc)
* @see com.ibm.icu.text.CharsetRecognizer#getName()
*/
@Override
abstract String getName();
/* (non-Javadoc)
* @see com.ibm.icu.text.CharsetRecognizer#match(com.ibm.icu.text.CharsetDetector)
*/
@Override
abstract CharsetMatch match(CharsetDetector det);
static int codeUnit16FromBytes(byte hi, byte lo) {
return ((hi & 0xff) << 8) | (lo & 0xff);
}
// UTF-16 confidence calculation. Very simple minded, but better than nothing.
// Any 8 bit non-control characters bump the confidence up. These have a zero high byte,
// and are very likely to be UTF-16, although they could also be part of a UTF-32 code.
// NULs are a contra-indication, they will appear commonly if the actual encoding is UTF-32.
// NULs should be rare in actual text.
static int adjustConfidence(int codeUnit, int confidence) {
if (codeUnit == 0) {
confidence -= 10;
} else if ((codeUnit >= 0x20 && codeUnit <= 0xff) || codeUnit == 0x0a) {
confidence += 10;
}
if (confidence < 0) {
confidence = 0;
} else if (confidence > 100) {
confidence = 100;
}
return confidence;
}
static class CharsetRecog_UTF_16_BE extends CharsetRecog_Unicode {
@Override
String getName() {
return "UTF-16BE";
}
@Override
CharsetMatch match(CharsetDetector det) {
byte[] input = det.fRawInput;
int confidence = 10;
int bytesToCheck = Math.min(input.length, 30);
for (int charIndex = 0; charIndex < bytesToCheck - 1; charIndex += 2) {
int codeUnit = codeUnit16FromBytes(input[charIndex], input[charIndex + 1]);
if (charIndex == 0 && codeUnit == 0xFEFF) {
confidence = 100;
break;
}
confidence = adjustConfidence(codeUnit, confidence);
if (confidence == 0 || confidence == 100) {
break;
}
}
if (bytesToCheck < 4 && confidence < 100) {
confidence = 0;
}
if (confidence > 0) {
return new CharsetMatch(det, this, confidence);
}
return null;
}
}
static class CharsetRecog_UTF_16_LE extends CharsetRecog_Unicode {
@Override
String getName() {
return "UTF-16LE";
}
@Override
CharsetMatch match(CharsetDetector det) {
byte[] input = det.fRawInput;
int confidence = 10;
int bytesToCheck = Math.min(input.length, 30);
for (int charIndex = 0; charIndex < bytesToCheck - 1; charIndex += 2) {
int codeUnit = codeUnit16FromBytes(input[charIndex + 1], input[charIndex]);
if (charIndex == 0 && codeUnit == 0xFEFF) {
confidence = 100;
break;
}
confidence = adjustConfidence(codeUnit, confidence);
if (confidence == 0 || confidence == 100) {
break;
}
}
if (bytesToCheck < 4 && confidence < 100) {
confidence = 0;
}
if (confidence > 0) {
return new CharsetMatch(det, this, confidence);
}
return null;
}
}
static abstract class CharsetRecog_UTF_32 extends CharsetRecog_Unicode {
abstract int getChar(byte[] input, int index);
@Override
abstract String getName();
@Override
CharsetMatch match(CharsetDetector det) {
byte[] input = det.fRawInput;
int limit = (det.fRawLength / 4) * 4;
int numValid = 0;
int numInvalid = 0;
boolean hasBOM = false;
int confidence = 0;
if (limit == 0) {
return null;
}
if (getChar(input, 0) == 0x0000FEFF) {
hasBOM = true;
}
for (int i = 0; i < limit; i += 4) {
int ch = getChar(input, i);
if (ch < 0 || ch >= 0x10FFFF || (ch >= 0xD800 && ch <= 0xDFFF)) {
numInvalid += 1;
} else {
numValid += 1;
}
}
// Cook up some sort of confidence score, based on presence of a BOM
// and the existence of valid and/or invalid multi-byte sequences.
if (hasBOM && numInvalid == 0) {
confidence = 100;
} else if (hasBOM && numValid > numInvalid * 10) {
confidence = 80;
} else if (numValid > 3 && numInvalid == 0) {
confidence = 100;
} else if (numValid > 0 && numInvalid == 0) {
confidence = 80;
} else if (numValid > numInvalid * 10) {
// Probably corrupt UTF-32BE data. Valid sequences aren't likely by chance.
confidence = 25;
}
return confidence == 0 ? null : new CharsetMatch(det, this, confidence);
}
}
static class CharsetRecog_UTF_32_BE extends CharsetRecog_UTF_32 {
@Override
int getChar(byte[] input, int index) {
return (input[index + 0] & 0xFF) << 24 | (input[index + 1] & 0xFF) << 16 |
(input[index + 2] & 0xFF) << 8 | (input[index + 3] & 0xFF);
}
@Override
String getName() {
return "UTF-32BE";
}
}
static class CharsetRecog_UTF_32_LE extends CharsetRecog_UTF_32 {
@Override
int getChar(byte[] input, int index) {
return (input[index + 3] & 0xFF) << 24 | (input[index + 2] & 0xFF) << 16 |
(input[index + 1] & 0xFF) << 8 | (input[index + 0] & 0xFF);
}
@Override
String getName() {
return "UTF-32LE";
}
}
}
@@ -0,0 +1,552 @@
// © 2016 and later: Unicode, Inc. and others.
// License & terms of use: http://www.unicode.org/copyright.html
/*
****************************************************************************
* Copyright (C) 2005-2012, International Business Machines Corporation and *
* others. All Rights Reserved. *
****************************************************************************
*
*/
package io.legato.kazusa.lib.icu4j;
import java.util.Arrays;
/**
* CharsetRecognizer implemenation for Asian - double or multi-byte - charsets.
* Match is determined mostly by the input data adhering to the
* encoding scheme for the charset, and, optionally,
* frequency-of-occurence of characters.
* <p/>
* Instances of this class are singletons, one per encoding
* being recognized. They are created in the main
* CharsetDetector class and kept in the global list of available
* encodings to be checked. The specific encoding being recognized
* is determined by subclass.
*/
abstract class CharsetRecog_mbcs extends CharsetRecognizer {
/**
* Get the IANA name of this charset.
*
* @return the charset name.
*/
@Override
abstract String getName();
/**
* Test the match of this charset with the input text data
* which is obtained via the CharsetDetector object.
*
* @param det The CharsetDetector, which contains the input text
* to be checked for being in this charset.
* @return Two values packed into one int (Damn java, anyhow)
* <br/>
* bits 0-7: the match confidence, ranging from 0-100
* <br/>
* bits 8-15: The match reason, an enum-like value.
*/
int match(CharsetDetector det, int[] commonChars) {
@SuppressWarnings("unused")
int singleByteCharCount = 0; //TODO Do we really need this?
int doubleByteCharCount = 0;
int commonCharCount = 0;
int badCharCount = 0;
int totalCharCount = 0;
int confidence = 0;
iteratedChar iter = new iteratedChar();
detectBlock:
{
for (iter.reset(); nextChar(iter, det); ) {
totalCharCount++;
if (iter.error) {
badCharCount++;
} else {
long cv = iter.charValue & 0xFFFFFFFFL;
if (cv <= 0xff) {
singleByteCharCount++;
} else {
doubleByteCharCount++;
if (commonChars != null) {
// NOTE: This assumes that there are no 4-byte common chars.
if (Arrays.binarySearch(commonChars, (int) cv) >= 0) {
commonCharCount++;
}
}
}
}
if (badCharCount >= 2 && badCharCount * 5 >= doubleByteCharCount) {
// Bail out early if the byte data is not matching the encoding scheme.
break detectBlock;
}
}
if (doubleByteCharCount <= 10 && badCharCount == 0) {
// Not many multi-byte chars.
if (doubleByteCharCount == 0 && totalCharCount < 10) {
// There weren't any multibyte sequences, and there was a low density of non-ASCII single bytes.
// We don't have enough data to have any confidence.
// Statistical analysis of single byte non-ASCII charcters would probably help here.
confidence = 0;
} else {
// ASCII or ISO file? It's probably not our encoding,
// but is not incompatible with our encoding, so don't give it a zero.
confidence = 10;
}
break detectBlock;
}
//
// No match if there are too many characters that don't fit the encoding scheme.
// (should we have zero tolerance for these?)
//
if (doubleByteCharCount < 20 * badCharCount) {
confidence = 0;
break detectBlock;
}
if (commonChars == null) {
// We have no statistics on frequently occuring characters.
// Assess confidence purely on having a reasonable number of
// multi-byte characters (the more the better
confidence = 30 + doubleByteCharCount - 20 * badCharCount;
if (confidence > 100) {
confidence = 100;
}
} else {
//
// Frequency of occurence statistics exist.
//
double maxVal = Math.log((float) doubleByteCharCount / 4);
double scaleFactor = 90.0 / maxVal;
confidence = (int) (Math.log(commonCharCount + 1) * scaleFactor + 10);
confidence = Math.min(confidence, 100);
}
} // end of detectBlock:
return confidence;
}
// "Character" iterated character class.
// Recognizers for specific mbcs encodings make their "characters" available
// by providing a nextChar() function that fills in an instance of iteratedChar
// with the next char from the input.
// The returned characters are not converted to Unicode, but remain as the raw
// bytes (concatenated into an int) from the codepage data.
//
// For Asian charsets, use the raw input rather than the input that has been
// stripped of markup. Detection only considers multi-byte chars, effectively
// stripping markup anyway, and double byte chars do occur in markup too.
//
static class iteratedChar {
int charValue = 0; // 1-4 bytes from the raw input data
int nextIndex = 0;
boolean error = false;
boolean done = false;
void reset() {
charValue = 0;
nextIndex = 0;
error = false;
done = false;
}
int nextByte(CharsetDetector det) {
if (nextIndex >= det.fRawLength) {
done = true;
return -1;
}
return det.fRawInput[nextIndex++] & 0x00ff;
}
}
/**
* Get the next character (however many bytes it is) from the input data
* Subclasses for specific charset encodings must implement this function
* to get characters according to the rules of their encoding scheme.
* <p>
* This function is not a method of class iteratedChar only because
* that would require a lot of extra derived classes, which is awkward.
*
* @param it The iteratedChar "struct" into which the returned char is placed.
* @param det The charset detector, which is needed to get at the input byte data
* being iterated over.
* @return True if a character was returned, false at end of input.
*/
abstract boolean nextChar(iteratedChar it, CharsetDetector det);
/**
* Shift-JIS charset recognizer.
*/
static class CharsetRecog_sjis extends CharsetRecog_mbcs {
static int[] commonChars =
// TODO: This set of data comes from the character frequency-
// of-occurence analysis tool. The data needs to be moved
// into a resource and loaded from there.
{0x8140, 0x8141, 0x8142, 0x8145, 0x815b, 0x8169, 0x816a, 0x8175, 0x8176, 0x82a0,
0x82a2, 0x82a4, 0x82a9, 0x82aa, 0x82ab, 0x82ad, 0x82af, 0x82b1, 0x82b3, 0x82b5,
0x82b7, 0x82bd, 0x82be, 0x82c1, 0x82c4, 0x82c5, 0x82c6, 0x82c8, 0x82c9, 0x82cc,
0x82cd, 0x82dc, 0x82e0, 0x82e7, 0x82e8, 0x82e9, 0x82ea, 0x82f0, 0x82f1, 0x8341,
0x8343, 0x834e, 0x834f, 0x8358, 0x835e, 0x8362, 0x8367, 0x8375, 0x8376, 0x8389,
0x838a, 0x838b, 0x838d, 0x8393, 0x8e96, 0x93fa, 0x95aa};
@Override
boolean nextChar(iteratedChar it, CharsetDetector det) {
it.error = false;
int firstByte;
firstByte = it.charValue = it.nextByte(det);
if (firstByte < 0) {
return false;
}
if (firstByte <= 0x7f || (firstByte > 0xa0 && firstByte <= 0xdf)) {
return true;
}
int secondByte = it.nextByte(det);
if (secondByte < 0) {
return false;
}
it.charValue = (firstByte << 8) | secondByte;
if (!((secondByte >= 0x40 && secondByte <= 0x7f) || (secondByte >= 0x80 && secondByte <= 0xff))) {
// Illegal second byte value.
it.error = true;
}
return true;
}
@Override
CharsetMatch match(CharsetDetector det) {
int confidence = match(det, commonChars);
return confidence == 0 ? null : new CharsetMatch(det, this, confidence);
}
@Override
String getName() {
return "Shift_JIS";
}
@Override
public String getLanguage() {
return "ja";
}
}
/**
* Big5 charset recognizer.
*/
static class CharsetRecog_big5 extends CharsetRecog_mbcs {
static int[] commonChars =
// TODO: This set of data comes from the character frequency-
// of-occurence analysis tool. The data needs to be moved
// into a resource and loaded from there.
{0xa140, 0xa141, 0xa142, 0xa143, 0xa147, 0xa149, 0xa175, 0xa176, 0xa440, 0xa446,
0xa447, 0xa448, 0xa451, 0xa454, 0xa457, 0xa464, 0xa46a, 0xa46c, 0xa477, 0xa4a3,
0xa4a4, 0xa4a7, 0xa4c1, 0xa4ce, 0xa4d1, 0xa4df, 0xa4e8, 0xa4fd, 0xa540, 0xa548,
0xa558, 0xa569, 0xa5cd, 0xa5e7, 0xa657, 0xa661, 0xa662, 0xa668, 0xa670, 0xa6a8,
0xa6b3, 0xa6b9, 0xa6d3, 0xa6db, 0xa6e6, 0xa6f2, 0xa740, 0xa751, 0xa759, 0xa7da,
0xa8a3, 0xa8a5, 0xa8ad, 0xa8d1, 0xa8d3, 0xa8e4, 0xa8fc, 0xa9c0, 0xa9d2, 0xa9f3,
0xaa6b, 0xaaba, 0xaabe, 0xaacc, 0xaafc, 0xac47, 0xac4f, 0xacb0, 0xacd2, 0xad59,
0xaec9, 0xafe0, 0xb0ea, 0xb16f, 0xb2b3, 0xb2c4, 0xb36f, 0xb44c, 0xb44e, 0xb54c,
0xb5a5, 0xb5bd, 0xb5d0, 0xb5d8, 0xb671, 0xb7ed, 0xb867, 0xb944, 0xbad8, 0xbb44,
0xbba1, 0xbdd1, 0xc2c4, 0xc3b9, 0xc440, 0xc45f};
@Override
boolean nextChar(iteratedChar it, CharsetDetector det) {
it.error = false;
int firstByte;
firstByte = it.charValue = it.nextByte(det);
if (firstByte < 0) {
return false;
}
if (firstByte <= 0x7f || firstByte == 0xff) {
// single byte character.
return true;
}
int secondByte = it.nextByte(det);
if (secondByte < 0) {
return false;
}
it.charValue = (it.charValue << 8) | secondByte;
if (secondByte < 0x40 ||
secondByte == 0x7f ||
secondByte == 0xff) {
it.error = true;
}
return true;
}
@Override
CharsetMatch match(CharsetDetector det) {
int confidence = match(det, commonChars);
return confidence == 0 ? null : new CharsetMatch(det, this, confidence);
}
@Override
String getName() {
return "Big5";
}
@Override
public String getLanguage() {
return "zh";
}
}
/**
* EUC charset recognizers. One abstract class that provides the common function
* for getting the next character according to the EUC encoding scheme,
* and nested derived classes for EUC_KR, EUC_JP, EUC_CN.
*/
abstract static class CharsetRecog_euc extends CharsetRecog_mbcs {
/*
* (non-Javadoc)
* Get the next character value for EUC based encodings.
* Character "value" is simply the raw bytes that make up the character
* packed into an int.
*/
@Override
boolean nextChar(iteratedChar it, CharsetDetector det) {
it.error = false;
int firstByte;
int secondByte;
int thirdByte;
//int fourthByte = 0;
buildChar:
{
firstByte = it.charValue = it.nextByte(det);
if (firstByte < 0) {
// Ran off the end of the input data
it.done = true;
break buildChar;
}
if (firstByte <= 0x8d) {
// single byte char
break buildChar;
}
secondByte = it.nextByte(det);
it.charValue = (it.charValue << 8) | secondByte;
if (firstByte >= 0xA1 && firstByte <= 0xfe) {
// Two byte Char
if (secondByte < 0xa1) {
it.error = true;
}
break buildChar;
}
if (firstByte == 0x8e) {
// Code Set 2.
// In EUC-JP, total char size is 2 bytes, only one byte of actual char value.
// In EUC-TW, total char size is 4 bytes, three bytes contribute to char value.
// We don't know which we've got.
// Treat it like EUC-JP. If the data really was EUC-TW, the following two
// bytes will look like a well formed 2 byte char.
if (secondByte < 0xa1) {
it.error = true;
}
break buildChar;
}
if (firstByte == 0x8f) {
// Code set 3.
// Three byte total char size, two bytes of actual char value.
thirdByte = it.nextByte(det);
it.charValue = (it.charValue << 8) | thirdByte;
if (thirdByte < 0xa1) {
it.error = true;
}
}
}
return (!it.done);
}
/**
* The charset recognize for EUC-JP. A singleton instance of this class
* is created and kept by the public CharsetDetector class
*/
static class CharsetRecog_euc_jp extends CharsetRecog_euc {
static int[] commonChars =
// TODO: This set of data comes from the character frequency-
// of-occurence analysis tool. The data needs to be moved
// into a resource and loaded from there.
{0xa1a1, 0xa1a2, 0xa1a3, 0xa1a6, 0xa1bc, 0xa1ca, 0xa1cb, 0xa1d6, 0xa1d7, 0xa4a2,
0xa4a4, 0xa4a6, 0xa4a8, 0xa4aa, 0xa4ab, 0xa4ac, 0xa4ad, 0xa4af, 0xa4b1, 0xa4b3,
0xa4b5, 0xa4b7, 0xa4b9, 0xa4bb, 0xa4bd, 0xa4bf, 0xa4c0, 0xa4c1, 0xa4c3, 0xa4c4,
0xa4c6, 0xa4c7, 0xa4c8, 0xa4c9, 0xa4ca, 0xa4cb, 0xa4ce, 0xa4cf, 0xa4d0, 0xa4de,
0xa4df, 0xa4e1, 0xa4e2, 0xa4e4, 0xa4e8, 0xa4e9, 0xa4ea, 0xa4eb, 0xa4ec, 0xa4ef,
0xa4f2, 0xa4f3, 0xa5a2, 0xa5a3, 0xa5a4, 0xa5a6, 0xa5a7, 0xa5aa, 0xa5ad, 0xa5af,
0xa5b0, 0xa5b3, 0xa5b5, 0xa5b7, 0xa5b8, 0xa5b9, 0xa5bf, 0xa5c3, 0xa5c6, 0xa5c7,
0xa5c8, 0xa5c9, 0xa5cb, 0xa5d0, 0xa5d5, 0xa5d6, 0xa5d7, 0xa5de, 0xa5e0, 0xa5e1,
0xa5e5, 0xa5e9, 0xa5ea, 0xa5eb, 0xa5ec, 0xa5ed, 0xa5f3, 0xb8a9, 0xb9d4, 0xbaee,
0xbbc8, 0xbef0, 0xbfb7, 0xc4ea, 0xc6fc, 0xc7bd, 0xcab8, 0xcaf3, 0xcbdc, 0xcdd1};
@Override
String getName() {
return "EUC-JP";
}
@Override
CharsetMatch match(CharsetDetector det) {
int confidence = match(det, commonChars);
return confidence == 0 ? null : new CharsetMatch(det, this, confidence);
}
@Override
public String getLanguage() {
return "ja";
}
}
/**
* The charset recognize for EUC-KR. A singleton instance of this class
* is created and kept by the public CharsetDetector class
*/
static class CharsetRecog_euc_kr extends CharsetRecog_euc {
static int[] commonChars =
// TODO: This set of data comes from the character frequency-
// of-occurence analysis tool. The data needs to be moved
// into a resource and loaded from there.
{0xb0a1, 0xb0b3, 0xb0c5, 0xb0cd, 0xb0d4, 0xb0e6, 0xb0ed, 0xb0f8, 0xb0fa, 0xb0fc,
0xb1b8, 0xb1b9, 0xb1c7, 0xb1d7, 0xb1e2, 0xb3aa, 0xb3bb, 0xb4c2, 0xb4cf, 0xb4d9,
0xb4eb, 0xb5a5, 0xb5b5, 0xb5bf, 0xb5c7, 0xb5e9, 0xb6f3, 0xb7af, 0xb7c2, 0xb7ce,
0xb8a6, 0xb8ae, 0xb8b6, 0xb8b8, 0xb8bb, 0xb8e9, 0xb9ab, 0xb9ae, 0xb9cc, 0xb9ce,
0xb9fd, 0xbab8, 0xbace, 0xbad0, 0xbaf1, 0xbbe7, 0xbbf3, 0xbbfd, 0xbcad, 0xbcba,
0xbcd2, 0xbcf6, 0xbdba, 0xbdc0, 0xbdc3, 0xbdc5, 0xbec6, 0xbec8, 0xbedf, 0xbeee,
0xbef8, 0xbefa, 0xbfa1, 0xbfa9, 0xbfc0, 0xbfe4, 0xbfeb, 0xbfec, 0xbff8, 0xc0a7,
0xc0af, 0xc0b8, 0xc0ba, 0xc0bb, 0xc0bd, 0xc0c7, 0xc0cc, 0xc0ce, 0xc0cf, 0xc0d6,
0xc0da, 0xc0e5, 0xc0fb, 0xc0fc, 0xc1a4, 0xc1a6, 0xc1b6, 0xc1d6, 0xc1df, 0xc1f6,
0xc1f8, 0xc4a1, 0xc5cd, 0xc6ae, 0xc7cf, 0xc7d1, 0xc7d2, 0xc7d8, 0xc7e5, 0xc8ad};
@Override
String getName() {
return "EUC-KR";
}
@Override
CharsetMatch match(CharsetDetector det) {
int confidence = match(det, commonChars);
return confidence == 0 ? null : new CharsetMatch(det, this, confidence);
}
@Override
public String getLanguage() {
return "ko";
}
}
}
/**
* GB-18030 recognizer. Uses simplified Chinese statistics.
*/
static class CharsetRecog_gb_18030 extends CharsetRecog_mbcs {
/*
* (non-Javadoc)
* Get the next character value for EUC based encodings.
* Character "value" is simply the raw bytes that make up the character
* packed into an int.
*/
@Override
boolean nextChar(iteratedChar it, CharsetDetector det) {
it.error = false;
int firstByte;
int secondByte;
int thirdByte;
int fourthByte;
buildChar:
{
firstByte = it.charValue = it.nextByte(det);
if (firstByte < 0) {
// Ran off the end of the input data
it.done = true;
break buildChar;
}
if (firstByte <= 0x80) {
// single byte char
break buildChar;
}
secondByte = it.nextByte(det);
it.charValue = (it.charValue << 8) | secondByte;
if (firstByte >= 0x81 && firstByte <= 0xFE) {
// Two byte Char
if ((secondByte >= 0x40 && secondByte <= 0x7E) || (secondByte >= 80 && secondByte <= 0xFE)) {
break buildChar;
}
// Four byte char
if (secondByte >= 0x30 && secondByte <= 0x39) {
thirdByte = it.nextByte(det);
if (thirdByte >= 0x81 && thirdByte <= 0xFE) {
fourthByte = it.nextByte(det);
if (fourthByte >= 0x30 && fourthByte <= 0x39) {
it.charValue = (it.charValue << 16) | (thirdByte << 8) | fourthByte;
break buildChar;
}
}
}
it.error = true;
}
}
return (!it.done);
}
static int[] commonChars =
// TODO: This set of data comes from the character frequency-
// of-occurence analysis tool. The data needs to be moved
// into a resource and loaded from there.
{0xa1a1, 0xa1a2, 0xa1a3, 0xa1a4, 0xa1b0, 0xa1b1, 0xa1f1, 0xa1f3, 0xa3a1, 0xa3ac,
0xa3ba, 0xb1a8, 0xb1b8, 0xb1be, 0xb2bb, 0xb3c9, 0xb3f6, 0xb4f3, 0xb5bd, 0xb5c4,
0xb5e3, 0xb6af, 0xb6d4, 0xb6e0, 0xb7a2, 0xb7a8, 0xb7bd, 0xb7d6, 0xb7dd, 0xb8b4,
0xb8df, 0xb8f6, 0xb9ab, 0xb9c9, 0xb9d8, 0xb9fa, 0xb9fd, 0xbacd, 0xbba7, 0xbbd6,
0xbbe1, 0xbbfa, 0xbcbc, 0xbcdb, 0xbcfe, 0xbdcc, 0xbecd, 0xbedd, 0xbfb4, 0xbfc6,
0xbfc9, 0xc0b4, 0xc0ed, 0xc1cb, 0xc2db, 0xc3c7, 0xc4dc, 0xc4ea, 0xc5cc, 0xc6f7,
0xc7f8, 0xc8ab, 0xc8cb, 0xc8d5, 0xc8e7, 0xc9cf, 0xc9fa, 0xcab1, 0xcab5, 0xcac7,
0xcad0, 0xcad6, 0xcaf5, 0xcafd, 0xccec, 0xcdf8, 0xceaa, 0xcec4, 0xced2, 0xcee5,
0xcfb5, 0xcfc2, 0xcfd6, 0xd0c2, 0xd0c5, 0xd0d0, 0xd0d4, 0xd1a7, 0xd2aa, 0xd2b2,
0xd2b5, 0xd2bb, 0xd2d4, 0xd3c3, 0xd3d0, 0xd3fd, 0xd4c2, 0xd4da, 0xd5e2, 0xd6d0};
@Override
String getName() {
return "GB18030";
}
@Override
CharsetMatch match(CharsetDetector det) {
int confidence = match(det, commonChars);
return confidence == 0 ? null : new CharsetMatch(det, this, confidence);
}
@Override
public String getLanguage() {
return "zh";
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,53 @@
// © 2016 and later: Unicode, Inc. and others.
// License & terms of use: http://www.unicode.org/copyright.html
/**
* ******************************************************************************
* Copyright (C) 2005-2012, International Business Machines Corporation and *
* others. All Rights Reserved. *
* ******************************************************************************
*/
package io.legato.kazusa.lib.icu4j;
/**
* Abstract class for recognizing a single charset.
* Part of the implementation of ICU's CharsetDetector.
* <p>
* Each specific charset that can be recognized will have an instance
* of some subclass of this class. All interaction between the overall
* CharsetDetector and the stuff specific to an individual charset happens
* via the interface provided here.
* <p>
* Instances of CharsetDetector DO NOT have or maintain
* state pertaining to a specific match or detect operation.
* The WILL be shared by multiple instances of CharsetDetector.
* They encapsulate const charset-specific information.
*/
abstract class CharsetRecognizer {
/**
* Get the IANA name of this charset.
*
* @return the charset name.
*/
abstract String getName();
/**
* Get the ISO language code for this charset.
*
* @return the language code, or <code>null</code> if the language cannot be determined.
*/
public String getLanguage() {
return null;
}
/**
* Test the match of this charset with the input text data
* which is obtained via the CharsetDetector object.
*
* @param det The CharsetDetector, which contains the input text
* to be checked for being in this charset.
* @return A CharsetMatch object containing details of match
* with this charset, or null if there was no match.
*/
abstract CharsetMatch match(CharsetDetector det);
}
@@ -0,0 +1,140 @@
package io.legato.kazusa.lib.mobi
import io.legato.kazusa.lib.mobi.entities.KF6Section
import io.legato.kazusa.lib.mobi.entities.MobiEntryHeaders
import io.legato.kazusa.lib.mobi.entities.NCX
import io.legato.kazusa.lib.mobi.entities.TOC
import java.nio.CharBuffer
/**
* Kindle Format 6 Book
*/
class KF6Book(
pdbFile: PDBFile,
headers: MobiEntryHeaders,
kf8BoundaryOffset: Int,
resourceStart: Int
) : MobiBook(pdbFile, headers, kf8BoundaryOffset, resourceStart) {
lateinit var sections: List<KF6Section>
lateinit var sectionIdMap: LinkedHashMap<Int, ArrayList<TOC>>
init {
processSections()
processNCX()
processSectionsMap()
}
fun getResourceByHref(href: String): ByteArray? {
val recindex = href.substringAfter("recindex:").toIntOrNull() ?: return null
return getResource(recindex - 1).array()
}
fun getSectionText(section: KF6Section): String {
val inputStream = getTextRecordInputStream()
val byteArray = ByteArray(section.length)
inputStream.skip(section.start.toLong())
inputStream.read(byteArray)
return String(byteArray, charset)
}
fun getSectionByHref(href: String): KF6Section? {
val index = getIndexByHref(href)
return sections.getOrNull(index)
}
private fun processSectionsMap() {
sectionIdMap = linkedMapOf()
if (toc == null) {
return
}
fun fmap(item: TOC) {
val index = getIndexByHref(item.href)
if (index == -1) return
val array = sectionIdMap.getOrPut(index) { arrayListOf() }
array.add(item)
item.subitems?.forEach(::fmap)
}
toc!!.forEach(::fmap)
}
private fun getIndexByHref(href: String): Int {
val filepos = href.substringAfter("filepos:").toIntOrNull() ?: return -1
return sections.indexOfFirst { it.end > filepos }
}
private fun processNCX() {
val ncx = getNCX() ?: return
fun fmap(item: NCX): TOC {
val filepos = item.offset!!
val href = "filepos:${filepos.toString().padStart(10, '0')}"
return TOC(item.label, href, item.children?.map(::fmap))
}
toc = ncx.map(::fmap)
}
private fun processSections() {
val sections = arrayListOf<KF6Section>()
val pattern = mbpPagebreakRegex.toPattern()
val inputStream = getTextRecordInputStream()
val available = inputStream.available()
val reader = inputStream.reader(Charsets.ISO_8859_1)
var buffer = CharBuffer.allocate(4096)
reader.read(buffer)
buffer.flip()
val matcher = pattern.matcher(buffer)
var droppedOffset = 0
var nextStart = 0
var position = 0
while (true) {
if (!matcher.find()) {
buffer.position(position)
if (buffer.limit() == buffer.capacity()) {
if (position > 0) {
buffer.compact()
} else {
val newBuf = CharBuffer.allocate(buffer.capacity() * 2)
newBuf.put(buffer)
buffer = newBuf
}
droppedOffset += position
position = 0
}
if (reader.read(buffer) == -1) {
break
}
buffer.flip()
matcher.reset(buffer)
} else {
val last = sections.lastOrNull()
val index = sections.size
val start = nextStart
val end = matcher.start() + droppedOffset
nextStart = matcher.end() + droppedOffset
position = matcher.end()
val length = end - start
val href = "filepos:${start.toString().padStart(10, '0')}"
val section = KF6Section(index, start, end, length, href)
last?.next = section
sections.add(section)
}
}
if (nextStart > 0) {
val last = sections.lastOrNull()
val index = sections.size
val start = nextStart
val length = available - start
val href = "filepos:${start.toString().padStart(10, '0')}"
val section = KF6Section(index, start, available, length, href)
last?.next = section
sections.add(section)
}
this.sections = sections
}
companion object {
val mbpPagebreakRegex = "(?i)<\\s*(?:mbp:)?pagebreak[^>]*>".toRegex()
}
}
@@ -0,0 +1,272 @@
package io.legato.kazusa.lib.mobi
import io.legato.kazusa.lib.mobi.entities.FdstHeader
import io.legato.kazusa.lib.mobi.entities.Fragment
import io.legato.kazusa.lib.mobi.entities.KF8Pos
import io.legato.kazusa.lib.mobi.entities.KF8Resource
import io.legato.kazusa.lib.mobi.entities.KF8Section
import io.legato.kazusa.lib.mobi.entities.MobiEntryHeaders
import io.legato.kazusa.lib.mobi.entities.NCX
import io.legato.kazusa.lib.mobi.entities.Skeleton
import io.legato.kazusa.lib.mobi.entities.TOC
import io.legato.kazusa.lib.mobi.utils.readString
import io.legato.kazusa.lib.mobi.utils.readUInt32
import java.nio.ByteBuffer
import java.util.Locale
/**
* Kindle Format 8 Book
*/
@Suppress("SpellCheckingInspection")
class KF8Book(
pdbFile: PDBFile,
headers: MobiEntryHeaders,
kf8BoundaryOffset: Int,
resourceStart: Int
) : MobiBook(pdbFile, headers, kf8BoundaryOffset, resourceStart) {
private var fdstTableStarts: IntArray? = null
private var fdstTableEnds: IntArray? = null
private lateinit var skelTable: List<Skeleton>
private lateinit var fragTable: List<Fragment>
private var kf8 = headers.kf8!!
lateinit var sections: List<KF8Section>
lateinit var sectionIdMap: LinkedHashMap<Int, ArrayList<TOC>>
init {
readFdstTable()
readSkelTable()
readFragTable()
processSections()
processNCX()
processSectionsMap()
}
/**
* 建立 section id -> toc 的 map
*/
private fun processSectionsMap() {
sectionIdMap = linkedMapOf()
if (toc == null) {
return
}
fun fmap(item: TOC) {
val index = getIndexByHref(item.href)
if (index == -1) return
val array = sectionIdMap.getOrPut(index) { arrayListOf() }
array.add(item)
item.subitems?.forEach(::fmap)
}
toc!!.forEach(::fmap)
}
fun parsePosURI(href: String): KF8Pos? {
val match = kindlePosRegex.find(href) ?: return null
val fid = match.groupValues[1].toInt(32)
val off = match.groupValues[2].toInt(32)
return KF8Pos(fid, off)
}
private fun parseResourceURI(href: String): KF8Resource? {
val match = kindleResourceRegex.find(href) ?: return null
val resourceType = match.groupValues[1]
val id = match.groupValues[2].toInt(32)
val type = match.groupValues[3]
return KF8Resource(resourceType, id, type)
}
fun getResourceByHref(href: String): ByteArray? {
val resource = parseResourceURI(href) ?: return null
if (resource.resourceType == "flow") return null
return getResource(resource.id - 1).array()
}
fun getSectionByHref(href: String): KF8Section? {
val index = getIndexByHref(href)
return sections.getOrNull(index)
}
private fun getIndexByHref(href: String): Int {
val pos = parsePosURI(href) ?: return -1
return getIndexByFID(pos.fid)
}
private fun getIndexByFID(fid: Int): Int {
return sections.indexOfFirst {
it.frags.any { frag ->
frag.index == fid
}
}
}
fun getTextByHref(href: String, nextHref: String): String {
val pos = parsePosURI(href) ?: return ""
val nextPos = parsePosURI(nextHref) ?: return ""
val index = getIndexByFID(pos.fid)
val nextIndex = getIndexByFID(nextPos.fid)
val startFid = pos.fid
val endFid = if (index == nextIndex) nextPos.fid else Int.MAX_VALUE
val section = sections[index]
val skel = section.skeleton
val droppedFrags = section.frags.filter { it.index < startFid }
var droppedFragsLength = droppedFrags.sumOf { it.length }
val frags = section.frags.filter { it.index in startFid..endFid }
val length = skel.length + frags.sumOf { it.length }
val raw = getRaw(skel.offset, section.length)
val lastFragDroppedLength =
if (index == nextIndex) frags.last().length - nextPos.offset else 0
val skeleton = ByteArray(length - pos.offset - lastFragDroppedLength)
var leftBytes = skeleton.size
raw.copyInto(skeleton, 0, 0, skel.length)
leftBytes -= skel.length
for ((i, frag) in frags.withIndex()) {
val isFirstFrag = i == 0
val isLastFrag = i == frags.lastIndex
val insertOffset = frag.insertOffset - skel.offset - droppedFragsLength
val offset = skel.length + frag.offset
skeleton.copyInto(
skeleton,
insertOffset + frag.length -
(if (isFirstFrag) pos.offset else 0) -
(if (isLastFrag) lastFragDroppedLength else 0),
insertOffset,
skeleton.size - leftBytes
)
raw.copyInto(
skeleton,
insertOffset,
offset + if (isFirstFrag) pos.offset else 0,
offset + frag.length - if (isLastFrag) lastFragDroppedLength else 0
)
leftBytes -= frag.length -
(if (isFirstFrag) pos.offset else 0) -
(if (isLastFrag && index == nextIndex) nextPos.offset else 0)
if (isFirstFrag) {
droppedFragsLength += pos.offset
}
}
return String(skeleton, charset)
}
fun getSectionText(section: KF8Section): String {
val skel = section.skeleton
val frags = section.frags
val length = section.length
val raw = getRaw(skel.offset, length)
val skeleton = ByteArray(raw.size)
var leftBytes = raw.size
raw.copyInto(skeleton, 0, 0, skel.length)
leftBytes -= skel.length
for (frag in frags) {
val insertOffset = frag.insertOffset - skel.offset
val offset = skel.length + frag.offset
skeleton.copyInto(
skeleton,
insertOffset + frag.length,
insertOffset,
skeleton.size - leftBytes
)
raw.copyInto(skeleton, insertOffset, offset, offset + frag.length)
leftBytes -= frag.length
}
return String(skeleton, charset)
}
private fun getRaw(offset: Int, len: Int): ByteArray {
val inputStream = getTextRecordInputStream()
val byteArray = ByteArray(len)
inputStream.skip(offset.toLong())
inputStream.read(byteArray)
return byteArray
}
private fun processNCX() {
val ncx = getNCX() ?: return
fun fmap(item: NCX): TOC {
val (fid, off) = item.pos!!
val href = makePosURI(fid, off)
return TOC(item.label, href, item.children?.map(::fmap))
}
toc = ncx.map(::fmap)
}
private fun makePosURI(fid: Int, off: Int): String {
val encodedFid = fid.toString(32).uppercase(Locale.ROOT).padStart(4, '0')
val encodedOff = off.toString(32).uppercase(Locale.ROOT).padStart(10, '0')
return "kindle:pos:fid:$encodedFid:off:$encodedOff"
}
private fun processSections() {
sections = skelTable.fold(arrayListOf()) { arr, skel ->
val last = arr.lastOrNull()
val index = arr.size
val fragStart = last?.fragEnd ?: 0
val fragEnd = fragStart + skel.numFrag
val frags = fragTable.slice(fragStart..<fragEnd)
val length = skel.length + frags.sumOf { it.length }
val totalLength = (last?.totalLength ?: 0) + length
val href = frags.firstOrNull()?.let { makePosURI(it.index, 0) } ?: ""
val section = KF8Section(index, skel, frags, fragEnd, length, totalLength, href)
last?.next = section
arr.add(section)
arr
}
}
private fun readFragTable() {
val fragData = getIndexData(kf8.frag)
fragTable = fragData.table.map { indexEntry ->
val tagMap = indexEntry.tagMap
Fragment(
indexEntry.label.toInt(),
fragData.cncx[tagMap[2].tagValues[0]],
tagMap[4].tagValues[0],
tagMap[6].tagValues[0],
tagMap[6].tagValues[1]
)
}
}
private fun readSkelTable() {
skelTable = getIndexData(kf8.skel).table.mapIndexed { index, indexEntry ->
val tagMap = indexEntry.tagMap
Skeleton(
index,
indexEntry.label,
tagMap[1].tagValues[0],
tagMap[6].tagValues[0],
tagMap[6].tagValues[1],
)
}
}
private fun readFdstTable() {
try {
val fdstBuffer = getRecord(kf8.fdst)
val fdstHeader = readFdstHeader(fdstBuffer)
fdstTableStarts = IntArray(fdstHeader.numEntries)
fdstTableEnds = IntArray(fdstHeader.numEntries)
fdstBuffer.position(12)
for (i in 0..<fdstHeader.numEntries) {
fdstTableStarts!![i] = fdstBuffer.readUInt32()
fdstTableEnds!![i] = fdstBuffer.readUInt32()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun readFdstHeader(buffer: ByteBuffer): FdstHeader {
val magic = buffer.readString(0, 4)
if (magic != "FDST") error("Missing FDST record")
val numEntries = buffer.readUInt32(8)
return FdstHeader(magic, numEntries)
}
companion object {
val kindlePosRegex = "kindle:pos:fid:(\\w+):off:(\\w+)".toRegex()
val kindleResourceRegex =
"kindle:(flow|embed):(\\w+)(?:\\?mime=(\\w+/[-+.\\w]+))?".toRegex()
}
}
@@ -0,0 +1,446 @@
package io.legato.kazusa.lib.mobi
import android.util.SparseArray
import io.legato.kazusa.lib.mobi.decompress.Decompressor
import io.legato.kazusa.lib.mobi.decompress.HuffcdicDecompressor
import io.legato.kazusa.lib.mobi.decompress.Lz77Decompressor
import io.legato.kazusa.lib.mobi.decompress.PlainDecompressor
import io.legato.kazusa.lib.mobi.entities.IndexData
import io.legato.kazusa.lib.mobi.entities.IndexEntry
import io.legato.kazusa.lib.mobi.entities.IndexTag
import io.legato.kazusa.lib.mobi.entities.IndxHeader
import io.legato.kazusa.lib.mobi.entities.MobiEntryHeaders
import io.legato.kazusa.lib.mobi.entities.MobiMetadata
import io.legato.kazusa.lib.mobi.entities.NCX
import io.legato.kazusa.lib.mobi.entities.Ptagx
import io.legato.kazusa.lib.mobi.entities.TOC
import io.legato.kazusa.lib.mobi.entities.TagxHeader
import io.legato.kazusa.lib.mobi.entities.TagxTag
import io.legato.kazusa.lib.mobi.utils.readString
import io.legato.kazusa.lib.mobi.utils.readUInt16Array
import io.legato.kazusa.lib.mobi.utils.readUInt32
import io.legato.kazusa.lib.mobi.utils.readUInt8
import okhttp3.internal.and
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.nio.ByteBuffer
import java.nio.charset.Charset
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
@Suppress("SpellCheckingInspection")
abstract class MobiBook(
private val pdbFile: PDBFile,
val headers: MobiEntryHeaders,
private val kf8BoundaryOffset: Int,
private val resourceStart: Int,
) {
val mobi = headers.mobi
val palmdoc = headers.palmdoc
private val exth = headers.exth
private val trailingFlags = mobi.trailingFlags
val charset: Charset = when (val charset = mobi.encoding) {
65001 -> Charsets.UTF_8
1252 -> Charset.forName("windows-1252")
else -> error("unknown charset $charset")
}
private val decompressor: Decompressor = when (val compression = palmdoc.compression) {
1 -> PlainDecompressor()
2 -> Lz77Decompressor(max(4096, palmdoc.recordSize))
17480 -> HuffcdicDecompressor(this, mobi)
else -> error("unknown compression $charset")
}
@Suppress("UNCHECKED_CAST")
val metadata: MobiMetadata by lazy {
MobiMetadata(
mobi.uid.toString(),
exth["title"] as? String ?: mobi.title,
exth["creator"] as? List<String> ?: emptyList(),
exth["publisher"] as? String ?: "",
exth["language"] as? String ?: mobi.languege,
exth["date"] as? String ?: "",
exth["description"] as? String ?: "",
exth["subject"] as? List<String> ?: emptyList(),
exth["rights"] as? String ?: ""
)
}
var toc: List<TOC>? = null
private var textRecordOffsets = arrayListOf<Int>()
init {
buildTextRecordOffsets()
}
private fun buildTextRecordOffsets() {
var offset = 0
for (i in 0..<palmdoc.numTextRecords) {
offset += getTextRecord(i).size
textRecordOffsets.add(offset)
}
}
fun getRecord(index: Int): ByteBuffer {
return pdbFile.getRecordData(kf8BoundaryOffset + index)
}
fun getTextRecord(index: Int): ByteArray {
if (index < 0 && index >= palmdoc.numTextRecords) {
throw IndexOutOfBoundsException("Text record index out of bounds")
}
var content = getRecord(index + 1).array()
content = removeTrailingEntries(content)
return decompressor.decompress(content)
}
fun getTextRecordInputStream(): InputStream {
return object : InputStream() {
private var index = -1
private var bis: ByteArrayInputStream = emptyByteArrayInputStream
private var available = textRecordOffsets.last()
private var pos = 0
override fun read(): Int {
if (index >= palmdoc.numTextRecords) {
return -1
}
if (bis.available() == 0) {
if (++index >= palmdoc.numTextRecords) {
return -1
}
bis = getTextRecord(index).inputStream()
}
val b = bis.read()
available--
pos++
return b
}
override fun skip(n: Long): Long {
if (n == 0L) return 0L
val n1 = min(available, n.toInt())
if (n1 < bis.available()) {
bis.skip(n1.toLong())
available -= n1
pos += n1
return n1.toLong()
}
val bIndex = textRecordOffsets.binarySearch(pos + n1)
index = abs(bIndex + 1)
bis = getTextRecord(index).inputStream()
val offset = textRecordOffsets.getOrNull(index - 1) ?: 0
bis.skip((pos + n1 - offset).toLong())
available -= n1
pos += n1
return n1.toLong()
}
override fun available(): Int {
return available
}
}
}
private fun removeTrailingEntries(byteArray: ByteArray): ByteArray {
if (trailingFlags == 0) return byteArray
val multibyte = trailingFlags and 1 != 0
val numTrailingEntries = (trailingFlags shr 1).countOneBits()
val lastIndex = byteArray.lastIndex
var extraSize = 0
for (i in 0..<numTrailingEntries) {
var value = 0
for (j in max(0, lastIndex - 4 - extraSize)..max(0, lastIndex - extraSize)) {
val byte = byteArray[j]
if (byte and 0b1000_0000 != 0) {
value = 0
}
value = (value shl 7) or (byte and 0b111_1111)
}
extraSize += value
}
if (multibyte) {
val byte = byteArray[byteArray.lastIndex - extraSize]
extraSize += (byte and 0b11) + 1
}
return byteArray.copyOfRange(0, byteArray.size - extraSize)
}
fun getResource(index: Int): ByteBuffer {
return pdbFile.getRecordData(resourceStart + index)
}
fun getNCX(): List<NCX>? {
val indxIndex = mobi.indx
if (indxIndex == -1) {
return null
}
val indexData = getIndexData(indxIndex)
val items = indexData.table.mapIndexed { index, indexEntry ->
val tagMap = indexEntry.tagMap
NCX(
index,
tagMap[1]?.tagValues?.getOrNull(0),
tagMap[2]?.tagValues?.getOrNull(0),
indexData.cncx[tagMap[3].tagValues[0]],
tagMap[4]?.tagValues?.getOrNull(0),
tagMap[6]?.tagValues,
tagMap[21]?.tagValues?.getOrNull(0),
tagMap[22]?.tagValues?.getOrNull(0),
tagMap[23]?.tagValues?.getOrNull(0),
)
}
val parentItemMap = hashMapOf<Int, ArrayList<NCX>>()
items.forEach {
val parent = it.parent ?: return@forEach
val array = parentItemMap.getOrPut(parent) { arrayListOf() }
array.add(it)
}
fun getChildren(item: NCX): NCX {
if (item.firstChild == null) return item
item.children = parentItemMap[item.index]?.map(::getChildren)
return item
}
return items.filter { it.headingLevel == 0 }.map(::getChildren)
}
fun getCover(): ByteArray? {
val coverOffset = exth["coverOffset"] as? Int
val thumbnailOffset = exth["thumbnailOffset"] as? Int
if (coverOffset != null && coverOffset != -1) {
return getResource(coverOffset).array()
}
if (thumbnailOffset != null && thumbnailOffset != -1) {
return getResource(thumbnailOffset).array()
}
return null
}
fun getIndexData(indxIndex: Int): IndexData {
val indxRecord = getRecord(indxIndex)
val indx = readIndxHeader(indxRecord)
indxRecord.position(indx.length)
val tagxBuffer = indxRecord.slice()
val tagx = readTagxHeader(tagxBuffer)
val tagTable = readTagxTags(tagx, tagxBuffer)
val cncx = readCncx(indxIndex, indx)
val table = arrayListOf<IndexEntry>()
for (i in 0..<indx.numRecords) {
val indxBuffer = getRecord(indxIndex + 1 + i)
val indxHeader = readIndxHeader(indxBuffer)
val idxt = readIdxt(indxBuffer, indxHeader)
for (j in 0..<indxHeader.numRecords) {
val idxtOffset = idxt[j]
val entry = readIndexEntry(indxBuffer, tagx, tagTable, idxtOffset)
table.add(entry)
}
}
return IndexData(table, cncx)
}
private fun readIndexEntry(
indxBuffer: ByteBuffer,
tagx: TagxHeader,
tagTable: List<TagxTag>,
idxtOffset: Int
): IndexEntry {
val array = indxBuffer.array()
val len = indxBuffer.readUInt8(idxtOffset)
val label = indxBuffer.readString(idxtOffset + 1, len)
val ptagxs = arrayListOf<Ptagx>()
val startPos = idxtOffset + 1 + len
var controlByteIndex = 0
var pos = startPos + tagx.numControlBytes
for (tag in tagTable) {
if (tag.controlByte == 1) {
controlByteIndex++
continue
}
val offset = startPos + controlByteIndex
var value = indxBuffer.readUInt8(offset) and tag.bitmask
if (value == tag.bitmask) {
if (tag.bitmask.countOneBits() > 1) {
var v = 0
for (a in pos..<min(pos + 4, array.size)) {
val byte = array[a]
v = (v shl 7) or (byte and 0b111_1111)
pos++
if (byte and 0b1000_0000 != 0) break
}
ptagxs.add(Ptagx(tag.tag, tag.numValues, null, v))
} else {
ptagxs.add(Ptagx(tag.tag, tag.numValues, 1, null))
}
} else {
var mask = tag.bitmask
while ((mask and 1) == 0) {
mask = mask shr 1
value = value shr 1
}
ptagxs.add(Ptagx(tag.tag, tag.numValues, value, null))
}
}
val tags = arrayListOf<IndexTag>()
val tagMap = SparseArray<IndexTag>()
for (ptagx in ptagxs) {
val values = arrayListOf<Int>()
if (ptagx.valueCount != null) {
repeat(ptagx.valueCount * ptagx.tagValueCount) {
var v = 0
for (a in pos..<min(pos + 4, array.size)) {
val byte = array[a]
v = (v shl 7) or (byte and 0b111_1111)
pos++
if (byte and 0b1000_0000 != 0) break
}
values.add(v)
}
} else {
var count = 0
while (count < ptagx.valueBytes!!) {
var v = 0
for (a in pos..<min(pos + 4, array.size)) {
val byte = array[a]
v = (v shl 7) or (byte and 0b111_1111)
pos++
count++
if (byte and 0b1000_0000 != 0) break
}
values.add(v)
}
}
val tag = IndexTag(ptagx.tag, values)
tags.add(tag)
tagMap[tag.tagId] = tag
}
return IndexEntry(label, tags, tagMap)
}
private fun readIdxt(buffer: ByteBuffer, indxHeader: IndxHeader): IntArray {
return buffer.readUInt16Array(indxHeader.idxt + 4, indxHeader.numRecords)
}
private fun readTagxTags(tagx: TagxHeader, tagxBuffer: ByteBuffer): List<TagxTag> {
val numTags = (tagx.length - 12) / 4
val tags = arrayListOf<TagxTag>()
tagxBuffer.position(12)
for (i in 0..<numTags) {
val tag = tagxBuffer.readUInt8()
val numValues = tagxBuffer.readUInt8()
val bitmask = tagxBuffer.readUInt8()
val controlByte = tagxBuffer.readUInt8()
tags.add(TagxTag(tag, numValues, bitmask, controlByte))
}
return tags
}
private fun readCncx(indxIndex: Int, indx: IndxHeader): SparseArray<String> {
val cncx = SparseArray<String>()
var cncxRecordOffset = 0
for (i in 0..<indx.numCncx) {
val record = getRecord(indxIndex + indx.numRecords + i + 1)
val array = record.array()
var pos = 0
while (pos < array.size) {
val index = pos
var value = 0
var length = 0
for (a in pos..<min(pos + 4, array.size)) {
val byte = array[a]
value = (value shl 7) or (byte and 0b111_1111)
length++
if (byte and 0b1000_0000 != 0) break
}
pos += length
val result = record.readString(pos, value, charset)
pos += value
cncx[cncxRecordOffset + index] = result
}
cncxRecordOffset += 0x10000
}
return cncx
}
private fun readIndxHeader(indx: ByteBuffer): IndxHeader {
val magic = indx.readString(0, 4)
if (magic != "INDX") {
error("Invalid INDX record")
}
val length = indx.readUInt32(4)
val type = indx.readUInt32(8)
val idxt = indx.readUInt32(20)
val numRecords = indx.readUInt32(24)
val encoding = indx.readUInt32(28)
val language = indx.readUInt32(32)
val total = indx.readUInt32(36)
val ordt = indx.readUInt32(40)
val ligt = indx.readUInt32(44)
val numLigt = indx.readUInt32(48)
val numCncx = indx.readUInt32(52)
return IndxHeader(
magic, length, type, idxt, numRecords, encoding, language, total, ordt,
ligt, numLigt, numCncx
)
}
private fun readTagxHeader(buffer: ByteBuffer): TagxHeader {
val magic = buffer.readString(0, 4)
if (magic != "TAGX") {
error("Invalid INDX record")
}
val length = buffer.readUInt32(4)
val numControlBytes = buffer.readUInt32(8)
return TagxHeader(magic, length, numControlBytes)
}
fun close() {
pdbFile.close()
}
protected fun finalize() {
close()
}
companion object {
private val emptyByteArrayInputStream = ByteArrayInputStream(ByteArray(0))
}
}
@@ -0,0 +1,232 @@
package io.legato.kazusa.lib.mobi
import android.os.ParcelFileDescriptor
import io.legato.kazusa.lib.mobi.entities.ExthRecordType
import io.legato.kazusa.lib.mobi.entities.KF8Header
import io.legato.kazusa.lib.mobi.entities.MobiEntryHeaders
import io.legato.kazusa.lib.mobi.entities.MobiHeader
import io.legato.kazusa.lib.mobi.entities.PalmDocHeader
import io.legato.kazusa.lib.mobi.utils.readString
import io.legato.kazusa.lib.mobi.utils.readUInt16
import io.legato.kazusa.lib.mobi.utils.readUInt32
import io.legato.kazusa.lib.mobi.utils.readUInt8
import java.nio.ByteBuffer
import java.nio.charset.Charset
class MobiReader {
fun readMobi(pfd: ParcelFileDescriptor): MobiBook {
val pdbFile = PDBFile(pfd)
val record0 = pdbFile.getRecordData(0)
var mobiEntryHeaders = readMobiEntryHeaders(record0)
val mobi = mobiEntryHeaders.mobi
val exth = mobiEntryHeaders.exth
val resourceStart = mobi.resourceStart
var isKF8 = mobi.version >= 8
var kf8BoundaryOffset = 0
if (!isKF8) {
val boundary = exth["boundary"] as? Int
if (boundary != null && boundary != -1) {
try {
val buffer = pdbFile.getRecordData(boundary)
mobiEntryHeaders = readMobiEntryHeaders(buffer)
kf8BoundaryOffset = boundary
isKF8 = true
} catch (e: Exception) {
e.printStackTrace()
}
}
}
return if (isKF8) {
KF8Book(pdbFile, mobiEntryHeaders, kf8BoundaryOffset, resourceStart)
} else {
KF6Book(pdbFile, mobiEntryHeaders, kf8BoundaryOffset, resourceStart)
}
}
private fun readMobiEntryHeaders(buffer: ByteBuffer): MobiEntryHeaders {
val palmDocHeader = readPalmDocHeader(buffer)
val mobiHeader = readMobiHeader(buffer)
val exth = if (mobiHeader.exthFlag and 0b100_0000 != 0) {
buffer.position(mobiHeader.length + 16)
readExth(buffer.slice())
} else {
emptyMap()
}
val kF8Header = if (mobiHeader.version >= 8) {
readKF8Header(buffer)
} else {
null
}
return MobiEntryHeaders(palmDocHeader, mobiHeader, exth, kF8Header)
}
private fun readExth(buffer: ByteBuffer): Map<String, Any> {
val magic = buffer.readString(0, 4)
check(magic == "EXTH") { "Invalid EXTH header" }
val count = buffer.readUInt32(8)
var offset = 12
val map = HashMap<String, Any>()
for (i in 0..<count) {
val type = buffer.readUInt32(offset)
val length = buffer.readUInt32(offset + 4)
if (type in exthRecordTypeMap) {
val exthRecordType = exthRecordTypeMap[type]!!
val name = exthRecordType.name
val data: Any = if (exthRecordType.type == "uint") {
buffer.readUInt32(offset + 8)
} else {
buffer.readString(offset + 8, length - 8)
}
if (exthRecordType.many) {
if (!map.contains(name)) {
map[name] = arrayListOf<String>()
}
@Suppress("UNCHECKED_CAST")
val array = map[name] as ArrayList<String>
array.add(data as String)
} else {
map[name] = data
}
}
offset += length
}
return map
}
private fun readPalmDocHeader(content: ByteBuffer): PalmDocHeader {
val compression = content.readUInt16(0)
val numTextRecords = content.readUInt16(8)
val recordSize = content.readUInt16(10)
val encryption = content.readUInt16(12)
return PalmDocHeader(compression, numTextRecords, recordSize, encryption)
}
private fun readMobiHeader(content: ByteBuffer): MobiHeader {
val identifier = content.readString(16, 4)
check(identifier == "MOBI") { "Missing MOBI header" }
val length = content.readUInt32(20)
val type = content.readUInt32(24)
val encoding = content.readUInt32(28)
val uid = content.readUInt32(32)
val version = content.readUInt32(36)
val titleOffset = content.readUInt32(84)
val titleLength = content.readUInt32(88)
val localeRegion = content.readUInt8(94)
val localeLanguage = content.readUInt8(95)
val resourceStar = content.readUInt32(108)
val huffcdic = content.readUInt32(112)
val numHuffcdic = content.readUInt32(116)
val exthFlag = content.readUInt32(128)
val trailingFlags = content.readUInt32(240)
val indx = content.readUInt32(244)
val charset: Charset = when (encoding) {
65001 -> Charsets.UTF_8
1252 -> Charset.forName("windows-1252")
else -> error("unknown charset $encoding")
}
val title = content.readString(titleOffset, titleLength, charset)
val lang = mobiLangMap[localeLanguage]
val language = lang?.getOrNull(localeRegion shr 2) ?: lang?.first() ?: ""
return MobiHeader(
identifier, length, type, encoding, uid, version, titleOffset, titleLength,
localeRegion, localeLanguage, resourceStar, huffcdic, numHuffcdic, exthFlag,
trailingFlags, indx, title, language
)
}
private fun readKF8Header(content: ByteBuffer): KF8Header {
val fdst = content.readUInt32(192)
val numFdst = content.readUInt32(196)
val frag = content.readUInt32(248)
val skel = content.readUInt32(252)
val guide = content.readUInt32(260)
return KF8Header(fdst, numFdst, frag, skel, guide)
}
companion object {
val exthRecordTypeMap = mapOf(
100 to ExthRecordType("creator", "string", true),
101 to ExthRecordType("publisher"),
103 to ExthRecordType("description"),
104 to ExthRecordType("isbn"),
105 to ExthRecordType("subject", "string", true),
106 to ExthRecordType("date"),
108 to ExthRecordType("contributor", "string", true),
109 to ExthRecordType("rights"),
110 to ExthRecordType("subjectCode", "string", true),
112 to ExthRecordType("source", "string", true),
113 to ExthRecordType("asin"),
121 to ExthRecordType("boundary", "uint"),
122 to ExthRecordType("fixedLayout"),
125 to ExthRecordType("numResources", "uint"),
126 to ExthRecordType("originalResolution"),
127 to ExthRecordType("zeroGutter"),
128 to ExthRecordType("zeroMargin"),
129 to ExthRecordType("coverURI"),
132 to ExthRecordType("regionMagnification"),
201 to ExthRecordType("coverOffset", "uint"),
202 to ExthRecordType("thumbnailOffset", "uint"),
204 to ExthRecordType("creatorSoftware", "uint"),
503 to ExthRecordType("title"),
524 to ExthRecordType("language", "string", true),
527 to ExthRecordType("pageProgressionDirection"),
)
val mobiLangMap = mapOf(
1 to listOf(
"ar", "ar-SA", "ar-IQ", "ar-EG", "ar-LY", "ar-DZ", "ar-MA", "ar-TN", "ar-OM",
"ar-YE", "ar-SY", "ar-JO", "ar-LB", "ar-KW", "ar-AE", "ar-BH", "ar-QA"
),
2 to listOf("bg"), 3 to listOf("ca"),
4 to listOf("zh", "zh-TW", "zh-CN", "zh-HK", "zh-SG"),
5 to listOf("cs"), 6 to listOf("da"),
7 to listOf("de", "de-DE", "de-CH", "de-AT", "de-LU", "de-LI"), 8 to listOf("el"),
9 to listOf(
"en", "en-US", "en-GB", "en-AU", "en-CA", "en-NZ", "en-IE", "en-ZA",
"en-JM", null, "en-BZ", "en-TT", "en-ZW", "en-PH"
),
10 to listOf(
"es", "es-ES", "es-MX", null, "es-GT", "es-CR", "es-PA", "es-DO",
"es-VE", "es-CO", "es-PE", "es-AR", "es-EC", "es-CL", "es-UY", "es-PY",
"es-BO", "es-SV", "es-HN", "es-NI", "es-PR"
),
11 to listOf("fi"),
12 to listOf("fr", "fr-FR", "fr-BE", "fr-CA", "fr-CH", "fr-LU", "fr-MC"),
13 to listOf("he"), 14 to listOf("hu"), 15 to listOf("is"),
16 to listOf("it", "it-IT", "it-CH"), 17 to listOf("ja"), 18 to listOf("ko"),
19 to listOf("nl", "nl-NL", "nl-BE"), 20 to listOf("no", "nb", "nn"),
21 to listOf("pl"), 22 to listOf("pt", "pt-BR", "pt-PT"), 23 to listOf("rm"),
24 to listOf("ro"), 25 to listOf("ru"), 26 to listOf("hr", null, "sr"),
27 to listOf("sk"), 28 to listOf("sq"), 29 to listOf("sv", "sv-SE", "sv-FI"),
30 to listOf("th"), 31 to listOf("tr"), 32 to listOf("ur"), 33 to listOf("id"),
34 to listOf("uk"), 35 to listOf("be"), 36 to listOf("sl"), 37 to listOf("et"),
38 to listOf("lv"), 39 to listOf("lt"), 41 to listOf("fa"), 42 to listOf("vi"),
43 to listOf("hy"), 44 to listOf("az"), 45 to listOf("eu"), 46 to listOf("hsb"),
47 to listOf("mk"), 48 to listOf("st"), 49 to listOf("ts"), 50 to listOf("tn"),
52 to listOf("xh"), 53 to listOf("zu"), 54 to listOf("af"), 55 to listOf("ka"),
56 to listOf("fo"), 57 to listOf("hi"), 58 to listOf("mt"), 59 to listOf("se"),
62 to listOf("ms"), 63 to listOf("kk"), 65 to listOf("sw"),
67 to listOf("uz", null, "uz-UZ"), 68 to listOf("tt"), 69 to listOf("bn"),
70 to listOf("pa"), 71 to listOf("gu"), 72 to listOf("or"), 73 to listOf("ta"),
74 to listOf("te"), 75 to listOf("kn"), 76 to listOf("ml"), 77 to listOf("as"),
78 to listOf("mr"), 79 to listOf("sa"), 82 to listOf("cy", "cy-GB"),
83 to listOf("gl", "gl-ES"), 87 to listOf("kok"), 97 to listOf("ne"),
98 to listOf("fy")
)
}
}
@@ -0,0 +1,49 @@
package io.legato.kazusa.lib.mobi
import android.os.ParcelFileDescriptor
import io.legato.kazusa.lib.mobi.utils.readString
import io.legato.kazusa.lib.mobi.utils.readUInt16
import io.legato.kazusa.lib.mobi.utils.readUInt32
import java.io.FileInputStream
import java.nio.ByteBuffer
import java.nio.channels.FileChannel
class PDBFile(private val pfd: ParcelFileDescriptor) {
private val fc: FileChannel = FileInputStream(pfd.fileDescriptor).channel
private val offsets: IntArray
val name: String
val type: String
val creator: String
val recordCount: Int
init {
var buffer = ByteBuffer.allocate(79)
fc.read(buffer)
name = buffer.readString(0, 32)
type = buffer.readString(60, 4)
creator = buffer.readString(64, 4)
recordCount = buffer.readUInt16(76)
buffer = ByteBuffer.allocate(recordCount * 8)
fc.read(buffer, 78)
offsets = IntArray(recordCount) {
buffer.readUInt32(it * 8)
}
}
fun getRecordData(index: Int): ByteBuffer {
if (index < 0 || index >= recordCount) {
throw IndexOutOfBoundsException("Record index out of bounds")
}
val len = offsets.getOrElse(index + 1) { fc.size().toInt() } - offsets[index]
val buffer = ByteBuffer.allocate(len)
fc.read(buffer, offsets[index].toLong())
return buffer
}
fun close() {
fc.close()
pfd.close()
}
}
@@ -0,0 +1,6 @@
package io.legato.kazusa.lib.mobi.decompress
class CDICEntry(
var data: ByteArray,
var decompressed: Boolean
)
@@ -0,0 +1,7 @@
package io.legato.kazusa.lib.mobi.decompress
interface Decompressor {
fun decompress(data: ByteArray): ByteArray
}
@@ -0,0 +1,133 @@
package io.legato.kazusa.lib.mobi.decompress
import io.legato.kazusa.lib.mobi.MobiBook
import io.legato.kazusa.lib.mobi.entities.MobiHeader
import io.legato.kazusa.lib.mobi.utils.readIntArray
import io.legato.kazusa.lib.mobi.utils.readString
import io.legato.kazusa.lib.mobi.utils.readUInt16
import io.legato.kazusa.lib.mobi.utils.readUInt32
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import kotlin.math.min
@Suppress("SpellCheckingInspection")
class HuffcdicDecompressor(
mobiBook: MobiBook,
mobiHeader: MobiHeader,
) : Decompressor {
private val magic: String
private val offset1: Int
private val offset2: Int
private val table1: IntArray
private val mincodeTable = LongArray(33)
private val maxcodeTable = LongArray(33)
private val dictionary = arrayListOf<CDICEntry>()
init {
val huff = mobiBook.getRecord(mobiHeader.huffcdic)
magic = huff.readString(0, 4)
if (magic != "HUFF") error("Invalid HUFF record")
offset1 = huff.readUInt32(8)
offset2 = huff.readUInt32(12)
table1 = huff.readIntArray(offset1, 256)
huff.position(offset2)
for (i in 1..32) {
val mincode = huff.readUInt32().toLong()
val maxcode = huff.readUInt32().toLong()
mincodeTable[i] = mincode shl (32 - i)
maxcodeTable[i] = ((maxcode + 1) shl (32 - i)) - 1
}
for (i in 1..<mobiHeader.numHuffcdic) {
val record = mobiBook.getRecord(mobiHeader.huffcdic + i)
val magic = record.readString(0, 4)
if (magic != "CDIC") error("Invalid CDIC record")
val length = record.readUInt32(4)
val numEntries = record.readUInt32(8)
val codeLength = record.readUInt32(12)
val n = min(1 shl codeLength, numEntries - dictionary.size)
record.position(length)
val buffer = record.slice()
for (j in 0..<n) {
val offset = buffer.readUInt16(j * 2)
val x = buffer.readUInt16(offset)
val len = x and 0x7fff
val decompressed = x and 0x8000 != 0
val data = ByteArray(len)
buffer.position(offset + 2)
buffer.get(data)
dictionary.add(CDICEntry(data, decompressed))
}
}
}
override fun decompress(data: ByteArray): ByteArray {
val bos = ByteArrayOutputStream()
val buffer = ByteBuffer.wrap(data)
var bitsleft = data.size * 8
var pos = 0
var x = buffer.readUIntX(pos, 8)
var bitcount = 32
while (true) {
if (bitcount <= 0) {
pos += 4
x = buffer.readUIntX(pos, 8)
bitcount += 32
}
val code = (x shr bitcount) and ((1L shl 32) - 1)
val t1 = table1[(code shr 24).toInt()]
var codelen = t1 and 0x1f
var maxcode = (((t1.toLong() shr 8) + 1) shl ((32L - codelen).toInt())) - 1
if (t1 and 0x80 == 0) {
while (code < mincodeTable[codelen]) {
codelen++
}
maxcode = maxcodeTable[codelen]
}
bitcount -= codelen
bitsleft -= codelen
if (bitsleft < 0) {
break
}
val index = (maxcode - code) shr ((32 - codelen))
val entry = dictionary[index.toInt()]
if (!entry.decompressed) {
entry.data = decompress(entry.data)
entry.decompressed = true
}
bos.write(entry.data)
}
return bos.toByteArray()
}
private fun ByteBuffer.readUIntX(offset: Int, maxlen: Int): Long {
position(offset)
var value = 0L
var i = maxlen
var bytesLeft = limit() - position()
while (i-- > 0 && bytesLeft-- > 0) {
value = value or ((get().toLong() and 0xFF) shl (i * 8))
}
return value
}
}
@@ -0,0 +1,49 @@
package io.legato.kazusa.lib.mobi.decompress
import androidx.core.util.Pools.SynchronizedPool
class Lz77Decompressor(private val textRecordSize: Int) : Decompressor {
val pool = SynchronizedPool<ByteArray>(2)
override fun decompress(data: ByteArray): ByteArray {
val out = pool.acquire() ?: ByteArray(textRecordSize)
var i = 0
var o = 0
while (i < data.size) {
var c = data[i++].toInt() and 0x00FF
if (c in 0x01..0x08) {
var j = 0
while (j < c && i + j < data.size) {
out[o++] = data[i + j]
j++
}
i += c
} else if (c <= 0x7f) {
out[o++] = c.toByte()
} else if (c >= 0xC0) {
out[o++] = ' '.code.toByte()
out[o++] = (c xor 0x80).toByte()
} else {
if (i < data.size) {
c = c shl 8 or (data[i++].toInt() and 0xFF)
val length = (c and 0x0007) + 3
val location = (c shr 3) and 0x7FF
if (location in 1..o) {
for (j in 0 until length) {
val idx = o - location
out[o++] = out[idx]
}
}
}
}
}
val result = ByteArray(o)
System.arraycopy(out, 0, result, 0, o)
return result.also { pool.release(out) }
}
}
@@ -0,0 +1,9 @@
package io.legato.kazusa.lib.mobi.decompress
class PlainDecompressor : Decompressor {
override fun decompress(data: ByteArray): ByteArray {
return data
}
}
@@ -0,0 +1,7 @@
package io.legato.kazusa.lib.mobi.entities
data class ExthRecordType(
val name: String,
val type: String = "string",
val many: Boolean = false
)
@@ -0,0 +1,6 @@
package io.legato.kazusa.lib.mobi.entities
data class FdstHeader(
val magic: String,
val numEntries: Int
)
@@ -0,0 +1,9 @@
package io.legato.kazusa.lib.mobi.entities
data class Fragment(
val insertOffset: Int,
val selector: String,
val index: Int,
val offset: Int,
val length: Int
)
@@ -0,0 +1,8 @@
package io.legato.kazusa.lib.mobi.entities
import android.util.SparseArray
data class IndexData(
val table: List<IndexEntry>,
val cncx: SparseArray<String>
)
@@ -0,0 +1,9 @@
package io.legato.kazusa.lib.mobi.entities
import android.util.SparseArray
data class IndexEntry(
val label: String,
val tags: List<IndexTag>,
val tagMap: SparseArray<IndexTag>
)
@@ -0,0 +1,6 @@
package io.legato.kazusa.lib.mobi.entities
data class IndexTag(
val tagId: Int,
val tagValues: List<Int>
)
@@ -0,0 +1,16 @@
package io.legato.kazusa.lib.mobi.entities
data class IndxHeader(
val magic: String,
val length: Int,
val type: Int,
val idxt: Int,
val numRecords: Int,
val encoding: Int,
val language: Int,
val total: Int,
val ordt: Int,
val ligt: Int,
val numLigt: Int,
val numCncx: Int,
)
@@ -0,0 +1,10 @@
package io.legato.kazusa.lib.mobi.entities
data class KF6Section(
val index: Int,
val start: Int,
val end: Int,
val length: Int,
val href: String,
var next: KF6Section? = null
)
@@ -0,0 +1,9 @@
package io.legato.kazusa.lib.mobi.entities
data class KF8Header(
val fdst: Int,
val numFdst: Int,
val frag: Int,
val skel: Int,
val guide: Int,
)
@@ -0,0 +1,6 @@
package io.legato.kazusa.lib.mobi.entities
data class KF8Pos(
val fid: Int,
val offset: Int
)
@@ -0,0 +1,7 @@
package io.legato.kazusa.lib.mobi.entities
data class KF8Resource(
val resourceType: String,
val id: Int,
val type: String
)
@@ -0,0 +1,14 @@
package io.legato.kazusa.lib.mobi.entities
data class KF8Section(
val index: Int,
val skeleton: Skeleton,
val frags: List<Fragment>,
val fragEnd: Int,
val length: Int,
val totalLength: Int,
val href: String,
var next: KF8Section? = null
) {
val linear get() = frags.isNotEmpty()
}
@@ -0,0 +1,8 @@
package io.legato.kazusa.lib.mobi.entities
data class MobiEntryHeaders(
val palmdoc: PalmDocHeader,
val mobi: MobiHeader,
val exth: Map<String, Any>,
val kf8: KF8Header?
)
@@ -0,0 +1,22 @@
package io.legato.kazusa.lib.mobi.entities
data class MobiHeader(
val identifier: String,
val length: Int,
val type: Int,
val encoding: Int,
val uid: Int,
val version: Int,
val titleOffset: Int,
val titleLength: Int,
val localeRegion: Int,
val localeLanguage: Int,
val resourceStart: Int,
val huffcdic: Int,
val numHuffcdic: Int,
val exthFlag: Int,
val trailingFlags: Int,
val indx: Int,
val title: String,
val languege: String
)
@@ -0,0 +1,13 @@
package io.legato.kazusa.lib.mobi.entities
data class MobiMetadata(
val identifier: String,
val title: String,
val author: List<String>,
val publisher: String,
val language: String,
val published: String,
val description: String,
val subject: List<String>,
val rights: String
)
@@ -0,0 +1,14 @@
package io.legato.kazusa.lib.mobi.entities
data class NCX(
val index: Int,
val offset: Int?,
val size: Int?,
val label: String,
val headingLevel: Int?,
val pos: List<Int>?,
val parent: Int?,
val firstChild: Int?,
val lastChild: Int?,
var children: List<NCX>? = null
)
@@ -0,0 +1,8 @@
package io.legato.kazusa.lib.mobi.entities
data class PalmDocHeader(
val compression: Int,
val numTextRecords: Int,
val recordSize: Int,
val encryption: Int
)
@@ -0,0 +1,8 @@
package io.legato.kazusa.lib.mobi.entities
data class Ptagx(
val tag: Int,
val tagValueCount: Int,
val valueCount: Int?,
val valueBytes: Int?
)
@@ -0,0 +1,9 @@
package io.legato.kazusa.lib.mobi.entities
data class Skeleton(
val index: Int,
val name: String,
val numFrag: Int,
val offset: Int,
val length: Int
)
@@ -0,0 +1,7 @@
package io.legato.kazusa.lib.mobi.entities
data class TOC(
val label: String,
val href: String,
val subitems: List<TOC>? = null
)
@@ -0,0 +1,7 @@
package io.legato.kazusa.lib.mobi.entities
data class TagxHeader(
val magic: String,
val length: Int,
val numControlBytes: Int
)
@@ -0,0 +1,8 @@
package io.legato.kazusa.lib.mobi.entities
data class TagxTag(
val tag: Int,
val numValues: Int,
val bitmask: Int,
val controlByte: Int,
)
@@ -0,0 +1,73 @@
package io.legato.kazusa.lib.mobi.utils
import okhttp3.internal.and
import java.nio.ByteBuffer
import java.nio.charset.Charset
fun ByteBuffer.readByteArray(offset: Int, len: Int): ByteArray {
position(offset)
val b = ByteArray(len)
get(b)
return b
}
fun ByteBuffer.readByteArray(len: Int): ByteArray {
val b = ByteArray(len)
get(b)
return b
}
fun ByteBuffer.readIntArray(offset: Int, len: Int): IntArray {
position(offset)
return IntArray(len) {
getInt()
}
}
fun ByteBuffer.readUInt16Array(offset: Int, len: Int): IntArray {
position(offset)
return IntArray(len) {
getShort() and 0xFFFF
}
}
fun ByteBuffer.readString(len: Int): String {
return String(readByteArray(len))
}
fun ByteBuffer.readString(offset: Int, len: Int): String {
return String(readByteArray(offset, len))
}
fun ByteBuffer.readString(offset: Int, len: Int, charset: Charset): String {
return String(readByteArray(offset, len), charset)
}
fun ByteBuffer.readUInt8(offset: Int): Int {
position(offset)
return get() and 0xFF
}
fun ByteBuffer.readUInt8(): Int {
return get() and 0xFF
}
fun ByteBuffer.readUInt16(offset: Int): Int {
position(offset)
return getShort() and 0xFFFF
}
fun ByteBuffer.readUInt32(offset: Int): Int {
position(offset)
return getInt()
}
fun ByteBuffer.readUInt32(): Int {
return getInt()
}
fun ByteBuffer.readUInt64(offset: Int): Long {
position(offset)
return getLong()
}
@@ -0,0 +1,7 @@
package io.legato.kazusa.lib.permission
interface OnErrorCallback {
fun onError(e: Exception)
}
@@ -0,0 +1,7 @@
package io.legato.kazusa.lib.permission
interface OnPermissionsDeniedCallback {
fun onPermissionsDenied(deniedPermissions: Array<String>)
}
@@ -0,0 +1,7 @@
package io.legato.kazusa.lib.permission
interface OnPermissionsGrantedCallback {
fun onPermissionsGranted()
}
@@ -0,0 +1,11 @@
package io.legato.kazusa.lib.permission
interface OnPermissionsResultCallback {
fun onPermissionsGranted()
fun onPermissionsDenied(deniedPermissions: Array<String>?)
fun onError(e: Exception)
}
@@ -0,0 +1,10 @@
package io.legato.kazusa.lib.permission
interface OnRequestPermissionsResultCallback {
fun onRequestPermissionsResult(permissions: Array<String>, grantResults: IntArray)
fun onSettingActivityResult()
fun onError(e: Exception)
}
@@ -0,0 +1,227 @@
package io.legato.kazusa.lib.permission
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import androidx.activity.addCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import io.legato.kazusa.R
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.utils.registerForActivityResult
import io.legato.kazusa.utils.toastOnUi
import kotlinx.coroutines.launch
class PermissionActivity : AppCompatActivity() {
private var rationaleDialog: AlertDialog? = null
private val settingActivityResult =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
onRequestPermissionFinish()
}
private val settingActivityResultAwait =
registerForActivityResult(ActivityResultContracts.StartActivityForResult())
private val requestPermissionResult =
registerForActivityResult(ActivityResultContracts.RequestPermission())
private val requestPermissionsResult =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions())
@SuppressLint("BatteryLife")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val rationale = intent.getStringExtra(KEY_RATIONALE)
val requestCode = intent.getIntExtra(KEY_INPUT_PERMISSIONS_CODE, 1000)
val permissions = intent.getStringArrayExtra(KEY_INPUT_PERMISSIONS)!!
when (intent.getIntExtra(KEY_INPUT_REQUEST_TYPE, Request.TYPE_REQUEST_PERMISSION)) {
//权限请求
Request.TYPE_REQUEST_PERMISSION -> showSettingDialog(permissions, rationale) {
lifecycleScope.launch {
try {
val result = requestPermissionsResult.launch(permissions)
if (result.values.all { it }) {
onRequestPermissionFinish()
} else {
openSettingsActivity()
}
} catch (e: Exception) {
AppLog.put("请求权限出错\n$e", e, true)
RequestPlugins.sRequestCallback?.onError(e)
finish()
}
}
}
//跳转到设置界面
Request.TYPE_REQUEST_SETTING -> showSettingDialog(permissions, rationale) {
openSettingsActivity()
}
//所有文件的管理权限
Request.TYPE_MANAGE_ALL_FILES_ACCESS -> showSettingDialog(permissions, rationale) {
try {
if (Permissions.isManageExternalStorage()) {
val settingIntent =
Intent(
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.parse("package:$packageName")
)
settingActivityResult.launch(settingIntent)
} else {
throw NoStackTraceException("no MANAGE_ALL_FILES_ACCESS_PERMISSION")
}
} catch (e: Exception) {
AppLog.put("请求所有文件的管理权限出错\n$e", e, true)
RequestPlugins.sRequestCallback?.onError(e)
finish()
}
}
Request.TYPE_REQUEST_NOTIFICATIONS -> showSettingDialog(permissions, rationale) {
lifecycleScope.launch {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
&& requestPermissionResult.launch(Permissions.POST_NOTIFICATIONS)
) {
onRequestPermissionFinish()
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
//这种方案适用于 API 26, 即8.0(含8.0)以上可以用
val intent = Intent()
intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS
intent.putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
intent.putExtra(Settings.EXTRA_CHANNEL_ID, applicationInfo.uid)
settingActivityResult.launch(intent)
} else {
openSettingsActivity()
}
} catch (e: Exception) {
AppLog.put("请求通知权限出错\n$e", e, true)
RequestPlugins.sRequestCallback?.onError(e)
finish()
}
}
}
Request.TYPE_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS -> showSettingDialog(
permissions, rationale
) {
lifecycleScope.launch {
try {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
intent.setData(Uri.parse("package:$packageName"))
val className =
"com.android.settings.fuelgauge.RequestIgnoreBatteryOptimizations"
val activities = packageManager.queryIntentActivities(
intent,
PackageManager.MATCH_DEFAULT_ONLY
)
if (activities.any { it.activityInfo.name == className }) {
val component = intent.resolveActivity(packageManager)
if (component.className != className) {
intent.setClassName("com.android.settings", className)
settingActivityResultAwait.launch(intent)
}
}
intent.component = null
settingActivityResult.launch(intent)
} catch (e: Exception) {
AppLog.put("请求后台权限出错\n$e", e, true)
RequestPlugins.sRequestCallback?.onError(e)
finish()
}
}
}
}
onBackPressedDispatcher.addCallback(this) {
}
}
private fun onRequestPermissionFinish() {
RequestPlugins.sRequestCallback?.onSettingActivityResult()
finish()
}
private fun openSettingsActivity() {
try {
val settingIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
settingIntent.data = Uri.fromParts("package", packageName, null)
settingActivityResult.launch(settingIntent)
} catch (e: Exception) {
toastOnUi(R.string.tip_cannot_jump_setting_page)
RequestPlugins.sRequestCallback?.onError(e)
finish()
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
RequestPlugins.sRequestCallback?.onRequestPermissionsResult(
permissions,
grantResults
)
finish()
}
override fun startActivity(intent: Intent) {
super.startActivity(intent)
@Suppress("DEPRECATION")
overridePendingTransition(0, 0)
}
override fun finish() {
super.finish()
@Suppress("DEPRECATION")
overridePendingTransition(0, 0)
}
private fun showSettingDialog(
permissions: Array<String>,
rationale: CharSequence?,
onOk: () -> Unit
) {
rationaleDialog?.dismiss()
if (rationale.isNullOrEmpty()) {
finish()
return
}
rationaleDialog = AlertDialog.Builder(this)
.setTitle(R.string.dialog_title)
.setMessage(rationale)
.setPositiveButton(R.string.dialog_setting) { _, _ ->
onOk.invoke()
}
.setNegativeButton(R.string.dialog_cancel) { _, _ ->
RequestPlugins.sRequestCallback?.onRequestPermissionsResult(
permissions,
IntArray(0)
)
finish()
}.setOnCancelListener {
RequestPlugins.sRequestCallback?.onRequestPermissionsResult(
permissions,
IntArray(0)
)
finish()
}
.show()
}
companion object {
const val KEY_RATIONALE = "KEY_RATIONALE"
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"
}
}
@@ -0,0 +1,90 @@
package io.legato.kazusa.lib.permission
import android.os.Build
@Suppress("unused")
object Permissions {
const val POST_NOTIFICATIONS = "android.permission.POST_NOTIFICATIONS"
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"
const val MANAGE_EXTERNAL_STORAGE = "android.permission.MANAGE_EXTERNAL_STORAGE"
const val ACCESS_MEDIA_LOCATION = "android.permission.ACCESS_MEDIA_LOCATION"
const val REQUEST_IGNORE_BATTERY_OPTIMIZATIONS =
"android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"
object Group {
val STORAGE = if (isManageExternalStorage()) {
arrayOf(MANAGE_EXTERNAL_STORAGE)
} else {
arrayOf(READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE)
}
val CAMERA = arrayOf(Permissions.CAMERA)
val CALENDAR = arrayOf(READ_CALENDAR, WRITE_CALENDAR)
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
)
}
fun isManageExternalStorage(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
}
}
@@ -0,0 +1,72 @@
package io.legato.kazusa.lib.permission
import androidx.annotation.StringRes
@Suppress("unused")
class PermissionsCompat private constructor() {
private var request: Request? = null
fun request() {
RequestManager.pushRequest(request)
}
class Builder {
private val request: Request = Request()
fun addPermissions(vararg permissions: String): Builder {
request.addPermissions(*permissions)
return this
}
fun onGranted(callback: () -> Unit): Builder {
request.setOnGrantedCallback(object : OnPermissionsGrantedCallback {
override fun onPermissionsGranted() {
callback()
}
})
return this
}
fun onDenied(callback: (deniedPermissions: Array<String>) -> Unit): Builder {
request.setOnDeniedCallback(object : OnPermissionsDeniedCallback {
override fun onPermissionsDenied(deniedPermissions: Array<String>) {
callback(deniedPermissions)
}
})
return this
}
fun onError(callback: (e: Exception) -> Unit): Builder {
request.setOnErrorCallBack(object : OnErrorCallback{
override fun onError(e: Exception) {
callback(e)
}
})
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()
return compat
}
}
}
@@ -0,0 +1,228 @@
package io.legato.kazusa.lib.permission
import android.annotation.SuppressLint
import android.content.pm.PackageManager
import android.os.Build
import android.os.Environment
import androidx.annotation.StringRes
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import io.legato.kazusa.utils.startActivity
import splitties.init.appCtx
import splitties.systemservices.powerManager
@Suppress("MemberVisibilityCanBePrivate")
internal class Request : OnRequestPermissionsResultCallback {
internal val requestTime: Long = System.currentTimeMillis()
private var requestCode: Int = TYPE_REQUEST_PERMISSION
private var permissions: ArrayList<String> = ArrayList()
private var grantedCallback: OnPermissionsGrantedCallback? = null
private var deniedCallback: OnPermissionsDeniedCallback? = null
private var errorCallback: OnErrorCallback? = null
private var rationale: CharSequence? = null
private val deniedPermissions: Array<String>?
get() {
return getDeniedPermissions(this.permissions.toTypedArray())
}
fun addPermissions(vararg permissions: String) {
this.permissions.addAll(listOf(*permissions))
}
fun setOnGrantedCallback(callback: OnPermissionsGrantedCallback) {
grantedCallback = callback
}
fun setOnDeniedCallback(callback: OnPermissionsDeniedCallback) {
deniedCallback = callback
}
fun setOnErrorCallBack(callback: OnErrorCallback) {
errorCallback = callback
}
fun setRationale(@StringRes resId: Int) {
rationale = appCtx.getString(resId)
}
fun setRationale(rationale: CharSequence) {
this.rationale = rationale
}
@SuppressLint("ObsoleteSdkInt")
fun start() {
RequestPlugins.setOnRequestPermissionsCallback(this)
val deniedPermissions = deniedPermissions
val rationale = this.rationale
if (deniedPermissions == null) {
onPermissionsGranted()
return
}
if (rationale == null) {
onPermissionsDenied(deniedPermissions)
return
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
toSetting()
} else {
if (deniedPermissions.contains(Permissions.MANAGE_EXTERNAL_STORAGE)) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
toManageFileSetting(deniedPermissions)
}
} else if (deniedPermissions.contains(Permissions.POST_NOTIFICATIONS)) {
toNotificationSetting(deniedPermissions)
} else if (deniedPermissions.contains(Permissions.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
toIgnoreBatterySetting(deniedPermissions)
}
} else if (deniedPermissions.isNotEmpty()) {
appCtx.startActivity<PermissionActivity> {
putExtra(PermissionActivity.KEY_RATIONALE, rationale)
putExtra(PermissionActivity.KEY_INPUT_REQUEST_TYPE, TYPE_REQUEST_PERMISSION)
putExtra(PermissionActivity.KEY_INPUT_PERMISSIONS_CODE, requestCode)
putExtra(PermissionActivity.KEY_INPUT_PERMISSIONS, deniedPermissions)
}
}
}
}
fun clear() {
grantedCallback = null
deniedCallback = null
}
fun getDeniedPermissions(permissions: Array<String>): Array<String>? {
val deniedPermissionList = ArrayList<String>()
for (permission in permissions) {
when (permission) {
Permissions.POST_NOTIFICATIONS -> {
if (!NotificationManagerCompat.from(appCtx).areNotificationsEnabled()) {
deniedPermissionList.add(permission)
}
}
Permissions.MANAGE_EXTERNAL_STORAGE -> {
if (Permissions.isManageExternalStorage()) {
if (!Environment.isExternalStorageManager()) {
deniedPermissionList.add(permission)
}
}
}
Permissions.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (!powerManager.isIgnoringBatteryOptimizations(appCtx.packageName)) {
deniedPermissionList.add(permission)
}
}
}
else -> {
if (
ContextCompat.checkSelfPermission(appCtx, permission)
!= PackageManager.PERMISSION_GRANTED
) {
deniedPermissionList.add(permission)
}
}
}
}
val size = deniedPermissionList.size
if (size > 0) {
return deniedPermissionList.toTypedArray()
}
return null
}
private fun onPermissionsGranted() {
try {
grantedCallback?.onPermissionsGranted()
} catch (ignore: Exception) {
}
RequestPlugins.sResultCallback?.onPermissionsGranted()
}
private fun onPermissionsDenied(deniedPermissions: Array<String>) {
try {
deniedCallback?.onPermissionsDenied(deniedPermissions)
} catch (ignore: Exception) {
}
RequestPlugins.sResultCallback?.onPermissionsDenied(deniedPermissions)
}
private fun toSetting() {
appCtx.startActivity<PermissionActivity> {
putExtra(PermissionActivity.KEY_RATIONALE, rationale)
putExtra(PermissionActivity.KEY_INPUT_REQUEST_TYPE, TYPE_REQUEST_SETTING)
}
}
private fun toManageFileSetting(deniedPermissions: Array<String>) {
appCtx.startActivity<PermissionActivity> {
putExtra(PermissionActivity.KEY_RATIONALE, rationale)
putExtra(PermissionActivity.KEY_INPUT_REQUEST_TYPE, TYPE_MANAGE_ALL_FILES_ACCESS)
putExtra(PermissionActivity.KEY_INPUT_PERMISSIONS_CODE, requestCode)
putExtra(PermissionActivity.KEY_INPUT_PERMISSIONS, deniedPermissions)
}
}
private fun toNotificationSetting(deniedPermissions: Array<String>) {
appCtx.startActivity<PermissionActivity> {
putExtra(PermissionActivity.KEY_RATIONALE, rationale)
putExtra(PermissionActivity.KEY_INPUT_REQUEST_TYPE, TYPE_REQUEST_NOTIFICATIONS)
putExtra(PermissionActivity.KEY_INPUT_PERMISSIONS_CODE, requestCode)
putExtra(PermissionActivity.KEY_INPUT_PERMISSIONS, deniedPermissions)
}
}
private fun toIgnoreBatterySetting(deniedPermissions: Array<String>) {
appCtx.startActivity<PermissionActivity> {
putExtra(PermissionActivity.KEY_RATIONALE, rationale)
putExtra(
PermissionActivity.KEY_INPUT_REQUEST_TYPE,
TYPE_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
)
putExtra(PermissionActivity.KEY_INPUT_PERMISSIONS_CODE, requestCode)
putExtra(PermissionActivity.KEY_INPUT_PERMISSIONS, deniedPermissions)
}
}
override fun onRequestPermissionsResult(
permissions: Array<String>,
grantResults: IntArray
) {
val deniedPermissions = getDeniedPermissions(permissions)
if (deniedPermissions != null) {
onPermissionsDenied(deniedPermissions)
} else {
onPermissionsGranted()
}
}
override fun onSettingActivityResult() {
val deniedPermissions = deniedPermissions
if (deniedPermissions == null) {
onPermissionsGranted()
} else {
onPermissionsDenied(deniedPermissions)
}
}
override fun onError(e: Exception) {
errorCallback?.onError(e)
RequestPlugins.sResultCallback?.onError(e)
}
companion object {
const val TYPE_REQUEST_PERMISSION = 1
const val TYPE_REQUEST_SETTING = 2
const val TYPE_MANAGE_ALL_FILES_ACCESS = 3
const val TYPE_REQUEST_NOTIFICATIONS = 4
const val TYPE_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS = 5
}
}
@@ -0,0 +1,73 @@
package io.legato.kazusa.lib.permission
import android.os.Handler
import android.os.Looper
import java.util.*
internal object RequestManager : OnPermissionsResultCallback {
private var requests: Stack<Request>? = null
private var request: Request? = null
private val handler = Handler(Looper.getMainLooper())
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) {
@Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS")
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() {
startNextRequest()
}
override fun onPermissionsDenied(deniedPermissions: Array<String>?) {
startNextRequest()
}
override fun onError(e: Exception) {
startNextRequest()
}
}
@@ -0,0 +1,20 @@
package io.legato.kazusa.lib.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
}
}
@@ -0,0 +1,444 @@
package io.legato.kazusa.lib.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.PreferenceViewHolder
import com.jaredrummler.android.colorpicker.*
import io.legato.kazusa.utils.ColorUtils
import io.legato.kazusa.utils.applyTint
@Suppress("MemberVisibilityCanBePrivate", "unused")
class ColorPreference(context: Context, attrs: AttributeSet) : Preference(context, attrs),
ColorPickerDialogListener {
var onSaveColor: ((color: Int) -> Boolean)? = null
private val sizeNormal = 0
private val sizeLarge = 1
private var onShowDialogListener: OnShowDialogListener? = null
private var mColor = 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
layoutResource = io.legato.kazusa.R.layout.view_preference
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, sizeNormal)
val presetsResId = a.getResourceId(R.styleable.ColorPreference_cpv_colorPresets, 0)
dialogTitle =
a.getResourceId(R.styleable.ColorPreference_cpv_dialogTitle, R.string.cpv_default_title)
presets = if (presetsResId != 0) {
context.resources.getIntArray(presetsResId)
} else {
ColorPickerDialog.MATERIAL_COLORS
}
widgetLayoutResource = if (colorShape == ColorShape.CIRCLE) {
if (previewSize == sizeLarge) R.layout.cpv_preference_circle_large else R.layout.cpv_preference_circle
} else {
if (previewSize == sizeLarge) 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, mColor)
} else if (showDialog) {
val dialog = ColorPickerDialogCompat.newBuilder()
.setDialogType(dialogType)
.setDialogTitle(dialogTitle)
.setColorShape(colorShape)
.setPresets(presets!!)
.setAllowPresets(allowPresets)
.setAllowCustom(allowCustom)
.setShowAlphaSlider(showAlphaSlider)
.setShowColorShades(showColorShades)
.setColor(mColor)
.create()
dialog.setColorPickerDialogListener(this)
getActivity().supportFragmentManager
.beginTransaction()
.add(dialog, getFragmentTag())
.commitAllowingStateLoss()
}
}
private 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 onBindView(holder: PreferenceViewHolder) {
val v = bindView<ColorPanelView>(
context, holder, icon, title, summary, widgetLayoutResource,
R.id.cpv_preference_preview_color_panel, 30, 30
)
if (v is ColorPanelView) {
v.color = mColor
}
}
override fun onSetInitialValue(defaultValue: Any?) {
super.onSetInitialValue(defaultValue)
if (defaultValue is Int) {
mColor = if (!showAlphaSlider) ColorUtils.withAlpha(defaultValue, 1f) else defaultValue
persistInt(mColor)
} else {
mColor = getPersistedInt(-0x1000000)
}
}
override fun onGetDefaultValue(a: TypedArray, index: Int): Any {
return a.getInteger(index, Color.BLACK)
}
override fun onColorSelected(dialogId: Int, @ColorInt color: Int) {
//返回值为true时说明已经处理过,不再处理
if (onSaveColor?.invoke(color) == true) {
return
}
saveValue(color)
}
override fun onDialogDismissed(dialogId: Int) {
// no-op
}
/**
* Set the new color
*
* @param color The newly selected color
*/
fun saveValue(@ColorInt color: Int) {
mColor = if (showAlphaSlider) color else ColorUtils.withAlpha(color, 1f)
persistInt(mColor)
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?.applyTint()
}
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
}
}
}
}
@@ -0,0 +1,34 @@
package io.legato.kazusa.lib.prefs
import android.content.Context
import android.util.AttributeSet
import android.widget.TextView
import androidx.preference.PreferenceViewHolder
import io.legato.kazusa.R
//import io.legado.app.lib.theme.accentColor
class EditTextPreference(context: Context, attrs: AttributeSet) :
androidx.preference.EditTextPreference(context, attrs) {
private var mOnBindEditTextListener: OnBindEditTextListener? = null
private val onBindEditTextListener = OnBindEditTextListener { editText ->
//editText.applyTint(context.accentColor)
mOnBindEditTextListener?.onBindEditText(editText)
}
init {
// isPersistent = true
layoutResource = R.layout.view_preference
super.setOnBindEditTextListener(onBindEditTextListener)
}
override fun onBindViewHolder(holder: PreferenceViewHolder) {
Preference.bindView<TextView>(context, holder, icon, title, summary, null, null)
super.onBindViewHolder(holder)
}
override fun setOnBindEditTextListener(onBindEditTextListener: OnBindEditTextListener?) {
mOnBindEditTextListener = onBindEditTextListener
}
}
@@ -0,0 +1,65 @@
package io.legato.kazusa.lib.prefs
import android.app.Dialog
import android.os.Bundle
import android.view.Gravity
import android.view.WindowManager
import androidx.preference.EditTextPreferenceDialogFragmentCompat
import androidx.preference.PreferenceDialogFragmentCompat
import io.legato.kazusa.R
import io.legato.kazusa.help.config.AppConfig
//import io.legado.app.lib.theme.accentColor
//import io.legado.app.lib.theme.filletBackground
class EditTextPreferenceDialog : EditTextPreferenceDialogFragmentCompat() {
companion object {
fun newInstance(key: String): EditTextPreferenceDialog {
val fragment = EditTextPreferenceDialog()
val b = Bundle(1)
b.putString(PreferenceDialogFragmentCompat.ARG_KEY, key)
fragment.arguments = b
return fragment
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
// dialog.window?.setBackgroundDrawable(requireContext().filletBackground)
// dialog.window?.decorView?.post {
// (dialog as AlertDialog).run {
// getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor(accentColor)
// getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(accentColor)
// getButton(AlertDialog.BUTTON_NEUTRAL)?.setTextColor(accentColor)
// }
// }
return dialog
}
override fun onStart() {
super.onStart()
if (AppConfig.isEInkMode) {
dialog?.window?.let {
it.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
val attr = it.attributes
attr.dimAmount = 0.0f
attr.windowAnimations = 0
it.attributes = attr
it.setBackgroundDrawableResource(R.color.transparent)
when (attr.gravity) {
Gravity.TOP -> it.decorView.setBackgroundResource(R.drawable.bg_eink_border_bottom)
Gravity.BOTTOM -> it.decorView.setBackgroundResource(R.drawable.bg_eink_border_top)
else -> {
// val padding = 2.dpToPx();
// it.decorView.setPadding(padding, padding, padding, padding)
it.decorView.setBackgroundResource(R.drawable.bg_eink_border_dialog)
}
}
}
}
}
}
@@ -0,0 +1,213 @@
package io.legato.kazusa.lib.prefs
import android.content.Context
import android.content.ContextWrapper
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.fragment.app.FragmentActivity
import androidx.preference.ListPreference
import androidx.preference.PreferenceViewHolder
import androidx.recyclerview.widget.LinearLayoutManager
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseBottomSheetDialogFragment
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter
import io.legato.kazusa.databinding.DialogRecyclerViewBinding
import io.legato.kazusa.databinding.ItemIconPreferenceBinding
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.utils.getCompatDrawable
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
class IconListPreference(context: Context, attrs: AttributeSet) : ListPreference(context, attrs) {
private var iconNames: Array<CharSequence>
private val mEntryDrawables = arrayListOf<Drawable?>()
init {
layoutResource = R.layout.view_preference
widgetLayoutResource = R.layout.view_icon
val a = context.theme.obtainStyledAttributes(attrs, R.styleable.IconListPreference, 0, 0)
iconNames = try {
a.getTextArray(R.styleable.IconListPreference_icons)
} finally {
a.recycle()
}
for (iconName in iconNames) {
val resId = context.resources
.getIdentifier(iconName.toString(), "mipmap", context.packageName)
var d: Drawable? = null
kotlin.runCatching {
d = context.getCompatDrawable(resId)
}
mEntryDrawables.add(d)
}
}
override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)
val v = Preference.bindView<ImageView>(
context,
holder,
icon,
title,
summary,
widgetLayoutResource,
R.id.preview,
50,
50
)
if (v is ImageView) {
val selectedIndex = findIndexOfValue(value)
if (selectedIndex >= 0) {
val drawable = mEntryDrawables[selectedIndex]
v.setImageDrawable(drawable)
}
}
}
override fun onClick() {
getActivity()?.let {
val dialog = IconDialog().apply {
val args = Bundle()
args.putString("value", value)
args.putCharSequenceArray("entries", entries)
args.putCharSequenceArray("entryValues", entryValues)
args.putCharSequenceArray("iconNames", iconNames)
arguments = args
onChanged = { value ->
this@IconListPreference.value = value
}
}
it.supportFragmentManager
.beginTransaction()
.add(dialog, getFragmentTag())
.commitAllowingStateLoss()
}
}
override fun onAttached() {
super.onAttached()
val fragment =
getActivity()?.supportFragmentManager?.findFragmentByTag(getFragmentTag()) as IconDialog?
fragment?.onChanged = { value ->
this@IconListPreference.value = value
}
}
private 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
}
}
return null
}
private fun getFragmentTag(): String {
return "icon_$key"
}
class IconDialog : BaseBottomSheetDialogFragment(R.layout.dialog_recycler_view) {
var onChanged: ((value: String) -> Unit)? = null
var dialogValue: String? = null
var dialogEntries: Array<CharSequence>? = null
var dialogEntryValues: Array<CharSequence>? = null
var dialogIconNames: Array<CharSequence>? = null
private val binding by viewBinding(DialogRecyclerViewBinding::bind)
override fun onStart() {
super.onStart()
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
binding.toolBar.setTitle(R.string.change_icon)
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
val adapter = Adapter(requireContext())
binding.recyclerView.adapter = adapter
arguments?.let {
dialogValue = it.getString("value")
dialogEntries = it.getCharSequenceArray("entries")
dialogEntryValues = it.getCharSequenceArray("entryValues")
dialogIconNames = it.getCharSequenceArray("iconNames")
dialogEntryValues?.let { values ->
adapter.setItems(values.toList())
}
}
}
inner class Adapter(context: Context) :
RecyclerAdapter<CharSequence, ItemIconPreferenceBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemIconPreferenceBinding {
return ItemIconPreferenceBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemIconPreferenceBinding,
item: CharSequence,
payloads: MutableList<Any>
) {
binding.run {
val index = findIndexOfValue(item.toString())
dialogEntries?.let {
label.text = it[index]
}
dialogIconNames?.let {
val resId = context.resources
.getIdentifier(it[index].toString(), "mipmap", context.packageName)
val d = try {
context.getCompatDrawable(resId)
} catch (e: Exception) {
null
}
d?.let {
icon.setImageDrawable(d)
}
}
label.isChecked = item.toString() == dialogValue
root.setOnClickListener {
onChanged?.invoke(item.toString())
this@IconDialog.dismissAllowingStateLoss()
}
}
}
override fun registerListener(
holder: ItemViewHolder,
binding: ItemIconPreferenceBinding
) {
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
onChanged?.invoke(it.toString())
}
}
}
private fun findIndexOfValue(value: String?): Int {
dialogEntryValues?.let { values ->
for (i in values.indices.reversed()) {
if (values[i] == value) {
return i
}
}
}
return -1
}
}
}
}
@@ -0,0 +1,68 @@
package io.legato.kazusa.lib.prefs
import android.app.Dialog
import android.os.Bundle
import android.view.Gravity
import android.view.WindowManager
import androidx.preference.ListPreferenceDialogFragmentCompat
import androidx.preference.PreferenceDialogFragmentCompat
import io.legato.kazusa.R
import io.legato.kazusa.help.config.AppConfig
//import io.legado.app.lib.theme.accentColor
//import io.legado.app.lib.theme.filletBackground
import io.legato.kazusa.utils.dpToPx
class ListPreferenceDialog : ListPreferenceDialogFragmentCompat() {
companion object {
fun newInstance(key: String?): ListPreferenceDialog {
val fragment = ListPreferenceDialog()
val b = Bundle(1)
b.putString(PreferenceDialogFragmentCompat.ARG_KEY, key)
fragment.arguments = b
return fragment
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
// dialog.window?.setBackgroundDrawable(requireContext().filletBackground)
// dialog.window?.decorView?.post {
// (dialog as AlertDialog).run {
// getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor(accentColor)
// getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(accentColor)
// getButton(AlertDialog.BUTTON_NEUTRAL)?.setTextColor(accentColor)
// listView?.forEach {
// it.applyTint(accentColor)
// }
// }
// }
return dialog
}
override fun onStart() {
super.onStart()
if (AppConfig.isEInkMode) {
dialog?.window?.let {
it.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
val attr = it.attributes
attr.dimAmount = 0.0f
attr.windowAnimations = 0
it.attributes = attr
it.setBackgroundDrawableResource(R.color.transparent)
when (attr.gravity) {
Gravity.TOP -> it.decorView.setBackgroundResource(R.drawable.bg_eink_border_bottom)
Gravity.BOTTOM -> it.decorView.setBackgroundResource(R.drawable.bg_eink_border_top)
else -> {
val padding = 2.dpToPx();
it.decorView.setPadding(padding, padding, padding, padding)
it.decorView.setBackgroundResource(R.drawable.bg_eink_border_dialog)
}
}
}
}
}
}
@@ -0,0 +1,70 @@
package io.legato.kazusa.lib.prefs
import android.app.Dialog
import android.os.Bundle
import android.view.Gravity
import android.view.WindowManager
import androidx.preference.MultiSelectListPreferenceDialogFragmentCompat
import androidx.preference.PreferenceDialogFragmentCompat
import io.legato.kazusa.R
import io.legato.kazusa.help.config.AppConfig
//import io.legado.app.lib.theme.accentColor
//import io.legado.app.lib.theme.filletBackground
import io.legato.kazusa.utils.dpToPx
class MultiSelectListPreferenceDialog : MultiSelectListPreferenceDialogFragmentCompat() {
companion object {
fun newInstance(key: String?): MultiSelectListPreferenceDialog {
val fragment =
MultiSelectListPreferenceDialog()
val b = Bundle(1)
b.putString(PreferenceDialogFragmentCompat.ARG_KEY, key)
fragment.arguments = b
return fragment
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
// dialog.window?.setBackgroundDrawable(requireContext().filletBackground)
// dialog.window?.decorView?.post {
// (dialog as AlertDialog).run {
// getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor(accentColor)
// getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(accentColor)
// getButton(AlertDialog.BUTTON_NEUTRAL)?.setTextColor(accentColor)
// listView?.forEach {
// it.applyTint(accentColor)
// }
// }
// }
return dialog
}
override fun onStart() {
super.onStart()
if (AppConfig.isEInkMode) {
dialog?.window?.let {
it.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
val attr = it.attributes
attr.dimAmount = 0.0f
attr.windowAnimations = 0
it.attributes = attr
it.setBackgroundDrawableResource(R.color.transparent)
when (attr.gravity) {
Gravity.TOP -> it.decorView.setBackgroundResource(R.drawable.bg_eink_border_bottom)
Gravity.BOTTOM -> it.decorView.setBackgroundResource(R.drawable.bg_eink_border_top)
else -> {
val padding = 2.dpToPx();
it.decorView.setPadding(padding, padding, padding, padding)
it.decorView.setBackgroundResource(R.drawable.bg_eink_border_dialog)
}
}
}
}
}
}
@@ -0,0 +1,41 @@
package io.legato.kazusa.lib.prefs
import android.content.Context
import android.util.AttributeSet
import android.widget.TextView
import androidx.preference.ListPreference
import androidx.preference.PreferenceViewHolder
import io.legato.kazusa.R
//import io.legado.app.lib.theme.bottomBackground
//import io.legado.app.lib.theme.getPrimaryTextColor
class NameListPreference(context: Context, attrs: AttributeSet) : ListPreference(context, attrs) {
private val isBottomBackground: Boolean
init {
layoutResource = R.layout.view_preference
widgetLayoutResource = R.layout.item_fillet_text
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.Preference)
isBottomBackground = typedArray.getBoolean(R.styleable.Preference_isBottomBackground, false)
typedArray.recycle()
}
override fun onBindViewHolder(holder: PreferenceViewHolder) {
val v = Preference.bindView<TextView>(
context, holder, icon, title, summary, widgetLayoutResource,
R.id.text_view, isBottomBackground = isBottomBackground
)
if (v is TextView) {
v.text = entry
if (isBottomBackground) {
// val bgColor = context.bottomBackground
// val pTextColor = context.getPrimaryTextColor(ColorUtils.isColorLight(bgColor))
//v.setTextColor(pTextColor)
}
}
super.onBindViewHolder(holder)
}
}
@@ -0,0 +1,131 @@
package io.legato.kazusa.lib.prefs
import android.content.Context
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.preference.PreferenceViewHolder
import io.legato.kazusa.R
//import io.legado.app.lib.theme.accentColor
//import io.legado.app.lib.theme.bottomBackground
//import io.legado.app.lib.theme.getPrimaryTextColor
//import io.legado.app.lib.theme.getSecondaryTextColor
import splitties.views.onLongClick
import kotlin.math.roundToInt
open class Preference(context: Context, attrs: AttributeSet) :
androidx.preference.Preference(context, attrs) {
private var onLongClick: ((preference: Preference) -> Boolean)? = null
private val isBottomBackground: Boolean
init {
layoutResource = R.layout.view_preference
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.Preference)
isBottomBackground = typedArray.getBoolean(R.styleable.Preference_isBottomBackground, false)
typedArray.recycle()
}
companion object {
fun <T : View> bindView(
context: Context,
viewHolder: PreferenceViewHolder?,
icon: Drawable?,
title: CharSequence?,
summary: CharSequence?,
weightLayoutRes: Int? = null,
viewId: Int? = null,
weightWidth: Int = 0,
weightHeight: Int = 0,
isBottomBackground: Boolean = false
): T? {
if (viewHolder == null) return null
val tvTitle = viewHolder.findViewById(R.id.preference_title) as? TextView
tvTitle?.let {
tvTitle.text = title
tvTitle.isVisible = !title.isNullOrEmpty()
}
val tvSummary = viewHolder.findViewById(R.id.preference_desc) as? TextView
tvSummary?.let {
tvSummary.text = summary
tvSummary.isGone = summary.isNullOrEmpty()
}
if (isBottomBackground && !viewHolder.itemView.isInEditMode) {
// val isLight = ColorUtils.isColorLight(context.bottomBackground)
// val pTextColor = context.getPrimaryTextColor(isLight)
// tvTitle?.setTextColor(pTextColor)
// val sTextColor = context.getSecondaryTextColor(isLight)
// tvSummary?.setTextColor(sTextColor)
}
val iconView = viewHolder.findViewById(R.id.preference_icon)
if (iconView is ImageView) {
iconView.isVisible = icon != null
iconView.setImageDrawable(icon)
//iconView.setColorFilter(context.accentColor)
}
if (weightLayoutRes != null && weightLayoutRes != 0 && viewId != null && viewId != 0) {
val lay = viewHolder.findViewById(R.id.preference_widget)
if (lay is FrameLayout) {
var needRequestLayout = false
var v = viewHolder.itemView.findViewById<T>(viewId)
if (v == null) {
val inflater: LayoutInflater = LayoutInflater.from(context)
val childView = inflater.inflate(weightLayoutRes, null)
lay.removeAllViews()
lay.addView(childView)
lay.isVisible = true
v = lay.findViewById(viewId)
} else
needRequestLayout = true
if (weightWidth > 0 || weightHeight > 0) {
val lp = lay.layoutParams
if (weightHeight > 0)
lp.height =
(context.resources.displayMetrics.density * weightHeight).roundToInt()
if (weightWidth > 0)
lp.width =
(context.resources.displayMetrics.density * weightWidth).roundToInt()
lay.layoutParams = lp
} else if (needRequestLayout)
v.requestLayout()
return v
}
}
return null
}
}
final override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)
onBindView(holder)
onLongClick?.let { listener ->
holder.itemView.onLongClick {
listener.invoke(this)
}
}
}
open fun onBindView(holder: PreferenceViewHolder) {
bindView<View>(
context, holder, icon, title, summary,
isBottomBackground = isBottomBackground
)
}
fun onLongClick(listener: (preference: Preference) -> Boolean) {
onLongClick = listener
}
}
@@ -0,0 +1,55 @@
package io.legato.kazusa.lib.prefs
import android.content.Context
import android.util.AttributeSet
import android.widget.TextView
import androidx.core.view.isVisible
import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceViewHolder
import io.legato.kazusa.R
//import io.legado.app.lib.theme.accentColor
//import io.legado.app.lib.theme.backgroundColor
class PreferenceCategory(context: Context, attrs: AttributeSet) :
PreferenceCategory(context, attrs) {
init {
isPersistent = true
layoutResource = R.layout.view_preference_category
}
override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)
val view = holder.findViewById(R.id.preference_title)
if (view is TextView) { // && !view.isInEditMode
view.text = title
if (view.isInEditMode) return
//view.setTextColor(context.accentColor)
view.isVisible = !title.isNullOrEmpty()
// val da = holder.findViewById(R.id.preference_divider_above)
// val dividerColor = if (AppConfig.isNightTheme) {
//// ColorUtils.withAlpha(
//// ColorUtils.shiftColor(context.backgroundColor, 1.05f),
//// 0.5f
//// )
// } else {
//// ColorUtils.withAlpha(
//// ColorUtils.shiftColor(context.backgroundColor, 0.95f),
//// 0.5f
//// )
// }
// if (da is View) {
// //da.setBackgroundColor(dividerColor)
// da.isVisible = holder.isDividerAllowedAbove
// }
// val db = holder.findViewById(R.id.preference_divider_below)
// if (db is View) {
// //db.setBackgroundColor(dividerColor)
// db.isVisible = holder.isDividerAllowedBelow
// }
}
}
}
@@ -0,0 +1,47 @@
package io.legato.kazusa.lib.prefs
import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.widget.SwitchCompat
import androidx.preference.PreferenceViewHolder
import androidx.preference.SwitchPreferenceCompat
import io.legato.kazusa.R
//import io.legado.app.lib.theme.accentColor
class SwitchPreference(context: Context, attrs: AttributeSet) :
SwitchPreferenceCompat(context, attrs) {
private val isBottomBackground: Boolean
private var onLongClick: ((preference: SwitchPreference) -> Boolean)? = null
init {
layoutResource = R.layout.view_preference
widgetLayoutResource = R.layout.preference_widget_material_switch
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.Preference)
isBottomBackground = typedArray.getBoolean(R.styleable.Preference_isBottomBackground, false)
typedArray.recycle()
}
override fun onBindViewHolder(holder: PreferenceViewHolder) {
val v = Preference.bindView<SwitchCompat>(
context, holder, icon, title, summary,
widgetLayoutResource,
androidx.preference.R.id.switchWidget,
isBottomBackground = isBottomBackground
)
if (v is SwitchCompat && !v.isInEditMode) {
//v.applyTint(context.accentColor)
}
super.onBindViewHolder(holder)
onLongClick?.let { listener ->
holder.itemView.setOnLongClickListener {
listener.invoke(this)
}
}
}
fun onLongClick(listener: (preference: SwitchPreference) -> Boolean) {
onLongClick = listener
}
}
@@ -0,0 +1,75 @@
package io.legato.kazusa.lib.prefs.fragment
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.View
import androidx.fragment.app.DialogFragment
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.MultiSelectListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import io.legato.kazusa.lib.prefs.EditTextPreferenceDialog
import io.legato.kazusa.lib.prefs.ListPreferenceDialog
import io.legato.kazusa.lib.prefs.MultiSelectListPreferenceDialog
import io.legato.kazusa.utils.applyNavigationBarPadding
abstract class PreferenceFragment : PreferenceFragmentCompat() {
private val dialogFragmentTag = "androidx.preference.PreferenceFragment.DIALOG"
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
listView.clipToPadding = false
listView.applyNavigationBarPadding()
}
@SuppressLint("RestrictedApi")
override fun onDisplayPreferenceDialog(preference: Preference) {
var handled = false
if (callbackFragment is OnPreferenceDisplayDialogCallback) {
handled =
(callbackFragment as OnPreferenceDisplayDialogCallback)
.onPreferenceDisplayDialog(this, preference)
}
if (!handled && activity is OnPreferenceDisplayDialogCallback) {
handled = (activity as OnPreferenceDisplayDialogCallback)
.onPreferenceDisplayDialog(this, preference)
}
if (handled) {
return
}
// check if dialog is already showing
if (parentFragmentManager.findFragmentByTag(dialogFragmentTag) != null) {
return
}
val dialogFragment: DialogFragment = when (preference) {
is EditTextPreference -> {
EditTextPreferenceDialog.newInstance(preference.getKey())
}
is ListPreference -> {
ListPreferenceDialog.newInstance(preference.getKey())
}
is MultiSelectListPreference -> {
MultiSelectListPreferenceDialog.newInstance(preference.getKey())
}
else -> {
throw IllegalArgumentException(
"Cannot display dialog for an unknown Preference type: "
+ preference.javaClass.simpleName
+ ". Make sure to implement onPreferenceDisplayDialog() to handle "
+ "displaying a custom dialog for this Preference."
)
}
}
@Suppress("DEPRECATION")
dialogFragment.setTargetFragment(this, 0)
dialogFragment.show(parentFragmentManager, dialogFragmentTag)
}
}
@@ -0,0 +1,7 @@
@file:Suppress("unused")
package io.legato.kazusa.lib.theme
/**
* @author Karim Abou Zeid (kabouzeid)
*/
@@ -0,0 +1,450 @@
package io.legato.kazusa.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
@Suppress("unused")
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
*/
@Suppress("MemberVisibilityCanBePrivate")
class DrawableSelector {
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 {
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<IntArray>(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)
}
}
}
@@ -0,0 +1,354 @@
package io.legato.kazusa.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.legato.kazusa.utils.ColorUtils
import splitties.init.appCtx
import androidx.core.graphics.toColorInt
/**
* @author Aidan Follestad (afollestad), Karim Abou Zeid (kabouzeid)
*/
@Suppress("unused")
class ThemeStore @SuppressLint("CommitPrefEdits")
private constructor(private val mContext: Context) : ThemeStoreInterface {
private val mEditor = prefs(mContext).edit()
// override fun enableDynamicColors(context: Context, enable: Boolean): ThemeStore {
// mEditor.putBoolean(ThemeStorePrefKeys.KEY_DYNAMIC_COLORS_ENABLED, enable)
// if (enable && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// // 使用系统动态颜色
// val dynamicColors = DynamicColorsOptions.Builder()
// .setThemeOverlay(R.style.Theme_App_Dynamic)
// .build()
// DynamicColors.applyToActivitiesIfAvailable(context.applicationContext, dynamicColors)
// }
// return this
// }
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(ThemeUtils.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(ThemeUtils.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(ThemeUtils.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(ThemeUtils.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(ThemeUtils.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(ThemeUtils.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(ThemeUtils.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(ThemeUtils.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(ThemeUtils.resolveColor(mContext, colorAttr))
}
override fun backgroundColor(color: Int): ThemeStore {
mEditor.putInt(ThemeStorePrefKeys.KEY_BACKGROUND_COLOR, color)
return this
}
override fun bottomBackground(color: Int): ThemeStore {
mEditor.putInt(ThemeStorePrefKeys.KEY_BOTTOM_BACKGROUND, color)
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()
accentColor = accentColor()
}
companion object {
var accentColor = accentColor()
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 = appCtx): Int {
return prefs(context).getInt(
ThemeStorePrefKeys.KEY_PRIMARY_COLOR,
ThemeUtils.resolveColor(
context,
androidx.appcompat.R.attr.colorPrimary,
"#455A64".toColorInt()
)
)
}
@CheckResult
@ColorInt
fun primaryColorDark(context: Context): Int {
return prefs(context).getInt(
ThemeStorePrefKeys.KEY_PRIMARY_COLOR_DARK,
ThemeUtils.resolveColor(
context,
androidx.appcompat.R.attr.colorPrimaryDark,
Color.parseColor("#37474F")
)
)
}
@CheckResult
@ColorInt
fun accentColor(context: Context = appCtx): Int {
return prefs(context).getInt(
ThemeStorePrefKeys.KEY_ACCENT_COLOR,
ThemeUtils.resolveColor(
context,
androidx.appcompat.R.attr.colorAccent,
Color.parseColor("#263238")
)
)
}
@CheckResult
@ColorInt
fun statusBarColor(context: Context, transparent: Boolean): Int {
return 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 prefs(context).getInt(
ThemeStorePrefKeys.KEY_NAVIGATION_BAR_COLOR,
bottomBackground(context)
)
}
@CheckResult
@ColorInt
fun textColorPrimary(context: Context): Int {
return prefs(context).getInt(
ThemeStorePrefKeys.KEY_TEXT_COLOR_PRIMARY,
ThemeUtils.resolveColor(context, android.R.attr.textColorPrimary)
)
}
@CheckResult
@ColorInt
fun textColorPrimaryInverse(context: Context): Int {
return prefs(context).getInt(
ThemeStorePrefKeys.KEY_TEXT_COLOR_PRIMARY_INVERSE,
ThemeUtils.resolveColor(context, android.R.attr.textColorPrimaryInverse)
)
}
@CheckResult
@ColorInt
fun textColorSecondary(context: Context): Int {
return prefs(context).getInt(
ThemeStorePrefKeys.KEY_TEXT_COLOR_SECONDARY,
ThemeUtils.resolveColor(context, android.R.attr.textColorSecondary)
)
}
@CheckResult
@ColorInt
fun textColorSecondaryInverse(context: Context): Int {
return prefs(context).getInt(
ThemeStorePrefKeys.KEY_TEXT_COLOR_SECONDARY_INVERSE,
ThemeUtils.resolveColor(context, android.R.attr.textColorSecondaryInverse)
)
}
@CheckResult
@ColorInt
fun backgroundColor(context: Context = appCtx): Int {
return prefs(context).getInt(
ThemeStorePrefKeys.KEY_BACKGROUND_COLOR,
ThemeUtils.resolveColor(context, android.R.attr.colorBackground)
)
}
@CheckResult
@ColorInt
fun bottomBackground(context: Context = appCtx): Int {
return prefs(context).getInt(
ThemeStorePrefKeys.KEY_BOTTOM_BACKGROUND,
ThemeUtils.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
}
}
}
@@ -0,0 +1,90 @@
package io.legato.kazusa.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
// Background
fun backgroundColor(@ColorInt color: Int): ThemeStore
fun bottomBackground(@ColorInt color: Int): ThemeStore
// Commit/apply
fun apply()
}
@@ -0,0 +1,37 @@
package io.legato.kazusa.lib.theme
/**
* @author Aidan Follestad (afollestad), Karim Abou Zeid (kabouzeid)
*/
object ThemeStorePrefKeys {
const val KEY_DYNAMIC_COLORS_ENABLED = ""
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_BOTTOM_BACKGROUND = "bottomBackground"
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"
const val KEY_COLOR_PRIMARY = "key_color_primary"
const val KEY_COLOR_ON_PRIMARY = "key_color_on_primary"
const val KEY_COLOR_PRIMARY_CONTAINER = "key_color_primary_container"
const val KEY_COLOR_ON_PRIMARY_CONTAINER = "key_color_on_primary_container"
const val KEY_COLOR_SECONDARY = "key_color_secondary"
}
@@ -0,0 +1,44 @@
package io.legato.kazusa.lib.theme
import android.content.Context
import android.graphics.drawable.Drawable
import androidx.annotation.AttrRes
/**
* @author Aidan Follestad (afollestad)
*/
object ThemeUtils {
@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()
}
}
@JvmOverloads
fun resolveFloat(context: Context, @AttrRes attr: Int, fallback: Float = 0.0f): Float {
val a = context.theme.obtainStyledAttributes(intArrayOf(attr))
return try {
a.getFloat(0, fallback)
} catch (e: Exception) {
fallback
} finally {
a.recycle()
}
}
fun resolveDrawable(context: Context, @AttrRes attr: Int): Drawable? {
val a = context.theme.obtainStyledAttributes(intArrayOf(attr))
return try {
a.getDrawable(0)
} finally {
a.recycle()
}
}
}
@@ -0,0 +1,487 @@
package io.legato.kazusa.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.view.View
import android.widget.Button
import android.widget.CheckBox
import android.widget.CheckedTextView
import android.widget.EditText
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.RadioButton
import android.widget.SeekBar
import android.widget.Switch
import android.widget.TextView
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 androidx.core.widget.TextViewCompat
import com.google.android.material.floatingactionbutton.FloatingActionButton
import io.legato.kazusa.R
import io.legato.kazusa.utils.ColorUtils
/**
* @author afollestad, plusCubed
*/
@Suppress("MemberVisibilityCanBePrivate")
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)
androidx.appcompat.R.color.ripple_material_light
else
androidx.appcompat.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
when (view) {
is Button -> {
sl = getDisabledColorStateList(color, disabled)
if (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
)
)
)
}
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 CheckedTextView -> 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<ImageView>(iconId)
if (icon != null) {
setTint(icon, color)
}
}
}
else -> isBg = true
}
if (!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) androidx.appcompat.R.color.ripple_material_dark else androidx.appcompat.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)
}
}
}
}
@SuppressLint("PrivateResource")
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
)
)
radioButton.buttonTintList = 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
)
)
seekBar.thumbTintList = s1
seekBar.progressTintList = s1
}
@JvmOverloads
fun setTint(
progressBar: ProgressBar, @ColorInt color: Int,
skipIndeterminate: Boolean = false
) {
val sl = ColorStateList.valueOf(color)
progressBar.progressTintList = sl
progressBar.secondaryProgressTintList = sl
if (!skipIndeterminate)
progressBar.indeterminateTintList = sl
}
@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)
}
@SuppressLint("PrivateResource")
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
)
)
box.buttonTintList = sl
}
@SuppressLint("PrivateResource")
fun setTint(checkedTextView: CheckedTextView, @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(
checkedTextView.context,
if (useDarker) R.color.ate_control_disabled_dark else R.color.ate_control_disabled_light
),
ContextCompat.getColor(
checkedTextView.context,
if (useDarker) R.color.ate_control_normal_dark else R.color.ate_control_normal_light
),
color
)
)
checkedTextView.checkMarkTintList = sl
TextViewCompat.setCompoundDrawableTintList(checkedTextView, sl)
}
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(
@SuppressLint("UseSwitchCompatOrMaterialCode") 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
}
@SuppressLint("DiscouragedPrivateApi", "SoonBlockedPrivateApi")
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<Drawable>(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) {
}
}
}
@@ -0,0 +1,43 @@
package io.legato.kazusa.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
import io.legato.kazusa.utils.DrawableUtils
/**
* @author Karim Abou Zeid (kabouzeid)
*/
@Suppress("unused")
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
}
}
@@ -0,0 +1,50 @@
package io.legato.kazusa.lib.theme.view
import android.content.Context
import android.graphics.Color
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.ViewCompat
import com.google.android.material.bottomnavigation.BottomNavigationView
import io.legato.kazusa.databinding.ViewNavigationBadgeBinding
import io.legato.kazusa.help.config.AppConfig
//import io.legado.app.lib.theme.bottomBackground
//import io.legado.app.lib.theme.getSecondaryTextColor
import io.legato.kazusa.ui.widget.text.BadgeView
import androidx.core.graphics.drawable.toDrawable
class ThemeBottomNavigationVIew(context: Context, attrs: AttributeSet) :
BottomNavigationView(context, attrs) {
init {
// val bgColor = context.bottomBackground
// setBackgroundColor(bgColor)
// val textIsDark = ColorUtils.isColorLight(bgColor)
// val textColor = context.getSecondaryTextColor(textIsDark)
//
// val colorStateList = Selector.colorBuild()
// .setDefaultColor(textColor)
// .setSelectedColor(ThemeStore.accentColor(context)).create()
// itemIconTintList = colorStateList
// itemTextColor = colorStateList
if (AppConfig.isEInkMode) {
isItemHorizontalTranslationEnabled = false
itemBackground = Color.TRANSPARENT.toDrawable()
}
ViewCompat.setOnApplyWindowInsetsListener(this, null)
}
fun addBadgeView(index: Int): BadgeView {
//获取底部菜单view
val menuView = getChildAt(0) as ViewGroup
//获取第index个itemView
val itemView = menuView.getChildAt(index) as ViewGroup
val badgeBinding = ViewNavigationBadgeBinding.inflate(LayoutInflater.from(context))
itemView.addView(badgeBinding.root)
return badgeBinding.viewBadge
}
}
@@ -0,0 +1,15 @@
package io.legato.kazusa.lib.theme.view
import android.content.Context
import android.util.AttributeSet
import com.google.android.material.checkbox.MaterialCheckBox
//import io.legado.app.lib.theme.accentColor
class ThemeCheckBox(context: Context, attrs: AttributeSet) : MaterialCheckBox(context, attrs) {
// init {
// if (!isInEditMode) {
// applyTint(context.accentColor)
// }
// }
}
@@ -0,0 +1,21 @@
package io.legato.kazusa.lib.theme.view
import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatEditText
//import io.legado.app.lib.theme.accentColor
class ThemeEditText @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : AppCompatEditText(context, attrs) {
// init {
// if (!isInEditMode) {
// applyTint(context.accentColor)
// }
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
// isLocalePreferredLineHeightForMinimumUsed = false
// }
// }
}
@@ -0,0 +1,15 @@
package io.legato.kazusa.lib.theme.view
import android.content.Context
import android.util.AttributeSet
import android.widget.ProgressBar
//import io.legado.app.lib.theme.accentColor
class ThemeProgressBar(context: Context, attrs: AttributeSet) : ProgressBar(context, attrs) {
// init {
// if (!isInEditMode) {
// applyTint(context.accentColor)
// }
// }
}
@@ -0,0 +1,16 @@
package io.legato.kazusa.lib.theme.view
import android.content.Context
import android.util.AttributeSet
import com.google.android.material.radiobutton.MaterialRadioButton
//import io.legado.app.lib.theme.accentColor
class ThemeRadioButton(context: Context, attrs: AttributeSet) :
MaterialRadioButton(context, attrs) {
// init {
// if (!isInEditMode) {
// applyTint(context.accentColor)
// }
// }
}
@@ -0,0 +1,78 @@
package io.legato.kazusa.lib.theme.view
import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.widget.TooltipCompat
import com.google.android.material.radiobutton.MaterialRadioButton
import io.legato.kazusa.R
//import io.legado.app.lib.theme.accentColor
//import io.legado.app.lib.theme.bottomBackground
//import io.legado.app.lib.theme.getPrimaryTextColor
class ThemeRadioNoButton(context: Context, attrs: AttributeSet) :
MaterialRadioButton(context, attrs) {
private val isBottomBackground: Boolean
init {
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.ThemeRadioNoButton)
isBottomBackground =
typedArray.getBoolean(R.styleable.ThemeRadioNoButton_isBottomBackground, false)
typedArray.recycle()
initTheme()
TooltipCompat.setTooltipText(this, text)
}
private fun initTheme() {
// when {
// isInEditMode -> Unit
// isBottomBackground -> {
// val accentColor = context.accentColor
// val isLight = ColorUtils.isColorLight(context.bottomBackground)
// val textColor = context.getPrimaryTextColor(isLight)
// val checkedTextColor = if (ColorUtils.isColorLight(accentColor)) {
// Color.BLACK
// } else {
// Color.WHITE
// }
// background = Selector.shapeBuild()
// .setCornerRadius(2.dpToPx())
// .setStrokeWidth(2.dpToPx())
// .setCheckedBgColor(accentColor)
// .setCheckedStrokeColor(accentColor)
// .setDefaultStrokeColor(textColor)
// .create()
// setTextColor(
// Selector.colorBuild()
// .setDefaultColor(textColor)
// .setCheckedColor(checkedTextColor)
// .create()
// )
// }
// else -> {
// val accentColor = context.accentColor
// val defaultTextColor = context.getCompatColor(R.color.primaryText)
// val checkedTextColor = if (ColorUtils.isColorLight(accentColor)) {
// Color.BLACK
// } else {
// Color.WHITE
// }
// background = Selector.shapeBuild()
// .setCornerRadius(2.dpToPx())
// .setStrokeWidth(2.dpToPx())
// .setCheckedBgColor(accentColor)
// .setCheckedStrokeColor(accentColor)
// .setDefaultStrokeColor(defaultTextColor)
// .create()
// setTextColor(
// Selector.colorBuild()
// .setDefaultColor(defaultTextColor)
// .setCheckedColor(checkedTextColor)
// .create()
// )
// }
// }
}
}
@@ -0,0 +1,18 @@
package io.legato.kazusa.lib.theme.view
import android.content.Context
import android.util.AttributeSet
import com.google.android.material.slider.Slider
//import io.legado.app.lib.theme.accentColor
/**
* @author Aidan Follestad (afollestad)
*/
class ThemeSlider(context: Context, attrs: AttributeSet) : Slider(context, attrs) {
// init {
// if (!isInEditMode) {
// applyTint(context.accentColor)
// }
// }
}
@@ -0,0 +1,20 @@
package io.legato.kazusa.lib.theme.view
import android.content.Context
import android.util.AttributeSet
import com.google.android.material.materialswitch.MaterialSwitch
//import io.legado.app.lib.theme.accentColor
/**
* @author Aidan Follestad (afollestad)
*/
class ThemeSwitch(context: Context, attrs: AttributeSet) : MaterialSwitch(context, attrs) {
// init {
// if (!isInEditMode) {
// applyTint(context.accentColor)
// }
//
// }
}
@@ -0,0 +1,32 @@
package io.legato.kazusa.lib.webdav
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Server.WebDavConfig
import okhttp3.Credentials
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
data class Authorization(
val username: String,
val password: String,
val charset: Charset = StandardCharsets.ISO_8859_1
) {
var name = "Authorization"
private set
var data: String = Credentials.basic(username, password, charset)
private set
override fun toString(): String {
return "$username:$password"
}
constructor(serverID: Long) : this(
appDb.serverDao.get(serverID)?.getWebDavConfig()
?: throw WebDavException("Unexpected WebDav Authorization")
)
constructor(webDavConfig: WebDavConfig) : this(webDavConfig.username, webDavConfig.password)
}
@@ -0,0 +1,454 @@
package io.legato.kazusa.lib.webdav
import android.annotation.SuppressLint
import android.net.Uri
import cn.hutool.core.net.URLDecoder
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.http.newCallResponse
import io.legato.kazusa.help.http.okHttpClient
import io.legato.kazusa.help.http.text
import io.legato.kazusa.model.analyzeRule.AnalyzeUrl
import io.legato.kazusa.model.analyzeRule.CustomUrl
import io.legato.kazusa.utils.NetworkUtils
import io.legato.kazusa.utils.findNS
import io.legato.kazusa.utils.findNSPrefix
import io.legato.kazusa.utils.printOnDebug
import io.legato.kazusa.utils.toRequestBody
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.intellij.lang.annotations.Language
import org.jsoup.Jsoup
import org.jsoup.parser.Parser
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.net.MalformedURLException
import java.net.URL
import java.net.URLEncoder
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.concurrent.TimeUnit
import kotlin.coroutines.coroutineContext
@Suppress("unused", "MemberVisibilityCanBePrivate")
open class WebDav(
val path: String,
val authorization: Authorization
) {
companion object {
fun fromPath(path: String): WebDav {
val id = AnalyzeUrl(path).serverID ?: throw WebDavException("没有serverID")
val authorization = Authorization(id)
return WebDav(path, authorization)
}
@SuppressLint("DateTimeFormatter")
private val dateTimeFormatter = DateTimeFormatter.RFC_1123_DATE_TIME
// 指定返回哪些属性
@Language("xml")
private const val DIR =
"""<?xml version="1.0"?>
<a:propfind xmlns:a="DAV:">
<a:prop>
<a:displayname/>
<a:resourcetype/>
<a:getcontentlength/>
<a:creationdate/>
<a:getlastmodified/>
%s
</a:prop>
</a:propfind>"""
@Language("xml")
private const val EXISTS =
"""<?xml version="1.0"?>
<propfind xmlns="DAV:">
<prop>
<resourcetype />
</prop>
</propfind>"""
private const val DEFAULT_CONTENT_TYPE = "application/octet-stream"
}
private val url: URL = URL(CustomUrl(path).getUrl())
private val httpUrl: String? by lazy {
val raw = url.toString()
.replace("davs://", "https://")
.replace("dav://", "http://")
return@lazy kotlin.runCatching {
URLEncoder.encode(raw, "UTF-8")
.replace("+", "%20")
.replace("%3A", ":")
.replace("%2F", "/")
}.getOrNull()
}
private val webDavClient by lazy {
val authInterceptor = Interceptor { chain ->
var request = chain.request()
if (request.url.host.equals(host, true)) {
request = request
.newBuilder()
.header(authorization.name, authorization.data)
.build()
}
chain.proceed(request)
}
okHttpClient.newBuilder().run {
callTimeout(0, TimeUnit.SECONDS)
interceptors().add(0, authInterceptor)
addNetworkInterceptor(authInterceptor)
build()
}
}
val host: String? get() = url.host
/**
* 获取当前url文件信息
*/
@Throws(WebDavException::class)
suspend fun getWebDavFile(): WebDavFile? {
return propFindResponse(depth = 0)?.let {
parseBody(it).firstOrNull()
}
}
/**
* 列出当前路径下的文件
* @return 文件列表
*/
@Throws(WebDavException::class)
suspend fun listFiles(): List<WebDavFile> {
propFindResponse()?.let { body ->
return parseBody(body).filter {
it.path != path
}
}
return emptyList()
}
/**
* @param propsList 指定列出文件的哪些属性
*/
@Throws(WebDavException::class)
private suspend fun propFindResponse(
propsList: List<String> = emptyList(),
depth: Int = 1
): String? {
val requestProps = StringBuilder()
for (p in propsList) {
requestProps.append("<a:").append(p).append("/>\n")
}
val requestPropsStr: String = if (requestProps.toString().isEmpty()) {
DIR.replace("%s", "")
} else {
String.format(DIR, requestProps.toString() + "\n")
}
val url = httpUrl ?: return null
return webDavClient.newCallResponse {
url(url)
addHeader("Depth", depth.toString())
// 添加RequestBody对象,可以只返回的属性。如果设为null,则会返回全部属性
// 注意:尽量手动指定需要返回的属性。若返回全部属性,可能后由于Prop.java里没有该属性名,而崩溃。
val requestBody = requestPropsStr.toRequestBody("text/plain".toMediaType())
method("PROPFIND", requestBody)
}.apply {
checkResult(this)
}.body?.text()
}
/**
* 解析webDav返回的xml
*/
private fun parseBody(s: String): List<WebDavFile> {
val list = ArrayList<WebDavFile>()
val document = kotlin.runCatching {
Jsoup.parse(s, Parser.xmlParser())
}.getOrElse {
Jsoup.parse(s)
}
val ns = document.findNSPrefix("DAV:")
val elements = document.findNS("response", ns)
val urlStr = httpUrl ?: return list
val baseUrl = NetworkUtils.getBaseUrl(urlStr)
for (element in elements) {
//依然是优化支持 caddy 自建的 WebDav ,其目录后缀都为“/”, 所以删除“/”的判定,不然无法获取该目录项
val href = element.findNS("href", ns)[0].text()
val hrefDecode = URLDecoder.decodeForPath(href, Charsets.UTF_8)
val fileName = hrefDecode.removeSuffix("/").substringAfterLast("/")
val webDavFile: WebDav
try {
val urlName = hrefDecode.ifEmpty {
url.file.replace("/", "")
}
val displayName = element
.findNS("displayname", ns)
.firstOrNull()?.text()?.takeIf { it.isNotEmpty() }
?.let { URLDecoder.decodeForPath(it, Charsets.UTF_8) } ?: fileName
val contentType = element
.findNS("getcontenttype", ns)
.firstOrNull()?.text().orEmpty()
val resourceType = element
.findNS("resourcetype", ns)
.firstOrNull()?.html()?.trim().orEmpty()
val size = kotlin.runCatching {
element.findNS("getcontentlength", ns)
.firstOrNull()?.text()?.toLong() ?: 0
}.getOrDefault(0)
val lastModify: Long = kotlin.runCatching {
element.findNS("getlastmodified", ns)
.firstOrNull()?.text()?.let {
LocalDateTime.parse(it, dateTimeFormatter)
.toInstant(ZoneOffset.of("+8")).toEpochMilli()
}
}.getOrNull() ?: 0
var fullURL = NetworkUtils.getAbsoluteURL(baseUrl, hrefDecode)
if (WebDavFile.isDir(contentType, resourceType) && !fullURL.endsWith("/")) {
fullURL += "/"
}
webDavFile = WebDavFile(
fullURL,
authorization,
displayName = displayName,
urlName = urlName,
size = size,
contentType = contentType,
resourceType = resourceType,
lastModify = lastModify
)
list.add(webDavFile)
} catch (e: MalformedURLException) {
e.printOnDebug()
}
}
return list
}
/**
* 文件是否存在
*/
suspend fun exists(): Boolean {
val url = httpUrl ?: return false
return kotlin.runCatching {
return webDavClient.newCallResponse {
url(url)
addHeader("Depth", "0")
val requestBody = EXISTS.toRequestBody("application/xml".toMediaType())
method("PROPFIND", requestBody)
}.use { it.isSuccessful }
}.onFailure {
coroutineContext.ensureActive()
}.getOrDefault(false)
}
/**
* 检查用户名密码是否有效
*/
suspend fun check(): Boolean {
return kotlin.runCatching {
webDavClient.newCallResponse {
url(url)
addHeader("Depth", "0")
val requestBody = EXISTS.toRequestBody("application/xml".toMediaType())
method("PROPFIND", requestBody)
}.use { it.code != 401 }
}.onFailure {
coroutineContext.ensureActive()
}.getOrDefault(true)
}
/**
* 根据自己的URL,在远程处创建对应的文件夹
* @return 是否创建成功
*/
suspend fun makeAsDir(): Boolean {
val url = httpUrl ?: return false
//防止报错
return kotlin.runCatching {
if (!exists()) {
webDavClient.newCallResponse {
url(url)
method("MKCOL", null)
}.use {
checkResult(it)
}
}
}.onFailure {
coroutineContext.ensureActive()
AppLog.put("WebDav创建目录失败\n${it.localizedMessage}", it)
}.isSuccess
}
/**
* 下载到本地
* @param savedPath 本地的完整路径,包括最后的文件名
* @param replaceExisting 是否替换本地的同名文件
*/
@Throws(WebDavException::class)
suspend fun downloadTo(savedPath: String, replaceExisting: Boolean) {
val file = File(savedPath)
if (file.exists() && !replaceExisting) {
return
}
downloadInputStream().use { byteStream ->
FileOutputStream(file).use {
byteStream.copyTo(it)
}
}
}
/**
* 下载文件,返回ByteArray
*/
@Throws(WebDavException::class)
suspend fun download(): ByteArray {
return downloadInputStream().use {
it.readBytes()
}
}
/**
* 上传文件
*/
@Throws(WebDavException::class)
suspend fun upload(localPath: String, contentType: String = DEFAULT_CONTENT_TYPE) {
upload(File(localPath), contentType)
}
@Throws(WebDavException::class)
suspend fun upload(file: File, contentType: String = DEFAULT_CONTENT_TYPE) {
kotlin.runCatching {
withContext(IO) {
if (!file.exists()) throw WebDavException("文件不存在")
// 务必注意RequestBody不要嵌套,不然上传时内容可能会被追加多余的文件信息
val fileBody = file.asRequestBody(contentType.toMediaType())
val url = httpUrl ?: throw WebDavException("url不能为空")
webDavClient.newCallResponse {
url(url)
put(fileBody)
}.use {
checkResult(it)
}
}
}.onFailure {
coroutineContext.ensureActive()
AppLog.put("WebDav上传失败\n${it.localizedMessage}", it)
throw WebDavException("WebDav上传失败\n${it.localizedMessage}")
}
}
@Throws(WebDavException::class)
suspend fun upload(byteArray: ByteArray, contentType: String = DEFAULT_CONTENT_TYPE) {
// 务必注意RequestBody不要嵌套,不然上传时内容可能会被追加多余的文件信息
kotlin.runCatching {
withContext(IO) {
val fileBody = byteArray.toRequestBody(contentType.toMediaType())
val url = httpUrl ?: throw NoStackTraceException("url不能为空")
webDavClient.newCallResponse {
url(url)
put(fileBody)
}.use {
checkResult(it)
}
}
}.onFailure {
coroutineContext.ensureActive()
AppLog.put("WebDav上传失败\n${it.localizedMessage}", it)
throw WebDavException("WebDav上传失败\n${it.localizedMessage}")
}
}
@Throws(WebDavException::class)
suspend fun upload(uri: Uri, contentType: String = DEFAULT_CONTENT_TYPE) {
// 务必注意RequestBody不要嵌套,不然上传时内容可能会被追加多余的文件信息
kotlin.runCatching {
withContext(IO) {
val fileBody = uri.toRequestBody(contentType.toMediaType())
val url = httpUrl ?: throw NoStackTraceException("url不能为空")
webDavClient.newCallResponse {
url(url)
put(fileBody)
}.use {
checkResult(it)
}
}
}.onFailure {
coroutineContext.ensureActive()
AppLog.put("WebDav上传失败\n${it.localizedMessage}", it)
throw WebDavException("WebDav上传失败\n${it.localizedMessage}")
}
}
@Throws(WebDavException::class)
suspend fun downloadInputStream(): InputStream {
val url = httpUrl ?: throw WebDavException("WebDav下载出错\nurl为空")
val byteStream = webDavClient.newCallResponse {
url(url)
}.apply {
checkResult(this)
}.body?.byteStream()
return byteStream ?: throw WebDavException("WebDav下载出错\nNull Exception")
}
/**
* 移除文件/文件夹
*/
suspend fun delete(): Boolean {
val url = httpUrl ?: return false
//防止报错
return kotlin.runCatching {
webDavClient.newCallResponse {
url(url)
method("DELETE", null)
}.use {
checkResult(it)
}
}.onFailure {
coroutineContext.ensureActive()
AppLog.put("WebDav删除失败\n${it.localizedMessage}", it)
}.isSuccess
}
/**
* 检测返回结果是否正确
*/
private fun checkResult(response: Response) {
if (!response.isSuccessful) {
val body = response.body?.string()
if (response.code == 401) {
val headers = response.headers("WWW-Authenticate")
val supportBasicAuth = headers.any {
it.startsWith("Basic", ignoreCase = true)
}
if (headers.isNotEmpty() && !supportBasicAuth) {
AppLog.put("服务器不支持BasicAuth认证")
}
}
if (response.message.isNotBlank() || body.isNullOrBlank()) {
throw WebDavException("${url}\n${response.code}:${response.message}")
}
val document = Jsoup.parse(body)
val exception = document.getElementsByTag("s:exception").firstOrNull()?.text()
val message = document.getElementsByTag("s:message").firstOrNull()?.text()
if (exception == "ObjectNotFound") {
throw ObjectNotFoundException(
message ?: "$path doesn't exist. code:${response.code}"
)
}
throw WebDavException(message ?: "未知错误 code:${response.code}")
}
}
}
@@ -0,0 +1,11 @@
package io.legato.kazusa.lib.webdav
open class WebDavException(msg: String) : Exception(msg) {
override fun fillInStackTrace(): Throwable {
return this
}
}
class ObjectNotFoundException(msg: String) : WebDavException(msg)
Loaded 100 of 972 files, more files were not shown because too many files have changed in this diff. Show more