Rename
This commit is contained in:
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 ("<" and ">") 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)
|
||||
|
||||
}
|
||||
+10
@@ -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
Reference in new issue
Block a user