Modernize Repo class for Magisk modules

- Use Kotlin
- Use Room database
- Use retrofit for networking
- Use RxJava pipeline for repo updating
This commit is contained in:
topjohnwu 2019-07-28 01:54:34 -07:00
parent 0c17ea5755
commit 42e7db8d13
30 changed files with 401 additions and 691 deletions

View File

@ -81,6 +81,7 @@ dependencies {
def vRetrofit = "2.6.0"
implementation "com.squareup.retrofit2:retrofit:${vRetrofit}"
implementation "com.squareup.retrofit2:converter-moshi:${vRetrofit}"
implementation "com.squareup.retrofit2:converter-scalars:${vRetrofit}"
implementation "com.squareup.retrofit2:adapter-rxjava2:${vRetrofit}"
def vOkHttp = '4.0.1'
@ -101,6 +102,7 @@ dependencies {
}
def vRoom = "2.1.0"
implementation "com.github.topjohnwu:room-runtime:${vRoom}"
kapt "androidx.room:room-compiler:${vRoom}"
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.browser:browser:1.0.0'

View File

@ -13,6 +13,8 @@ import androidx.multidex.MultiDex
import androidx.room.Room
import androidx.work.impl.WorkDatabase
import androidx.work.impl.WorkDatabase_Impl
import com.topjohnwu.magisk.data.database.RepoDatabase
import com.topjohnwu.magisk.data.database.RepoDatabase_Impl
import com.topjohnwu.magisk.di.koinModules
import com.topjohnwu.magisk.utils.LocaleManager
import com.topjohnwu.magisk.utils.RootUtils
@ -113,6 +115,7 @@ open class App : Application(), Application.ActivityLifecycleCallbacks {
Room.setFactory {
when (it) {
WorkDatabase::class.java -> WorkDatabase_Impl()
RepoDatabase::class.java -> RepoDatabase_Impl()
else -> null
}
}

View File

@ -41,7 +41,6 @@ object Config : PreferenceModel, DBConfig {
const val CUSTOM_CHANNEL = "custom_channel"
const val LOCALE = "locale"
const val DARK_THEME = "dark_theme"
const val ETAG_KEY = "ETag"
const val REPO_ORDER = "repo_order"
const val SHOW_SYSTEM_APP = "show_system"
const val DOWNLOAD_CACHE = "download_cache"
@ -117,8 +116,6 @@ object Config : PreferenceModel, DBConfig {
var customChannelUrl by preference(Key.CUSTOM_CHANNEL, "")
var locale by preference(Key.LOCALE, "")
@JvmStatic
var etagKey by preference(Key.ETAG_KEY, "")
var rootMode by dbSettings(Key.ROOT_ACCESS, Value.ROOT_ACCESS_APPS_AND_ADB)
var suMntNamespaceMode by dbSettings(Key.SU_MNT_NS, Value.NAMESPACE_MODE_REQUESTER)
@ -195,7 +192,6 @@ object Config : PreferenceModel, DBConfig {
}
}
config.delete()
remove(Key.ETAG_KEY)
}
}

View File

@ -53,13 +53,7 @@ object Const {
}
object Url {
@Deprecated("This shouldn't be used. There's literally no need for it")
const val REPO_URL =
"https://api.github.com/users/Magisk-Modules-Repo/repos?per_page=100&sort=pushed&page=%d"
const val FILE_URL = "https://raw.githubusercontent.com/Magisk-Modules-Repo/%s/master/%s"
const val ZIP_URL = "https://github.com/Magisk-Modules-Repo/%s/archive/master.zip"
const val MODULE_INSTALLER =
"https://raw.githubusercontent.com/topjohnwu/Magisk/master/scripts/module_installer.sh"
const val PAYPAL_URL = "https://www.paypal.me/topjohnwu"
const val PATREON_URL = "https://www.patreon.com/topjohnwu"
const val TWITTER_URL = "https://twitter.com/topjohnwu"
@ -67,16 +61,19 @@ object Const {
const val SOURCE_CODE_URL = "https://github.com/topjohnwu/Magisk"
@JvmField
val BOOTCTL_URL = getRaw("9c5dfc1b8245c0b5b524901ef0ff0f8335757b77", "bootctl")
const val GITHUB_RAW_API_URL = "https://raw.githubusercontent.com/"
const val GITHUB_RAW_URL = "https://raw.githubusercontent.com/"
const val GITHUB_API_URL = "https://api.github.com/users/Magisk-Modules-Repo/"
private fun getRaw(where: String, name: String) =
"${GITHUB_RAW_API_URL}topjohnwu/magisk_files/$where/$name"
"${GITHUB_RAW_URL}topjohnwu/magisk_files/$where/$name"
}
object Key {
// others
const val LINK_KEY = "Link"
const val IF_NONE_MATCH = "If-None-Match"
const val ETAG_KEY = "ETag"
// intents
const val OPEN_SECTION = "section"
const val INTENT_SET_NAME = "filename"

View File

@ -0,0 +1,70 @@
package com.topjohnwu.magisk.data.database
import androidx.room.*
import com.topjohnwu.magisk.Config
import com.topjohnwu.magisk.model.entity.module.Repo
@Dao
abstract class RepoDao {
val repoIDSet: Set<String> get() = getRepoID().map { it.id }.toSet()
val repos: List<Repo> get() = getReposWithOrder(when (Config.repoOrder) {
Config.Value.ORDER_NAME -> "name COLLATE NOCASE"
Config.Value.ORDER_DATE -> "last_update DESC"
else -> ""
})
var etagKey: String
set(etag) = addEtagRaw(RepoEtag(0, etag))
get() = etagRaw()?.key.orEmpty()
fun clear() {
clearRepos()
clearEtag()
}
@Query("SELECT * FROM repos ORDER BY :order")
protected abstract fun getReposWithOrder(order: String): List<Repo>
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract fun addRepo(repo: Repo)
@Query("SELECT * FROM repos WHERE id = :id")
abstract fun getRepo(id: String): Repo?
@Query("SELECT id FROM repos")
protected abstract fun getRepoID(): List<RepoID>
@Delete
abstract fun removeRepo(repo: Repo)
@Query("DELETE FROM repos WHERE id = :id")
abstract fun removeRepo(id: String)
@Query("DELETE FROM repos WHERE id IN (:idList)")
abstract fun removeRepos(idList: List<String>)
@Query("SELECT * FROM etag")
protected abstract fun etagRaw(): RepoEtag?
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract fun addEtagRaw(etag: RepoEtag)
@Query("DELETE FROM repos")
protected abstract fun clearRepos()
@Query("DELETE FROM etag")
protected abstract fun clearEtag()
}
data class RepoID(
@PrimaryKey val id: String
)
@Entity(tableName = "etag")
data class RepoEtag(
@PrimaryKey val id: Int,
val key: String
)

View File

@ -0,0 +1,11 @@
package com.topjohnwu.magisk.data.database
import androidx.room.Database
import androidx.room.RoomDatabase
import com.topjohnwu.magisk.model.entity.module.Repo
@Database(version = 6, entities = [Repo::class, RepoEtag::class])
abstract class RepoDatabase : RoomDatabase() {
abstract fun repoDao() : RepoDao
}

View File

@ -1,109 +0,0 @@
package com.topjohnwu.magisk.data.database
import android.content.Context
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import androidx.core.content.edit
import com.topjohnwu.magisk.Config
import com.topjohnwu.magisk.model.entity.Repo
import java.util.*
@Deprecated("")
class RepoDatabaseHelper
constructor(context: Context) : SQLiteOpenHelper(context, "repo.db", null, DATABASE_VER) {
private val mDb: SQLiteDatabase = writableDatabase
val rawCursor: Cursor
@Deprecated("")
get() = mDb.query(TABLE_NAME, null, null, null, null, null, null)
val repoCursor: Cursor
@Deprecated("")
get() {
var orderBy: String? = null
when (Config.repoOrder) {
Config.Value.ORDER_NAME -> orderBy = "name COLLATE NOCASE"
Config.Value.ORDER_DATE -> orderBy = "last_update DESC"
}
return mDb.query(TABLE_NAME, null, null, null, null, null, orderBy)
}
val repoIDSet: Set<String>
@Deprecated("")
get() {
val set = HashSet<String>(300)
mDb.query(TABLE_NAME, null, null, null, null, null, null).use { c ->
while (c.moveToNext()) {
set.add(c.getString(c.getColumnIndex("id")))
}
}
return set
}
override fun onCreate(db: SQLiteDatabase) {
onUpgrade(db, 0, DATABASE_VER)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
if (oldVersion != newVersion) {
// Nuke old DB and create new table
db.execSQL("DROP TABLE IF EXISTS $TABLE_NAME")
db.execSQL(
"CREATE TABLE IF NOT EXISTS " + TABLE_NAME + " " +
"(id TEXT, name TEXT, version TEXT, versionCode INT, " +
"author TEXT, description TEXT, last_update INT, PRIMARY KEY(id))")
Config.prefs.edit {
remove(Config.Key.ETAG_KEY)
}
}
}
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
onUpgrade(db, 0, DATABASE_VER)
}
@Deprecated("")
fun clearRepo() {
mDb.delete(TABLE_NAME, null, null)
}
@Deprecated("")
fun removeRepo(id: String) {
mDb.delete(TABLE_NAME, "id=?", arrayOf(id))
}
@Deprecated("")
fun removeRepo(repo: Repo) {
removeRepo(repo.id)
}
@Deprecated("")
fun removeRepo(list: Iterable<String>) {
list.forEach {
mDb.delete(TABLE_NAME, "id=?", arrayOf(it))
}
}
@Deprecated("")
fun addRepo(repo: Repo) {
mDb.replace(TABLE_NAME, null, repo.contentValues)
}
@Deprecated("")
fun getRepo(id: String): Repo? {
mDb.query(TABLE_NAME, null, "id=?", arrayOf(id), null, null, null).use { c ->
if (c.moveToNext()) {
return Repo(c)
}
}
return null
}
companion object {
private val DATABASE_VER = 5
private val TABLE_NAME = "repos"
}
}

View File

@ -2,15 +2,14 @@ package com.topjohnwu.magisk.data.network
import com.topjohnwu.magisk.Const
import com.topjohnwu.magisk.model.entity.UpdateInfo
import com.topjohnwu.magisk.tasks.GithubRepoInfo
import io.reactivex.Flowable
import io.reactivex.Single
import okhttp3.ResponseBody
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Streaming
import retrofit2.http.Url
import retrofit2.adapter.rxjava2.Result
import retrofit2.http.*
interface GithubRawApiServices {
interface GithubRawServices {
//region topjohnwu/magisk_files
@ -41,6 +40,9 @@ interface GithubRawApiServices {
@Streaming
fun fetchInstaller(): Single<ResponseBody>
@GET("$MAGISK_MODULES/{$MODULE}/master/{$FILE}")
fun fetchModuleInfo(@Path(MODULE) id: String, @Path(FILE) file: String): Single<String>
//endregion
/**
@ -51,6 +53,9 @@ interface GithubRawApiServices {
@Streaming
fun fetchFile(@Url url: String): Single<ResponseBody>
@GET
fun fetchString(@Url url: String): Single<String>
companion object {
private const val REVISION = "revision"
@ -64,3 +69,13 @@ interface GithubRawApiServices {
}
}
interface GithubApiServices {
@GET("repos")
fun fetchRepos(@Query("page") page: Int,
@Header(Const.Key.IF_NONE_MATCH) etag: String,
@Query("sort") sort: String = "pushed",
@Query("per_page") count: Int = 100): Flowable<Result<List<GithubRepoInfo>>>
}

View File

@ -1,13 +0,0 @@
package com.topjohnwu.magisk.data.repository
import com.topjohnwu.magisk.data.network.GithubRawApiServices
class FileRepository(
private val api: GithubRawApiServices
) {
fun downloadFile(url: String) = api.fetchFile(url)
fun downloadInstaller() = api.fetchInstaller()
}

View File

@ -1,12 +1,11 @@
package com.topjohnwu.magisk.data.repository
import android.content.Context
import android.content.pm.PackageManager
import com.topjohnwu.magisk.App
import com.topjohnwu.magisk.Config
import com.topjohnwu.magisk.Info
import com.topjohnwu.magisk.data.database.base.su
import com.topjohnwu.magisk.data.network.GithubRawApiServices
import com.topjohnwu.magisk.data.network.GithubRawServices
import com.topjohnwu.magisk.model.entity.HideAppInfo
import com.topjohnwu.magisk.model.entity.HideTarget
import com.topjohnwu.magisk.utils.Utils
@ -16,9 +15,8 @@ import com.topjohnwu.superuser.Shell
import io.reactivex.Single
class MagiskRepository(
private val context: Context,
private val apiRaw: GithubRawApiServices,
private val packageManager: PackageManager
private val apiRaw: GithubRawServices,
private val packageManager: PackageManager
) {
fun fetchSafetynet() = apiRaw.fetchSafetynet()

View File

@ -0,0 +1,15 @@
package com.topjohnwu.magisk.data.repository
import com.topjohnwu.magisk.data.network.GithubRawServices
import com.topjohnwu.magisk.model.entity.module.Repo
class StringRepository(
private val api: GithubRawServices
) {
fun getString(url: String) = api.fetchString(url)
fun getMetadata(repo: Repo) = api.fetchModuleInfo(repo.id, "module.prop")
fun getReadme(repo: Repo) = api.fetchModuleInfo(repo.id, "README.md")
}

View File

@ -1,7 +1,9 @@
package com.topjohnwu.magisk.di
import android.content.Context
import androidx.room.Room
import com.topjohnwu.magisk.data.database.*
import com.topjohnwu.magisk.tasks.UpdateRepos
import com.topjohnwu.magisk.tasks.RepoUpdater
import org.koin.dsl.module
@ -10,6 +12,13 @@ val databaseModule = module {
single { PolicyDao(get()) }
single { SettingsDao() }
single { StringDao() }
single { RepoDatabaseHelper(get()) }
single { UpdateRepos(get()) }
single { createRepoDatabase(get()) }
single { get<RepoDatabase>().repoDao() }
single { RepoUpdater(get(), get()) }
}
fun createRepoDatabase(context: Context) =
Room.databaseBuilder(context, RepoDatabase::class.java, "repo.db")
.fallbackToDestructiveMigration()
.allowMainThreadQueries()
.build()

View File

@ -4,20 +4,23 @@ import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.topjohnwu.magisk.BuildConfig
import com.topjohnwu.magisk.Const
import com.topjohnwu.magisk.data.network.GithubRawApiServices
import com.topjohnwu.magisk.data.network.GithubApiServices
import com.topjohnwu.magisk.data.network.GithubRawServices
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import org.koin.dsl.module
import retrofit2.Retrofit
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.converter.scalars.ScalarsConverterFactory
import se.ansman.kotshi.KotshiJsonAdapterFactory
val networkingModule = module {
single { createOkHttpClient() }
single { createMoshiConverterFactory() }
single { createRetrofit(get(), get()) }
single { createApiService<GithubRawApiServices>(get(), Const.Url.GITHUB_RAW_API_URL) }
single { createApiService<GithubRawServices>(get(), Const.Url.GITHUB_RAW_URL) }
single { createApiService<GithubApiServices>(get(), Const.Url.GITHUB_API_URL) }
}
fun createOkHttpClient(): OkHttpClient {
@ -45,6 +48,7 @@ fun createRetrofit(
converterFactory: MoshiConverterFactory
): Retrofit.Builder {
return Retrofit.Builder()
.addConverterFactory(ScalarsConverterFactory.create())
.addConverterFactory(converterFactory)
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.client(okHttpClient)

View File

@ -1,15 +1,15 @@
package com.topjohnwu.magisk.di
import com.topjohnwu.magisk.data.repository.AppRepository
import com.topjohnwu.magisk.data.repository.FileRepository
import com.topjohnwu.magisk.data.repository.LogRepository
import com.topjohnwu.magisk.data.repository.MagiskRepository
import com.topjohnwu.magisk.data.repository.StringRepository
import org.koin.dsl.module
val repositoryModule = module {
single { MagiskRepository(get(), get(), get()) }
single { MagiskRepository(get(), get()) }
single { LogRepository(get()) }
single { AppRepository(get()) }
single { FileRepository(get()) }
single { StringRepository(get()) }
}

View File

@ -5,7 +5,7 @@ import androidx.core.app.NotificationCompat
import com.skoumal.teanity.extensions.subscribeK
import com.topjohnwu.magisk.Config
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.data.repository.FileRepository
import com.topjohnwu.magisk.data.network.GithubRawServices
import com.topjohnwu.magisk.model.entity.internal.DownloadSubject
import com.topjohnwu.magisk.model.entity.internal.DownloadSubject.Magisk
import com.topjohnwu.magisk.model.entity.internal.DownloadSubject.Module
@ -24,10 +24,10 @@ import java.io.InputStream
abstract class RemoteFileService : NotificationService() {
private val repo by inject<FileRepository>()
private val service: GithubRawServices by inject()
private val supportedFolders
get() = listOfNotNull(
get() = listOf(
cacheDir,
Config.downloadDirectory
)
@ -68,11 +68,11 @@ abstract class RemoteFileService : NotificationService() {
}
}
private fun download(subject: DownloadSubject) = repo.downloadFile(subject.url)
private fun download(subject: DownloadSubject) = service.fetchFile(subject.url)
.map { it.toStream(subject.hashCode()) }
.flatMap { stream ->
when (subject) {
is Module -> repo.downloadInstaller()
is Module -> service.fetchInstaller()
.map { stream.toModule(subject.file, it.byteStream()); subject.file }
else -> Single.fromCallable { stream.writeTo(subject.file); subject.file }
}

View File

@ -1,146 +0,0 @@
package com.topjohnwu.magisk.model.entity;
import android.content.ContentValues;
import android.database.Cursor;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import java.util.List;
public abstract class OldBaseModule implements Comparable<OldBaseModule>, Parcelable {
private String mId, mName, mVersion, mAuthor, mDescription;
private int mVersionCode = -1;
protected OldBaseModule() {
mId = mName = mVersion = mAuthor = mDescription = "";
}
protected OldBaseModule(Cursor c) {
mId = nonNull(c.getString(c.getColumnIndex("id")));
mName = nonNull(c.getString(c.getColumnIndex("name")));
mVersion = nonNull(c.getString(c.getColumnIndex("version")));
mVersionCode = c.getInt(c.getColumnIndex("versionCode"));
mAuthor = nonNull(c.getString(c.getColumnIndex("author")));
mDescription = nonNull(c.getString(c.getColumnIndex("description")));
}
protected OldBaseModule(Parcel p) {
mId = p.readString();
mName = p.readString();
mVersion = p.readString();
mAuthor = p.readString();
mDescription = p.readString();
mVersionCode = p.readInt();
}
@Override
public int compareTo(@NonNull OldBaseModule module) {
return getName().toLowerCase().compareTo(module.getName().toLowerCase());
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(mId);
dest.writeString(mName);
dest.writeString(mVersion);
dest.writeString(mAuthor);
dest.writeString(mDescription);
dest.writeInt(mVersionCode);
}
private String nonNull(String s) {
return s == null ? "" : s;
}
public ContentValues getContentValues() {
ContentValues values = new ContentValues();
values.put("id", mId);
values.put("name", mName);
values.put("version", mVersion);
values.put("versionCode", mVersionCode);
values.put("author", mAuthor);
values.put("description", mDescription);
return values;
}
protected void parseProps(List<String> props) {
parseProps(props.toArray(new String[0]));
}
protected void parseProps(String[] props) throws NumberFormatException {
for (String line : props) {
String[] prop = line.split("=", 2);
if (prop.length != 2)
continue;
String key = prop[0].trim();
String value = prop[1].trim();
if (key.isEmpty() || key.charAt(0) == '#')
continue;
switch (key) {
case "id":
mId = value;
break;
case "name":
mName = value;
break;
case "version":
mVersion = value;
break;
case "versionCode":
mVersionCode = Integer.parseInt(value);
break;
case "author":
mAuthor = value;
break;
case "description":
mDescription = value;
break;
default:
break;
}
}
}
public String getName() {
return mName;
}
public void setName(String name) {
mName = name;
}
public String getVersion() {
return mVersion;
}
public String getAuthor() {
return mAuthor;
}
public String getId() {
return mId;
}
public void setId(String id) {
mId = id;
}
public String getDescription() {
return mDescription;
}
public int getVersionCode() {
return mVersionCode;
}
}

View File

@ -1,110 +0,0 @@
package com.topjohnwu.magisk.model.entity;
import android.content.ContentValues;
import android.database.Cursor;
import android.os.Parcel;
import android.os.Parcelable;
import com.topjohnwu.magisk.Const;
import com.topjohnwu.magisk.utils.Utils;
import com.topjohnwu.magisk.utils.XStringKt;
import java.text.DateFormat;
import java.util.Date;
public class Repo extends OldBaseModule {
private Date mLastUpdate;
public Repo(String id) {
setId(id);
}
public Repo(Cursor c) {
super(c);
mLastUpdate = new Date(c.getLong(c.getColumnIndex("last_update")));
}
public Repo(Parcel p) {
super(p);
mLastUpdate = new Date(p.readLong());
}
public static final Parcelable.Creator<Repo> CREATOR = new Parcelable.Creator<Repo>() {
@Override
public Repo createFromParcel(Parcel source) {
return new Repo(source);
}
@Override
public Repo[] newArray(int size) {
return new Repo[size];
}
};
@Override
public void writeToParcel(Parcel dest, int flags) {
super.writeToParcel(dest, flags);
dest.writeLong(mLastUpdate.getTime());
}
public void update() throws IllegalRepoException {
String[] props = Utils.INSTANCE.dlString(getPropUrl()).split("\\n");
try {
parseProps(props);
} catch (NumberFormatException e) {
throw new IllegalRepoException("Repo [" + getId() + "] parse error: " + e.getMessage());
}
if (getVersionCode() < 0) {
throw new IllegalRepoException("Repo [" + getId() + "] does not contain versionCode");
}
}
public void update(Date lastUpdate) throws IllegalRepoException {
mLastUpdate = lastUpdate;
update();
}
@Override
public ContentValues getContentValues() {
ContentValues values = super.getContentValues();
values.put("last_update", mLastUpdate.getTime());
return values;
}
public String getZipUrl() {
return String.format(Const.Url.ZIP_URL, getId());
}
public String getPropUrl() {
return getFileUrl("module.prop");
}
public String getDetailUrl() {
return getFileUrl("README.md");
}
public String getFileUrl(String file) {
return String.format(Const.Url.FILE_URL, getId(), file);
}
public String getLastUpdateString() {
return DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM).format(mLastUpdate);
}
public Date getLastUpdate() {
return mLastUpdate;
}
public String getDownloadFilename() {
return XStringKt.legalFilename(getName() + "-" + getVersion() + ".zip");
}
public static class IllegalRepoException extends Exception {
IllegalRepoException(String message) {
super(message);
}
}
}

View File

@ -5,7 +5,7 @@ import android.os.Parcelable
import com.topjohnwu.magisk.Config
import com.topjohnwu.magisk.Info
import com.topjohnwu.magisk.model.entity.MagiskJson
import com.topjohnwu.magisk.model.entity.Repo
import com.topjohnwu.magisk.model.entity.module.Repo
import com.topjohnwu.magisk.utils.cachedFile
import com.topjohnwu.magisk.utils.get
import kotlinx.android.parcel.IgnoredOnParcel
@ -20,8 +20,8 @@ sealed class DownloadSubject : Parcelable {
@Parcelize
data class Module(
val module: Repo,
val configuration: Configuration
val module: Repo,
val configuration: Configuration
) : DownloadSubject() {
override val url: String get() = module.zipUrl

View File

@ -0,0 +1,41 @@
package com.topjohnwu.magisk.model.entity.module
abstract class BaseModule : Comparable<BaseModule> {
abstract var id: String
protected set
abstract var name: String
protected set
abstract var author: String
protected set
abstract var version: String
protected set
abstract var versionCode: Int
protected set
abstract var description: String
protected set
@Throws(NumberFormatException::class)
protected fun parseProps(props: List<String>) {
for (line in props) {
val prop = line.split("=".toRegex(), 2).map { it.trim() }
if (prop.size != 2)
continue
val key = prop[0]
val value = prop[1]
if (key.isEmpty() || key[0] == '#')
continue
when (key) {
"id" -> id = value
"name" -> name = value
"version" -> version = value
"versionCode" -> versionCode = value.toInt()
"author" -> author = value
"description" -> description = value
}
}
}
override operator fun compareTo(other: BaseModule) = name.compareTo(other.name, true)
}

View File

@ -1,50 +1,10 @@
package com.topjohnwu.magisk.model.entity
package com.topjohnwu.magisk.model.entity.module
import androidx.annotation.WorkerThread
import com.topjohnwu.magisk.Const
import com.topjohnwu.superuser.Shell
import com.topjohnwu.superuser.io.SuFile
abstract class BaseModule : Comparable<BaseModule> {
abstract var id: String
protected set
abstract var name: String
protected set
abstract var author: String
protected set
abstract var version: String
protected set
abstract var versionCode: Int
protected set
abstract var description: String
protected set
@Throws(NumberFormatException::class)
protected fun parseProps(props: List<String>) {
for (line in props) {
val prop = line.split("=".toRegex(), 2).map { it.trim() }
if (prop.size != 2)
continue
val key = prop[0]
val value = prop[1]
if (key.isEmpty() || key[0] == '#')
continue
when (key) {
"id" -> id = value
"name" -> name = value
"version" -> version = value
"versionCode" -> versionCode = value.toInt()
"author" -> author = value
"description" -> description = value
}
}
}
override operator fun compareTo(other: BaseModule) = name.compareTo(other.name, true)
}
class Module(path: String) : BaseModule() {
override var id: String = ""
override var name: String = ""

View File

@ -0,0 +1,68 @@
package com.topjohnwu.magisk.model.entity.module
import android.os.Parcelable
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.topjohnwu.magisk.Const
import com.topjohnwu.magisk.data.repository.StringRepository
import com.topjohnwu.magisk.utils.get
import com.topjohnwu.magisk.utils.legalFilename
import kotlinx.android.parcel.Parcelize
import java.text.DateFormat
import java.util.*
@Entity(tableName = "repos")
@Parcelize
data class Repo(
@PrimaryKey override var id: String,
override var name: String,
override var author: String,
override var version: String,
override var versionCode: Int,
override var description: String,
var last_update: Long
) : BaseModule(), Parcelable {
private val stringRepo: StringRepository get() = get()
val lastUpdate get() = Date(last_update)
val lastUpdateString: String get() =
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM).format(lastUpdate)
val downloadFilename: String get() = "$name-$version.zip".legalFilename()
val readme get() = stringRepo.getReadme(this)
val zipUrl: String get() = Const.Url.ZIP_URL.format(id)
constructor(id: String) : this(id, "", "", "", -1, "", 0)
@Throws(IllegalRepoException::class)
fun update() {
val props = runCatching {
stringRepo.getMetadata(this).blockingGet()
.orEmpty().split("\\n".toRegex()).dropLastWhile { it.isEmpty() }
}.getOrElse {
throw IllegalRepoException("Repo [$id] module.prop download error: " + it.message)
}
props.runCatching {
parseProps(this)
}.onFailure {
throw IllegalRepoException("Repo [$id] parse error: " + it.message)
}
if (versionCode < 0) {
throw IllegalRepoException("Repo [$id] does not contain versionCode")
}
}
@Throws(IllegalRepoException::class)
fun update(lastUpdate: Date) {
last_update = lastUpdate.time
update()
}
class IllegalRepoException(message: String) : Exception(message)
}

View File

@ -6,8 +6,8 @@ import com.skoumal.teanity.databinding.ComparableRvItem
import com.skoumal.teanity.extensions.addOnPropertyChangedCallback
import com.skoumal.teanity.util.KObservableField
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.model.entity.Module
import com.topjohnwu.magisk.model.entity.Repo
import com.topjohnwu.magisk.model.entity.module.Module
import com.topjohnwu.magisk.model.entity.module.Repo
import com.topjohnwu.magisk.utils.get
import com.topjohnwu.magisk.utils.toggle
@ -69,11 +69,7 @@ class RepoRvItem(val item: Repo) : ComparableRvItem<RepoRvItem>() {
override val layoutRes: Int = R.layout.item_repo
override fun contentSameAs(other: RepoRvItem): Boolean = item.version == other.item.version
&& item.lastUpdate == other.item.lastUpdate
&& item.versionCode == other.item.versionCode
&& item.description == other.item.description
&& item.detailUrl == other.item.detailUrl
override fun contentSameAs(other: RepoRvItem): Boolean = item == other.item
override fun itemSameAs(other: RepoRvItem): Boolean = item.id == other.item.id
}

View File

@ -3,7 +3,7 @@ package com.topjohnwu.magisk.model.events
import android.app.Activity
import com.skoumal.teanity.viewevents.ViewEvent
import com.topjohnwu.magisk.model.entity.Policy
import com.topjohnwu.magisk.model.entity.Repo
import com.topjohnwu.magisk.model.entity.module.Repo
import io.reactivex.subjects.PublishSubject

View File

@ -0,0 +1,95 @@
package com.topjohnwu.magisk.tasks
import com.squareup.moshi.Json
import com.topjohnwu.magisk.Const
import com.topjohnwu.magisk.data.database.RepoDao
import com.topjohnwu.magisk.data.network.GithubApiServices
import com.topjohnwu.magisk.model.entity.module.Repo
import io.reactivex.Flowable
import io.reactivex.schedulers.Schedulers
import se.ansman.kotshi.JsonSerializable
import timber.log.Timber
import java.net.HttpURLConnection
import java.text.SimpleDateFormat
import java.util.*
import kotlin.collections.HashSet
class RepoUpdater(
private val api: GithubApiServices,
private val repoDB: RepoDao
) {
private lateinit var cached: MutableSet<String>
private val dateFormat: SimpleDateFormat
get() {
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
format.timeZone = TimeZone.getTimeZone("UTC")
return format
}
private fun loadRepos(repos: List<GithubRepoInfo>) = Flowable.fromIterable(repos)
.parallel().runOn(Schedulers.io()).map {
it.id to dateFormat.parse(it.pushed_at)!!
}.map {
// Skip submission
if (it.first == "submission")
return@map
(repoDB.getRepo(it.first)?.apply {
cached.remove(it.first)
} ?: Repo(it.first)).runCatching {
update(it.second)
repoDB.addRepo(this)
}.getOrElse { Timber.e(it) }
}.sequential()
private fun loadPage(page: Int, etag: String = ""): Flowable<Unit> =
api.fetchRepos(page, etag).flatMap {
it.error()?.also { throw it }
it.response()?.run {
if (code() == HttpURLConnection.HTTP_NOT_MODIFIED)
throw CachedException()
if (page == 1)
repoDB.etagKey = headers()[Const.Key.ETAG_KEY].orEmpty().trimEtag()
val flow = loadRepos(body()!!)
if (headers()[Const.Key.LINK_KEY].orEmpty().contains("next")) {
flow.mergeWith(loadPage(page + 1))
} else {
flow
}
}
}
private fun forcedReload() = Flowable.fromIterable(cached)
.parallel().runOn(Schedulers.io()).map {
runCatching {
Repo(it).update()
}.getOrElse { Timber.e(it) }
}.sequential()
private fun String.trimEtag() = substring(indexOf('\"'), lastIndexOf('\"') + 1)
operator fun invoke(forced: Boolean = false) : Flowable<Unit> {
cached = Collections.synchronizedSet(HashSet(repoDB.repoIDSet))
return loadPage(1, repoDB.etagKey).doOnComplete {
repoDB.removeRepos(cached.toList())
cached.clear()
}.onErrorResumeNext { it: Throwable ->
cached.clear()
if (it is CachedException) {
if (forced) forcedReload() else Flowable.empty()
} else {
Flowable.error(it)
}
}
}
class CachedException : Exception()
}
@JsonSerializable
data class GithubRepoInfo(
@Json(name = "name") val id: String,
val pushed_at: String
)

View File

@ -1,191 +0,0 @@
package com.topjohnwu.magisk.tasks;
import android.database.Cursor;
import android.util.Pair;
import com.topjohnwu.magisk.App;
import com.topjohnwu.magisk.Config;
import com.topjohnwu.magisk.Const;
import com.topjohnwu.magisk.data.database.RepoDatabaseHelper;
import com.topjohnwu.magisk.model.entity.Repo;
import com.topjohnwu.magisk.utils.Logger;
import com.topjohnwu.magisk.utils.Utils;
import com.topjohnwu.net.Networking;
import com.topjohnwu.net.Request;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.net.HttpURLConnection;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Collections;
import java.util.Date;
import java.util.Locale;
import java.util.Queue;
import java.util.Set;
import java.util.TimeZone;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import androidx.annotation.NonNull;
import io.reactivex.Single;
@Deprecated
public class UpdateRepos {
@NonNull
private final RepoDatabaseHelper repoDB;
private Set<String> cached;
private Queue<Pair<String, Date>> moduleQueue;
public UpdateRepos(@NonNull RepoDatabaseHelper repoDatabase) {
repoDB = repoDatabase;
}
private void runTasks(Runnable task) {
Future[] futures = new Future[App.THREAD_POOL.getMaximumPoolSize() - 1];
for (int i = 0; i < futures.length; ++i) {
futures[i] = App.THREAD_POOL.submit(task);
}
for (Future f : futures) {
while (true) {
try {
f.get();
} catch (InterruptedException e) {
continue;
} catch (ExecutionException ignored) {
}
break;
}
}
}
/**
* Static instance of (Simple)DateFormat is not threadsafe so in order to make it safe it needs
* to be created beforehand on the same thread where it'll be used.
* See https://stackoverflow.com/a/18383395
*/
private static SimpleDateFormat getDateFormat() {
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US);
format.setTimeZone(TimeZone.getTimeZone("UTC"));
return format;
}
/* We sort repos by last push, which means that we only need to check whether the
* first page is updated to determine whether the online repo database is changed
*/
private boolean parsePage(int page) {
Request req = Networking.get(Utils.INSTANCE.fmt(Const.Url.REPO_URL, page + 1));
if (page == 0) {
String etag = Config.getEtagKey();
if (!etag.isEmpty())
req.addHeaders(Const.Key.IF_NONE_MATCH, etag);
}
Request.Result<JSONArray> res = req.execForJSONArray();
// JSON not updated
if (res.getCode() == HttpURLConnection.HTTP_NOT_MODIFIED)
return false;
// Network error
if (res.getResult() == null) {
cached.clear();
return true;
}
// Current page is the last page
if (res.getResult().length() == 0)
return true;
try {
SimpleDateFormat dateFormat = getDateFormat();
for (int i = 0; i < res.getResult().length(); i++) {
JSONObject rawRepo = res.getResult().getJSONObject(i);
String id = rawRepo.getString("name");
Date date = dateFormat.parse(rawRepo.getString("pushed_at"));
moduleQueue.offer(new Pair<>(id, date));
}
} catch (JSONException | ParseException e) {
// Should not happen, but if exception occurs, page load fails
return false;
}
// Update ETAG
if (page == 0) {
String etag = res.getConnection().getHeaderField(Config.Key.ETAG_KEY);
if (etag != null) {
etag = etag.substring(etag.indexOf('\"'), etag.lastIndexOf('\"') + 1);
Config.setEtagKey(etag);
}
}
String links = res.getConnection().getHeaderField(Const.Key.LINK_KEY);
return links == null || !links.contains("next") || parsePage(page + 1);
}
private boolean loadPages() {
if (!parsePage(0))
return false;
runTasks(() -> {
while (true) {
Pair<String, Date> pair = moduleQueue.poll();
if (pair == null)
return;
Repo repo = repoDB.getRepo(pair.first);
try {
if (repo == null)
repo = new Repo(pair.first);
else
cached.remove(pair.first);
repo.update(pair.second);
repoDB.addRepo(repo);
} catch (Repo.IllegalRepoException e) {
Logger.debug(e.getMessage());
repoDB.removeRepo(pair.first);
}
}
});
return true;
}
private void fullReload() {
Cursor c = repoDB.getRawCursor();
runTasks(() -> {
while (true) {
Repo repo;
synchronized (c) {
if (!c.moveToNext())
return;
repo = new Repo(c);
}
try {
repo.update();
repoDB.addRepo(repo);
} catch (Repo.IllegalRepoException e) {
Logger.debug(e.getMessage());
repoDB.removeRepo(repo);
}
}
});
}
public Single<Boolean> exec(boolean force) {
return Single.fromCallable(() -> {
cached = Collections.synchronizedSet(repoDB.getRepoIDSet());
moduleQueue = new ConcurrentLinkedQueue<>();
if (loadPages()) {
// The leftover cached means they are removed from online repo
repoDB.removeRepo(cached);
} else if (force) {
fullReload();
}
return force; // not important
});
}
public Single<Boolean> exec() {
return exec(false);
}
}

View File

@ -9,18 +9,16 @@ import com.skoumal.teanity.util.DiffObservableList
import com.skoumal.teanity.util.KObservableField
import com.topjohnwu.magisk.BR
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.data.database.RepoDatabaseHelper
import com.topjohnwu.magisk.model.entity.Module
import com.topjohnwu.magisk.model.entity.Repo
import com.topjohnwu.magisk.data.database.RepoDao
import com.topjohnwu.magisk.model.entity.module.Module
import com.topjohnwu.magisk.model.entity.recycler.ModuleRvItem
import com.topjohnwu.magisk.model.entity.recycler.RepoRvItem
import com.topjohnwu.magisk.model.entity.recycler.SectionRvItem
import com.topjohnwu.magisk.model.events.InstallModuleEvent
import com.topjohnwu.magisk.model.events.OpenChangelogEvent
import com.topjohnwu.magisk.model.events.OpenFilePickerEvent
import com.topjohnwu.magisk.tasks.UpdateRepos
import com.topjohnwu.magisk.tasks.RepoUpdater
import com.topjohnwu.magisk.ui.base.MagiskViewModel
import com.topjohnwu.magisk.utils.toList
import com.topjohnwu.magisk.utils.toSingle
import com.topjohnwu.magisk.utils.update
import io.reactivex.Single
@ -28,9 +26,9 @@ import io.reactivex.disposables.Disposable
import me.tatarka.bindingcollectionadapter2.OnItemBind
class ModuleViewModel(
private val resources: Resources,
private val repoDatabase: RepoDatabaseHelper,
private val repoUpdater: UpdateRepos
private val resources: Resources,
private val repoUpdater: RepoUpdater,
private val repoDB: RepoDao
) : MagiskViewModel() {
val query = KObservableField("")
@ -65,9 +63,10 @@ class ModuleViewModel(
.toList()
.map { it to itemsInstalled.calculateDiff(it) }
.doOnSuccessUi { itemsInstalled.update(it.first, it.second) }
.flatMap { repoUpdater.exec(force) }
.flatMap { Single.fromCallable { repoDatabase.repoCursor.toList { Repo(it) } } }
.flattenAsFlowable { it }
.toFlowable()
.flatMap { repoUpdater(force) }
.collect({}, {_, _ -> })
.flattenAsFlowable { repoDB.repos }
.map { RepoRvItem(it) }
.toList()
.doOnSuccess { allItems.update(it) }

View File

@ -11,7 +11,7 @@ import com.topjohnwu.magisk.Config
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.databinding.FragmentReposBinding
import com.topjohnwu.magisk.model.download.DownloadService
import com.topjohnwu.magisk.model.entity.Repo
import com.topjohnwu.magisk.model.entity.module.Repo
import com.topjohnwu.magisk.model.entity.internal.Configuration
import com.topjohnwu.magisk.model.entity.internal.DownloadSubject
import com.topjohnwu.magisk.model.events.InstallModuleEvent
@ -89,7 +89,7 @@ class ReposFragment : MagiskFragment<ModuleViewModel, FragmentReposBinding>(),
}
private fun openChangelog(item: Repo) {
MarkDownWindow.show(requireActivity(), null, item.detailUrl)
MarkDownWindow.show(requireActivity(), null, item.readme)
}
@SuppressLint("MissingPermission")

View File

@ -8,7 +8,6 @@ import android.view.LayoutInflater
import android.widget.EditText
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.content.edit
import androidx.databinding.DataBindingUtil
import androidx.preference.ListPreference
import androidx.preference.Preference
@ -20,7 +19,7 @@ import com.topjohnwu.magisk.BuildConfig
import com.topjohnwu.magisk.Config
import com.topjohnwu.magisk.Const
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.data.database.RepoDatabaseHelper
import com.topjohnwu.magisk.data.database.RepoDao
import com.topjohnwu.magisk.databinding.CustomDownloadDialogBinding
import com.topjohnwu.magisk.model.observer.Observer
import com.topjohnwu.magisk.ui.base.BasePreferenceFragment
@ -33,7 +32,7 @@ import java.io.File
class SettingsFragment : BasePreferenceFragment() {
private val repoDatabase: RepoDatabaseHelper by inject()
private val repoDB: RepoDao by inject()
private lateinit var updateChannel: ListPreference
private lateinit var autoRes: ListPreference
@ -76,10 +75,7 @@ class SettingsFragment : BasePreferenceFragment() {
true
}
findPreference("clear").setOnPreferenceClickListener {
prefs.edit {
remove(Config.Key.ETAG_KEY)
}
repoDatabase.clearRepo()
repoDB.clear()
Utils.toast(R.string.repo_cache_cleared, Toast.LENGTH_SHORT)
true
}

View File

@ -4,9 +4,11 @@ import android.content.Context
import android.view.LayoutInflater
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import com.skoumal.teanity.extensions.subscribeK
import com.topjohnwu.magisk.R
import com.topjohnwu.net.Networking
import com.topjohnwu.net.ResponseListener
import com.topjohnwu.magisk.data.repository.StringRepository
import com.topjohnwu.magisk.utils.inject
import io.reactivex.Single
import ru.noties.markwon.Markwon
import ru.noties.markwon.html.HtmlPlugin
import ru.noties.markwon.image.ImagesPlugin
@ -16,20 +18,22 @@ import java.util.*
object MarkDownWindow {
private val stringRepo: StringRepository by inject()
fun show(activity: Context, title: String?, url: String) {
Networking.get(url).getAsString(Listener(activity, title))
show(activity, title, stringRepo.getString(url))
}
fun show(activity: Context, title: String?, input: InputStream) {
Scanner(input, "UTF-8").use {
it.useDelimiter("\\A")
Listener(activity, title).onResponse(it.next())
}
Single.just(Scanner(input, "UTF-8").apply { useDelimiter("\\A") })
.map { it.next() }
.also {
show(activity, title, it)
}
}
internal class Listener(var activity: Context, var title: String?) : ResponseListener<String> {
override fun onResponse(md: String) {
fun show(activity: Context, title: String?, content: Single<String>) {
content.subscribeK {
val markwon = Markwon.builder(activity)
.usePlugin(HtmlPlugin.create())
.usePlugin(ImagesPlugin.create(activity))
@ -40,7 +44,7 @@ object MarkDownWindow {
val mv = LayoutInflater.from(activity).inflate(R.layout.markdown_window, null)
val tv = mv.findViewById<TextView>(R.id.md_txt)
try {
markwon.setMarkdown(tv, md)
markwon.setMarkdown(tv, it)
} catch (e: ExceptionInInitializerError) {
//Nothing we can do about this error other than show error message
tv.setText(R.string.download_file_error)

View File

@ -47,7 +47,7 @@
android:id="@+id/version_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@{item.item.version == null || item.item.version.length == 0 ? @string/no_info_provided : item.item.version}"
android:text="@{item.item.version.length == 0 ? @string/no_info_provided : item.item.version}"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="@android:color/tertiary_text_dark"
android:textIsSelectable="false"
@ -62,7 +62,7 @@
android:id="@+id/author"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@{item.item.author == null || item.item.author.length == 0 ? @string/no_info_provided : @string/author(item.item.author)}"
android:text="@{item.item.author.length == 0 ? @string/no_info_provided : @string/author(item.item.author)}"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="@android:color/tertiary_text_dark"
android:textIsSelectable="false"
@ -78,7 +78,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:text="@{item.item.description == null || item.item.description.length == 0 ? @string/no_info_provided : item.item.description}"
android:text="@{item.item.description.length == 0 ? @string/no_info_provided : item.item.description}"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textIsSelectable="false"
app:layout_constraintBottom_toTopOf="@+id/update_time"