diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..db8870d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,13 @@
+Copyright 2016 drunlin@outlook.com
+
+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.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..33816df
--- /dev/null
+++ b/README.md
@@ -0,0 +1,22 @@
+# WebappBox
+[](https://android-arsenal.com/api?level=16)
+
+### A special web browser that makes Web applications more like native applications.
+
+# Screenshots
+
+
+# License
+ Copyright 2016 drunlin@outlook.com
+
+ 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.
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..7a969b6
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1,2 @@
+/build
+/app.iml
diff --git a/app/build.gradle b/app/build.gradle
new file mode 100644
index 0000000..bb89097
--- /dev/null
+++ b/app/build.gradle
@@ -0,0 +1,106 @@
+import com.android.build.gradle.internal.tasks.databinding.DataBindingExportBuildInfoTask
+
+apply plugin: "com.android.application"
+apply plugin: "kotlin-android"
+apply plugin: "kotlin-android-extensions"
+
+buildscript {
+ ext.kotlin_version = "1.0.5-eap-83"
+
+ repositories {
+ jcenter()
+ maven { url "https://dl.bintray.com/kotlin/kotlin-eap" }
+ }
+
+ dependencies {
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
+ classpath "org.jetbrains.kotlin:kotlin-android-extensions:$kotlin_version"
+ }
+}
+
+android {
+ compileSdkVersion 25
+ buildToolsVersion "25"
+
+ defaultConfig {
+ applicationId "com.github.drunlin.webappbox"
+ minSdkVersion 16
+ targetSdkVersion 25
+ versionCode 1
+ versionName "1.0"
+
+ testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
+ }
+
+ signingConfigs {
+ release {
+ storeFile file(project.property("STORE_FILE"))
+ storePassword project.property("STORE_PASSWORD")
+ keyAlias project.property("KEY_ALIAS")
+ keyPassword project.property("KEY_PASSWORD")
+ }
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
+ signingConfig signingConfigs.release
+ }
+ }
+
+ sourceSets {
+ main.java.srcDirs += "src/main/kotlin"
+ }
+
+ dataBinding {
+ enabled = true
+ }
+}
+
+kapt {
+ generateStubs = true
+}
+
+repositories {
+ maven { url "https://dl.bintray.com/kotlin/kotlin-eap" }
+}
+
+dependencies {
+ compile fileTree(include: ["*.jar"], dir: "libs")
+ compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
+ compile "com.android.support:appcompat-v7:25.0.0"
+ compile "com.android.support:design:25.0.0"
+ compile "com.android.support:cardview-v7:25.0.0"
+ compile "com.android.support:preference-v7:25.0.0"
+ compile "com.android.support:preference-v14:25.0.0"
+ compile "com.thebluealliance:spectrum:0.6.0"
+ compile "com.github.rahatarmanahmed:circularprogressview:2.5.0"
+ kapt "com.android.databinding:compiler:2.2.2"
+ compile "com.google.dagger:dagger:2.5"
+ kapt "com.google.dagger:dagger-compiler:2.5"
+ compile "io.reactivex:rxjava:1.1.5"
+ compile "io.reactivex:rxandroid:1.2.0"
+ compile "com.jakewharton.rxbinding:rxbinding-design:0.4.0"
+ compile "com.jakewharton.rxbinding:rxbinding:0.4.0"
+ compile "com.jakewharton.rxbinding:rxbinding-appcompat-v7:0.4.0"
+ compile "org.jsoup:jsoup:1.9.2"
+
+ testCompile "junit:junit:4.12"
+ testCompile "org.robolectric:robolectric:3.0"
+
+ androidTestCompile "com.android.support:support-annotations:25.0.0"
+ androidTestCompile "com.android.support.test:runner:0.5"
+ androidTestCompile "com.android.support.test:rules:0.5"
+}
+
+/**
+ * Workaround for https://code.google.com/p/android/issues/detail?id=182715
+ */
+tasks.withType(DataBindingExportBuildInfoTask) { task ->
+ if (task.name.endsWith("AndroidTest")) {
+ task.finalizedBy(tasks.create("${task.name}Workaround") << {
+ task.output.deleteDir()
+ })
+ }
+}
diff --git a/app/lint.xml b/app/lint.xml
new file mode 100644
index 0000000..3e31e90
--- /dev/null
+++ b/app/lint.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..60d264b
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,38 @@
+# Add project specific ProGuard rules here.
+# By default, the flags in this file are appended to flags specified
+# in /home/ubuntu/Android/Sdk/tools/proguard/proguard-android.txt
+# You can edit the include path and order by changing the proguardFiles
+# directive in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# Add any project specific keep options here:
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+-dontwarn kotlin.**
+
+-dontwarn sun.misc.**
+
+-keepclassmembers class rx.internal.util.unsafe.*ArrayQueue*Field* {
+ long producerIndex;
+ long consumerIndex;
+}
+
+-keepclassmembers class rx.internal.util.unsafe.BaseLinkedQueueProducerNodeRef {
+ rx.internal.util.atomic.LinkedQueueNode producerNode;
+}
+
+-keepclassmembers class rx.internal.util.unsafe.BaseLinkedQueueConsumerNodeRef {
+ rx.internal.util.atomic.LinkedQueueNode consumerNode;
+}
+
+-keepclassmembers class * extends android.webkit.WebChromeClient{
+ public void openFileChooser(...);
+}
diff --git a/app/src/androidTest/java/com/github/drunlin/webappbox/Utils.kt b/app/src/androidTest/java/com/github/drunlin/webappbox/Utils.kt
new file mode 100644
index 0000000..d41b047
--- /dev/null
+++ b/app/src/androidTest/java/com/github/drunlin/webappbox/Utils.kt
@@ -0,0 +1,8 @@
+package com.github.drunlin.webappbox
+
+import android.support.test.rule.ActivityTestRule
+import android.support.v4.app.FragmentManager
+import android.support.v7.app.AppCompatActivity
+
+val ActivityTestRule.fragmentManager: FragmentManager
+ get() = activity.supportFragmentManager
diff --git a/app/src/androidTest/java/com/github/drunlin/webappbox/activity/FragmentActivityTest.kt b/app/src/androidTest/java/com/github/drunlin/webappbox/activity/FragmentActivityTest.kt
new file mode 100644
index 0000000..2e884bd
--- /dev/null
+++ b/app/src/androidTest/java/com/github/drunlin/webappbox/activity/FragmentActivityTest.kt
@@ -0,0 +1,25 @@
+package com.github.drunlin.webappbox.activity
+
+import android.support.test.rule.ActivityTestRule
+import android.support.test.runner.AndroidJUnit4
+import org.hamcrest.core.AllOf.allOf
+import org.hamcrest.core.IsNot.not
+import org.junit.Assert.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class FragmentActivityTest {
+ @Rule @JvmField val rule = ActivityTestRule(FragmentActivity::class.java)
+
+ @Test
+ fun obtainActivityAnimation() {
+ with(rule.activity) {
+ assertThat(openEnterAnimation, allOf(not(0), not(android.R.anim.fade_in)))
+ assertThat(openExitAnimation, allOf(not(0), not(android.R.anim.fade_out)))
+ assertThat(closeEnterAnimation, allOf(not(0), not(android.R.anim.fade_in)))
+ assertThat(closeExitAnimation, allOf(not(0), not(android.R.anim.fade_out)))
+ }
+ }
+}
diff --git a/app/src/androidTest/java/com/github/drunlin/webappbox/activity/MainActivityTest.kt b/app/src/androidTest/java/com/github/drunlin/webappbox/activity/MainActivityTest.kt
new file mode 100644
index 0000000..98d8d00
--- /dev/null
+++ b/app/src/androidTest/java/com/github/drunlin/webappbox/activity/MainActivityTest.kt
@@ -0,0 +1,17 @@
+package com.github.drunlin.webappbox.activity
+
+import android.support.test.rule.ActivityTestRule
+import android.support.test.runner.AndroidJUnit4
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class MainActivityTest {
+ @Rule @JvmField val rule = ActivityTestRule(MainActivity::class.java)
+
+ @Test
+ fun start() {
+ Thread.sleep(Long.MAX_VALUE)
+ }
+}
diff --git a/app/src/androidTest/java/com/github/drunlin/webappbox/activity/WebappEditorActivityTest.kt b/app/src/androidTest/java/com/github/drunlin/webappbox/activity/WebappEditorActivityTest.kt
new file mode 100644
index 0000000..b60233b
--- /dev/null
+++ b/app/src/androidTest/java/com/github/drunlin/webappbox/activity/WebappEditorActivityTest.kt
@@ -0,0 +1,23 @@
+package com.github.drunlin.webappbox.activity
+
+import android.support.test.rule.ActivityTestRule
+import android.support.test.runner.AndroidJUnit4
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class WebappEditorActivityTest {
+ @Rule @JvmField val rule = ActivityTestRule(WebappEditorActivity::class.java, false, false)
+
+ @Before
+ fun setUp() {
+ rule.launchActivity(WebappEditorActivity.new())
+ }
+
+ @Test
+ fun start() {
+ Thread.sleep(Long.MAX_VALUE)
+ }
+}
diff --git a/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/AboutFragmentTest.kt b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/AboutFragmentTest.kt
new file mode 100644
index 0000000..09fd1f5
--- /dev/null
+++ b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/AboutFragmentTest.kt
@@ -0,0 +1,24 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.support.test.rule.ActivityTestRule
+import android.support.test.runner.AndroidJUnit4
+import com.github.drunlin.webappbox.activity.FragmentActivity
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class AboutFragmentTest {
+ @Rule @JvmField val rule = ActivityTestRule(FragmentActivity::class.java)
+
+ @Before
+ fun setUp() {
+ rule.activity.setContentFragment(AboutFragment())
+ }
+
+ @Test
+ fun start() {
+ Thread.sleep(Long.MAX_VALUE)
+ }
+}
diff --git a/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/ColorPickerFragmentTest.kt b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/ColorPickerFragmentTest.kt
new file mode 100644
index 0000000..e5839be
--- /dev/null
+++ b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/ColorPickerFragmentTest.kt
@@ -0,0 +1,27 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.graphics.Color
+import android.support.test.rule.ActivityTestRule
+import android.support.test.runner.AndroidJUnit4
+import android.support.v7.app.AppCompatActivity
+import com.github.drunlin.webappbox.common.show
+import com.github.drunlin.webappbox.fragmentManager
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ColorPickerFragmentTest {
+ @Rule @JvmField val rule = ActivityTestRule(AppCompatActivity::class.java)
+
+ @Before
+ fun setUp() {
+ ColorPickerFragment(Color.BLACK).show(rule.fragmentManager)
+ }
+
+ @Test
+ fun start() {
+ Thread.sleep(Long.MAX_VALUE)
+ }
+}
diff --git a/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/IconLoaderFragmentTest.kt b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/IconLoaderFragmentTest.kt
new file mode 100644
index 0000000..728cf41
--- /dev/null
+++ b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/IconLoaderFragmentTest.kt
@@ -0,0 +1,26 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.support.test.rule.ActivityTestRule
+import android.support.test.runner.AndroidJUnit4
+import com.github.drunlin.webappbox.activity.WebappContextActivity
+import com.github.drunlin.webappbox.common.show
+import com.github.drunlin.webappbox.fragmentManager
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class IconLoaderFragmentTest {
+ @Rule @JvmField val rule = ActivityTestRule(WebappContextActivity::class.java)
+
+ @Before
+ fun setUp() {
+ IconLoaderFragment().show(rule.fragmentManager)
+ }
+
+ @Test
+ fun start() {
+ Thread.sleep(Long.MAX_VALUE)
+ }
+}
diff --git a/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/LicensesFragmentTest.kt b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/LicensesFragmentTest.kt
new file mode 100644
index 0000000..1fb347c
--- /dev/null
+++ b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/LicensesFragmentTest.kt
@@ -0,0 +1,24 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.support.test.rule.ActivityTestRule
+import android.support.test.runner.AndroidJUnit4
+import com.github.drunlin.webappbox.activity.FragmentActivity
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class LicensesFragmentTest {
+ @Rule @JvmField val rule = ActivityTestRule(FragmentActivity::class.java)
+
+ @Before
+ fun setUp() {
+ rule.activity.setContentFragment(LicensesFragment())
+ }
+
+ @Test
+ fun start() {
+ Thread.sleep(Long.MAX_VALUE)
+ }
+}
diff --git a/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/ManualFragmentTest.kt b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/ManualFragmentTest.kt
new file mode 100644
index 0000000..b2a20f5
--- /dev/null
+++ b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/ManualFragmentTest.kt
@@ -0,0 +1,24 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.support.test.rule.ActivityTestRule
+import android.support.test.runner.AndroidJUnit4
+import com.github.drunlin.webappbox.activity.FragmentActivity
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ManualFragmentTest {
+ @Rule @JvmField val rule = ActivityTestRule(FragmentActivity::class.java)
+
+ @Before
+ fun setUp() {
+ rule.activity.setContentFragment(ManualFragment())
+ }
+
+ @Test
+ fun start() {
+ Thread.sleep(Long.MAX_VALUE)
+ }
+}
diff --git a/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/PatternEditorFragmentTest.kt b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/PatternEditorFragmentTest.kt
new file mode 100644
index 0000000..cbe7049
--- /dev/null
+++ b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/PatternEditorFragmentTest.kt
@@ -0,0 +1,26 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.support.test.rule.ActivityTestRule
+import android.support.test.runner.AndroidJUnit4
+import com.github.drunlin.webappbox.activity.WebappContextActivity
+import com.github.drunlin.webappbox.common.show
+import com.github.drunlin.webappbox.fragmentManager
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class PatternEditorFragmentTest {
+ @Rule @JvmField val rule = ActivityTestRule(WebappContextActivity::class.java)
+
+ @Before
+ fun setUp() {
+ PatternEditorFragment().show(rule.fragmentManager)
+ }
+
+ @Test
+ fun start() {
+ Thread.sleep(Long.MAX_VALUE)
+ }
+}
diff --git a/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/PatternsFragmentTest.kt b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/PatternsFragmentTest.kt
new file mode 100644
index 0000000..6a5aaa3
--- /dev/null
+++ b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/PatternsFragmentTest.kt
@@ -0,0 +1,24 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.support.test.rule.ActivityTestRule
+import android.support.test.runner.AndroidJUnit4
+import com.github.drunlin.webappbox.activity.WebappContextActivity
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class PatternsFragmentTest {
+ @Rule @JvmField val rule = ActivityTestRule(WebappContextActivity::class.java)
+
+ @Before
+ fun setUp() {
+ rule.activity.setContentFragment(PatternsFragment())
+ }
+
+ @Test
+ fun start() {
+ Thread.sleep(Long.MAX_VALUE)
+ }
+}
diff --git a/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/PreviewFragmentTest.kt b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/PreviewFragmentTest.kt
new file mode 100644
index 0000000..c2df8b6
--- /dev/null
+++ b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/PreviewFragmentTest.kt
@@ -0,0 +1,24 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.support.test.rule.ActivityTestRule
+import android.support.test.runner.AndroidJUnit4
+import com.github.drunlin.webappbox.activity.WebappContextActivity
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class PreviewFragmentTest {
+ @Rule @JvmField val rule = ActivityTestRule(WebappContextActivity::class.java)
+
+ @Before
+ fun setUp() {
+ rule.activity.setContentFragment(PreviewFragment())
+ }
+
+ @Test
+ fun start() {
+ Thread.sleep(Long.MAX_VALUE)
+ }
+}
diff --git a/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/RuleEditorFragmentTest.kt b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/RuleEditorFragmentTest.kt
new file mode 100644
index 0000000..f8bda66
--- /dev/null
+++ b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/RuleEditorFragmentTest.kt
@@ -0,0 +1,24 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.support.test.rule.ActivityTestRule
+import android.support.test.runner.AndroidJUnit4
+import com.github.drunlin.webappbox.activity.WebappContextActivity
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class RuleEditorFragmentTest {
+ @Rule @JvmField val rule = ActivityTestRule(WebappContextActivity::class.java)
+
+ @Before
+ fun setUp() {
+ rule.activity.setContentFragment(RuleEditorFragment())
+ }
+
+ @Test
+ fun start() {
+ Thread.sleep(Long.MAX_VALUE)
+ }
+}
diff --git a/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/RulesFragmentTest.kt b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/RulesFragmentTest.kt
new file mode 100644
index 0000000..c0bb229
--- /dev/null
+++ b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/RulesFragmentTest.kt
@@ -0,0 +1,24 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.support.test.rule.ActivityTestRule
+import android.support.test.runner.AndroidJUnit4
+import com.github.drunlin.webappbox.activity.WebappContextActivity
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class RulesFragmentTest {
+ @Rule @JvmField val rule = ActivityTestRule(WebappContextActivity::class.java)
+
+ @Before
+ fun setUp() {
+ rule.activity.setContentFragment(RulesFragment())
+ }
+
+ @Test
+ fun start() {
+ Thread.sleep(Long.MAX_VALUE)
+ }
+}
diff --git a/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/UserAgentEditorFragmentTest.kt b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/UserAgentEditorFragmentTest.kt
new file mode 100644
index 0000000..3525110
--- /dev/null
+++ b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/UserAgentEditorFragmentTest.kt
@@ -0,0 +1,26 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.support.test.rule.ActivityTestRule
+import android.support.test.runner.AndroidJUnit4
+import android.support.v7.app.AppCompatActivity
+import com.github.drunlin.webappbox.common.show
+import com.github.drunlin.webappbox.fragmentManager
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class UserAgentEditorFragmentTest {
+ @Rule @JvmField val rule = ActivityTestRule(AppCompatActivity::class.java)
+
+ @Before
+ fun setUp() {
+ UserAgentEditorFragment().show(rule.fragmentManager)
+ }
+
+ @Test
+ fun start() {
+ Thread.sleep(Long.MAX_VALUE)
+ }
+}
diff --git a/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/UserAgentsFragmentTest.kt b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/UserAgentsFragmentTest.kt
new file mode 100644
index 0000000..44c2595
--- /dev/null
+++ b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/UserAgentsFragmentTest.kt
@@ -0,0 +1,24 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.support.test.rule.ActivityTestRule
+import android.support.test.runner.AndroidJUnit4
+import com.github.drunlin.webappbox.activity.FragmentActivity
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class UserAgentsFragmentTest {
+ @Rule @JvmField val rule = ActivityTestRule(FragmentActivity::class.java)
+
+ @Before
+ fun setUp() {
+ rule.activity.setContentFragment(UserAgentsFragment())
+ }
+
+ @Test
+ fun start() {
+ Thread.sleep(Long.MAX_VALUE)
+ }
+}
diff --git a/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/WebappEditorFragmentTest.kt b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/WebappEditorFragmentTest.kt
new file mode 100644
index 0000000..9f64778
--- /dev/null
+++ b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/WebappEditorFragmentTest.kt
@@ -0,0 +1,24 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.support.test.rule.ActivityTestRule
+import android.support.test.runner.AndroidJUnit4
+import com.github.drunlin.webappbox.activity.WebappContextActivity
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class WebappEditorFragmentTest {
+ @Rule @JvmField val rule = ActivityTestRule(WebappContextActivity::class.java)
+
+ @Before
+ fun setUp() {
+ rule.activity.setContentFragment(WebappEditorFragment())
+ }
+
+ @Test
+ fun start() {
+ Thread.sleep(Long.MAX_VALUE)
+ }
+}
diff --git a/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/WebappFragmentTest.kt b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/WebappFragmentTest.kt
new file mode 100644
index 0000000..09b9f9a
--- /dev/null
+++ b/app/src/androidTest/java/com/github/drunlin/webappbox/fragment/WebappFragmentTest.kt
@@ -0,0 +1,24 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.support.test.rule.ActivityTestRule
+import android.support.test.runner.AndroidJUnit4
+import com.github.drunlin.webappbox.activity.WebappContextActivity
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class WebappFragmentTest {
+ @Rule @JvmField val rule = ActivityTestRule(WebappContextActivity::class.java)
+
+ @Before
+ fun setUp() {
+ rule.activity.setContentFragment(WebappFragment())
+ }
+
+ @Test
+ fun start() {
+ Thread.sleep(Long.MAX_VALUE)
+ }
+}
diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..dd5725e
--- /dev/null
+++ b/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/debug/java/com/github/drunlin/webappbox/activity/WebappContextActivity.kt b/app/src/debug/java/com/github/drunlin/webappbox/activity/WebappContextActivity.kt
new file mode 100644
index 0000000..509f2d5
--- /dev/null
+++ b/app/src/debug/java/com/github/drunlin/webappbox/activity/WebappContextActivity.kt
@@ -0,0 +1,9 @@
+package com.github.drunlin.webappbox.activity
+
+import com.github.drunlin.webappbox.common.app
+import com.github.drunlin.webappbox.fragment.WebappContext
+import com.github.drunlin.webappbox.module.WebappModule.Flag.NEW
+
+class WebappContextActivity : FragmentActivity(), WebappContext {
+ override val component by lazy { app.webappComponent(0, NEW) }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..480eb1f
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/github/drunlin/webappbox/AppApplication.kt b/app/src/main/java/com/github/drunlin/webappbox/AppApplication.kt
new file mode 100644
index 0000000..81bcd14
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/AppApplication.kt
@@ -0,0 +1,23 @@
+package com.github.drunlin.webappbox
+
+import android.app.Application
+import com.github.drunlin.webappbox.module.*
+import com.github.drunlin.webappbox.module.WebappModule.Flag
+import com.github.drunlin.webappbox.module.WebappModule.Flag.NORMAL
+
+class AppApplication : Application() {
+ lateinit var component: AppComponent
+ private set
+
+ private val webappComponents = Factory()
+
+ override fun onCreate() {
+ super.onCreate()
+
+ component = DaggerAppComponent.builder().appModule(AppModule(this)).build()
+ }
+
+ fun webappComponent(id: Long, flag: Flag = NORMAL) = webappComponents.get("$id-$flag") {
+ component.webappComponent(WebappModule(id, flag))
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/activity/AboutActivity.kt b/app/src/main/java/com/github/drunlin/webappbox/activity/AboutActivity.kt
new file mode 100644
index 0000000..1fbd360
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/activity/AboutActivity.kt
@@ -0,0 +1,12 @@
+package com.github.drunlin.webappbox.activity
+
+import android.os.Bundle
+import com.github.drunlin.webappbox.fragment.AboutFragment
+
+class AboutActivity : FragmentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ savedInstanceState ?: setContentFragment(AboutFragment())
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/activity/CompatWebappActivities.kt b/app/src/main/java/com/github/drunlin/webappbox/activity/CompatWebappActivities.kt
new file mode 100644
index 0000000..171d32b
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/activity/CompatWebappActivities.kt
@@ -0,0 +1,15 @@
+package com.github.drunlin.webappbox.activity
+
+val MAX_ACTIVITY_ID = 9
+val CLASS_NAME_PREFIX = WebappActivity::class.java.name!!
+
+class WebappActivity0 : WebappActivity()
+class WebappActivity1 : WebappActivity()
+class WebappActivity2 : WebappActivity()
+class WebappActivity3 : WebappActivity()
+class WebappActivity4 : WebappActivity()
+class WebappActivity5 : WebappActivity()
+class WebappActivity6 : WebappActivity()
+class WebappActivity7 : WebappActivity()
+class WebappActivity8 : WebappActivity()
+class WebappActivity9 : WebappActivity()
diff --git a/app/src/main/java/com/github/drunlin/webappbox/activity/FragmentActivity.kt b/app/src/main/java/com/github/drunlin/webappbox/activity/FragmentActivity.kt
new file mode 100644
index 0000000..9cb9bde
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/activity/FragmentActivity.kt
@@ -0,0 +1,92 @@
+package com.github.drunlin.webappbox.activity
+
+import android.content.Context
+import android.os.Bundle
+import android.support.v4.app.Fragment
+import android.view.MenuItem
+import android.view.inputmethod.InputMethodManager
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.common.Callback
+import com.github.drunlin.webappbox.common.add
+import com.github.drunlin.webappbox.widget.ContentLayout
+
+open class FragmentActivity : TranslucentStatusActivity() {
+ val onWindowFocusChanged = Callback<(Boolean) -> Unit>()
+
+ var openEnterAnimation = 0
+ private set
+ var openExitAnimation = 0
+ private set
+ var closeEnterAnimation = 0
+ private set
+ var closeExitAnimation = 0
+ private set
+
+ lateinit var contentView: ContentLayout
+ private set
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ obtainActivityAnimation()
+
+ contentView = ContentLayout(this).apply { id = R.id.content }
+ setContentView(contentView)
+ }
+
+ private fun obtainActivityAnimation() {
+ val attrs = intArrayOf(
+ android.R.attr.activityOpenEnterAnimation,
+ android.R.attr.activityOpenExitAnimation,
+ android.R.attr.activityCloseEnterAnimation,
+ android.R.attr.activityCloseExitAnimation)
+ val array = obtainStyledAttributes(android.R.style.Animation_Activity, attrs)
+ openEnterAnimation = array.getResourceId(0, android.R.anim.fade_in)
+ openExitAnimation = array.getResourceId(1, android.R.anim.fade_out)
+ closeEnterAnimation = array.getResourceId(2, android.R.anim.fade_in)
+ closeExitAnimation = array.getResourceId(3, android.R.anim.fade_out)
+ array.recycle()
+ }
+
+ fun setContentFragment(fragment: Fragment) {
+ supportFragmentManager.add(R.id.content, fragment)
+ }
+
+ fun replaceContentFragment(fragment: Fragment) {
+ hideSoftKeyboard()
+
+ val currentFragment = supportFragmentManager.findFragmentById(R.id.content)
+ supportFragmentManager.beginTransaction()
+ .setCustomAnimations(0, openExitAnimation, closeEnterAnimation, 0)
+ .hide(currentFragment)
+ .setCustomAnimations(openEnterAnimation, 0, 0, closeExitAnimation)
+ .add(R.id.content, fragment)
+ .addToBackStack(null)
+ .commit()
+ }
+
+ private fun hideSoftKeyboard() {
+ val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
+ imm.hideSoftInputFromWindow(contentView.windowToken, 0)
+ }
+
+ override fun onWindowFocusChanged(hasFocus: Boolean) {
+ super.onWindowFocusChanged(hasFocus)
+
+ onWindowFocusChanged.invoke { it(hasFocus) }
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ if (item.itemId == android.R.id.home) {
+ onBackPressed()
+ return true
+ }
+ return super.onOptionsItemSelected(item)
+ }
+
+ override fun onBackPressed() {
+ super.onBackPressed()
+
+ hideSoftKeyboard()
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/activity/LauncherActivity.kt b/app/src/main/java/com/github/drunlin/webappbox/activity/LauncherActivity.kt
new file mode 100644
index 0000000..500780f
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/activity/LauncherActivity.kt
@@ -0,0 +1,86 @@
+package com.github.drunlin.webappbox.activity
+
+import android.annotation.TargetApi
+import android.app.Activity
+import android.app.ActivityManager
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.widget.Toast
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.activity.WebappActivity.Companion.uuid
+import com.github.drunlin.webappbox.common.EXTRA_UUID
+import com.github.drunlin.webappbox.common.app
+import com.github.drunlin.webappbox.common.setClass
+import com.github.drunlin.webappbox.model.WebappManager
+import javax.inject.Inject
+
+class LauncherActivity : Activity() {
+ companion object {
+ fun start(uuid: String) = Intent()
+ .setAction(Intent.ACTION_VIEW)
+ .setClass(LauncherActivity::class.java)
+ .putExtra(EXTRA_UUID, uuid)!!
+ }
+
+ @Inject lateinit var webappManager: WebappManager
+
+ private val uuid by lazy { intent.getStringExtra(EXTRA_UUID) }
+
+ private val am by lazy { getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ app.component.inject(this)
+
+ if (webappManager.getWebappId(uuid) == null)
+ Toast.makeText(this, R.string.app_not_found, Toast.LENGTH_SHORT).show()
+ else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
+ startWebapp()
+ else
+ startCompatWebapp()
+
+ finish()
+ }
+
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ private fun startWebapp() {
+ am.appTasks.find { isTargetWebappActivity(it.taskInfo.baseIntent) }?.moveToFront()
+ ?: startActivity(WebappActivity.intent(uuid))
+ }
+
+ private fun isTargetWebappActivity(intent: Intent)
+ = isWebappActivity(getActivityName(intent)) && intent.uuid == uuid
+
+ private fun isWebappActivity(name: String) = name.startsWith(CLASS_NAME_PREFIX)
+
+ private fun getActivityName(intent: Intent) = intent.component.className
+
+ private fun startCompatWebapp() {
+ @Suppress("DEPRECATION")
+ val tasks = am.getRecentTasks(Int.MAX_VALUE, ActivityManager.RECENT_IGNORE_UNAVAILABLE)
+ val task = tasks.find { isTargetWebappActivity(it.baseIntent) }
+ val name = if (task != null) {
+ if (task.id != -1) {
+ am.moveTaskToFront(task.id, 0)
+ return
+ } else {
+ getActivityName(task.baseIntent)
+ }
+ } else {
+ val names = tasks.map { getActivityName(it.baseIntent) }.filter { isWebappActivity(it) }
+ if (names.lastIndex < MAX_ACTIVITY_ID) {
+ val ids = names.map { it.substringAfter(CLASS_NAME_PREFIX).toInt() }.toSet()
+ "$CLASS_NAME_PREFIX${(0..MAX_ACTIVITY_ID).find { !ids.contains(it) }}"
+ } else {
+ names.last()
+ }
+ }
+ val intent = WebappActivity.intent(uuid)
+ .setClassName(this, name)
+ .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
+ startActivity(intent)
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/activity/MainActivity.kt b/app/src/main/java/com/github/drunlin/webappbox/activity/MainActivity.kt
new file mode 100644
index 0000000..d254a5f
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/activity/MainActivity.kt
@@ -0,0 +1,120 @@
+package com.github.drunlin.webappbox.activity
+
+import android.content.Intent
+import android.os.Bundle
+import android.support.v7.widget.GridLayoutManager
+import android.support.v7.widget.SearchView
+import android.view.*
+import com.github.drunlin.webappbox.BR
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.common.app
+import com.github.drunlin.webappbox.common.asyncCall
+import com.github.drunlin.webappbox.data.Shortcut
+import com.github.drunlin.webappbox.databinding.ItemMainBinding
+import com.github.drunlin.webappbox.model.WebappManager
+import com.github.drunlin.webappbox.widget.adapter.ListAdapter
+import com.jakewharton.rxbinding.support.v7.widget.RxSearchView
+import kotlinx.android.synthetic.main.list_content.*
+import kotlinx.android.synthetic.main.toolbar.*
+import rx.android.schedulers.AndroidSchedulers
+import rx.schedulers.Schedulers
+import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+
+class MainActivity : TranslucentStatusActivity() {
+ @Inject lateinit var webappManager: WebappManager
+
+ private val adapter by lazy {
+ Adapter().apply { asyncCall({ webappManager.shortcuts }) { list = it } }
+ }
+
+ private var selectionId = 0L
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ app.component.inject(this)
+
+ setContentView(R.layout.activity_main)
+
+ setSupportActionBar(toolbar)
+ supportActionBar?.setTitle(R.string.apps)
+
+ webappManager.onInsert.add(this) { adapter.notifyItemInserted(it) }
+ webappManager.onRemove.add(this) { adapter.notifyItemRemoved(it) }
+ webappManager.onUpdate.add(this) { adapter.notifyItemChanged(it) }
+
+ recyclerView.layoutManager =
+ GridLayoutManager(this, resources.getInteger(R.integer.launcher_column_count))
+ recyclerView.adapter = adapter
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.activity_main, menu)
+
+ val searchView = menu.findItem(R.id.menu_search).actionView as SearchView
+ RxSearchView.queryTextChanges(searchView)
+ .skip(1)
+ .debounce(500, TimeUnit.MILLISECONDS)
+ .observeOn(Schedulers.io())
+ .map { it.toString() }
+ .map { webappManager.filter(it) }
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe { adapter.list = it }
+ return true
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ when (item.itemId) {
+ R.id.menu_new -> startActivity(WebappEditorActivity.new())
+ R.id.menu_settings -> startActivity(Intent(this, SettingsActivity::class.java))
+ R.id.menu_about -> startActivity(Intent(this, AboutActivity::class.java))
+ }
+ return true
+ }
+
+ override fun onCreateContextMenu(menu: ContextMenu, v: View, info: ContextMenu.ContextMenuInfo?) {
+ menuInflater.inflate(R.menu.item_webapp, menu)
+ }
+
+ override fun onContextItemSelected(item: MenuItem): Boolean {
+ when (item.itemId) {
+ R.id.menu_edit -> startActivity(WebappEditorActivity.edit(selectionId))
+ R.id.menu_delete -> webappManager.delete(selectionId)
+ R.id.menu_add_shortcut -> webappManager.installShortcut(selectionId)
+ }
+ return true
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+
+ webappManager.onInsert.remove(this)
+ webappManager.onRemove.remove(this)
+ webappManager.onUpdate.remove(this)
+ }
+
+ private inner class Adapter : ListAdapter() {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainActivity.ViewHolder {
+ return ViewHolder(ItemMainBinding.inflate(layoutInflater, parent, false))
+ }
+ }
+
+ private inner class ViewHolder(val binding: ItemMainBinding) :
+ ListAdapter.ViewHolder(binding.root) {
+
+ init {
+ itemView.setOnClickListener { startActivity(WebappActivity.start(data!!.uuid)) }
+ itemView.setOnCreateContextMenuListener(this@MainActivity)
+ itemView.setOnLongClickListener {
+ selectionId = data!!.id
+ itemView.showContextMenu()
+ return@setOnLongClickListener true
+ }
+ }
+
+ override fun onBind(data: Shortcut) {
+ binding.setVariable(BR.shortcut, data)
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/activity/SettingsActivity.kt b/app/src/main/java/com/github/drunlin/webappbox/activity/SettingsActivity.kt
new file mode 100644
index 0000000..d65af42
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/activity/SettingsActivity.kt
@@ -0,0 +1,12 @@
+package com.github.drunlin.webappbox.activity
+
+import android.os.Bundle
+import com.github.drunlin.webappbox.fragment.SettingsFragment
+
+class SettingsActivity : FragmentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ savedInstanceState ?: setContentFragment(SettingsFragment())
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/activity/TranslucentStatusActivity.kt b/app/src/main/java/com/github/drunlin/webappbox/activity/TranslucentStatusActivity.kt
new file mode 100644
index 0000000..565704f
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/activity/TranslucentStatusActivity.kt
@@ -0,0 +1,15 @@
+package com.github.drunlin.webappbox.activity
+
+import android.os.Build
+import android.os.Bundle
+import android.support.v7.app.AppCompatActivity
+import android.view.View
+
+abstract class TranslucentStatusActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
+ window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/activity/WebappActivity.kt b/app/src/main/java/com/github/drunlin/webappbox/activity/WebappActivity.kt
new file mode 100644
index 0000000..cc25ab4
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/activity/WebappActivity.kt
@@ -0,0 +1,68 @@
+package com.github.drunlin.webappbox.activity
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.widget.Toast
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.common.EXTRA_UUID
+import com.github.drunlin.webappbox.common.app
+import com.github.drunlin.webappbox.common.setClass
+import com.github.drunlin.webappbox.fragment.WebappContext
+import com.github.drunlin.webappbox.fragment.WebappFragment
+import com.github.drunlin.webappbox.model.WebappManager
+import javax.inject.Inject
+
+open class WebappActivity : FragmentActivity(), WebappContext {
+ companion object {
+ internal val Intent.uuid: String get() = data.getQueryParameter(EXTRA_UUID)
+
+ internal fun intent(uuid: String) = Intent()
+ .setClass(WebappActivity::class.java)
+ .setData(Uri.parse("?$EXTRA_UUID=$uuid"))
+
+ fun start(uuid: String) = LauncherActivity.start(uuid)
+ }
+
+ @Inject lateinit var webappManager: WebappManager
+
+ override val component by lazy { app.webappComponent(id!!) }
+
+ private val id by lazy { webappManager.getWebappId(intent.uuid) }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ if (intent.flags and Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS != 0)
+ finish()
+ else
+ create(savedInstanceState)
+ }
+
+ private fun create(savedInstanceState: Bundle?) {
+ app.component.inject(this)
+
+ if (id != null) {
+ savedInstanceState ?: setContentFragment(WebappFragment())
+ } else {
+ Toast.makeText(this, R.string.app_not_found, Toast.LENGTH_SHORT).show()
+ finishAndRemoveTaskCompat()
+ }
+ }
+
+ private fun finishAndRemoveTaskCompat() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ finishAndRemoveTask()
+ } else {
+ val intent = Intent()
+ .setComponent(intent.component)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
+ .addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
+ .addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
+ startActivity(intent)
+ finish()
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/activity/WebappEditorActivity.kt b/app/src/main/java/com/github/drunlin/webappbox/activity/WebappEditorActivity.kt
new file mode 100644
index 0000000..bc6fe95
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/activity/WebappEditorActivity.kt
@@ -0,0 +1,33 @@
+package com.github.drunlin.webappbox.activity
+
+import android.content.Intent
+import android.os.Bundle
+import com.github.drunlin.webappbox.common.*
+import com.github.drunlin.webappbox.fragment.WebappContext
+import com.github.drunlin.webappbox.fragment.WebappEditorFragment
+import com.github.drunlin.webappbox.module.WebappModule.Flag.EDIT
+import com.github.drunlin.webappbox.module.WebappModule.Flag.NEW
+
+class WebappEditorActivity : FragmentActivity(), WebappContext {
+ companion object {
+ fun new() = Intent(ACTION_NEW)
+ .setClass(WebappEditorActivity::class.java)
+ .putExtra(EXTRA_ID, generateId())!!
+
+ fun edit(id: Long) = Intent(ACTION_EDIT)
+ .setClass(WebappEditorActivity::class.java)
+ .putExtra(EXTRA_ID, id)!!
+ }
+
+ override val component by lazy { app.webappComponent(id, if (new) NEW else EDIT) }
+
+ private val id by lazy { intent.getLongExtra(EXTRA_ID) }
+ private val new by lazy { intent.action == ACTION_NEW }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ if (savedInstanceState == null)
+ setContentFragment(if (new) WebappEditorFragment() else WebappEditorFragment(id))
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/common/Callback.kt b/app/src/main/java/com/github/drunlin/webappbox/common/Callback.kt
new file mode 100644
index 0000000..46fa5e1
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/common/Callback.kt
@@ -0,0 +1,20 @@
+package com.github.drunlin.webappbox.common
+
+import java.util.*
+
+class Callback> {
+ private val functions: HashMap> = HashMap()
+
+ fun add(tag: Any, block: T) {
+ val set = functions[tag] ?: HashSet().apply { functions[tag] = this }
+ set.add(block)
+ }
+
+ fun remove(tag: Any) {
+ functions.remove(tag)
+ }
+
+ fun invoke(block: (T) -> Unit) {
+ functions.forEach { it.value.forEach { block(it) } }
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/common/Constants.kt b/app/src/main/java/com/github/drunlin/webappbox/common/Constants.kt
new file mode 100644
index 0000000..3a20d12
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/common/Constants.kt
@@ -0,0 +1,39 @@
+package com.github.drunlin.webappbox.common
+
+import android.os.Build
+
+val ACTION_NEW = "${Build.ID}.ACTION_NEW"
+val ACTION_EDIT = "${Build.ID}.ACTION_EDIT"
+
+val EXTRA_ID = "id"
+val EXTRA_UUID = "uuid"
+val EXTRA_SHORTCUT_DUPLICATE = "duplicate"
+
+val REQUEST_PICK_PICTURE = 0
+val REQUEST_LOCATION_SETTINGS = 1
+val REQUEST_GET_CONTENT = 2
+
+val PERMISSIONS_REQUEST_LOCATION = 0
+val PERMISSIONS_REQUEST_STORAGE = 1
+
+val ARGUMENT_ID = "id"
+val ARGUMENT_URL = "url"
+val ARGUMENT_COLOR = "color"
+
+val BUNDLE_UA = "ua"
+val BUNDLE_ICON = "icon"
+val BUNDLE_COUNT = "count"
+val BUNDLE_IMAGE = "image"
+
+val STATE_SUPER = "super"
+val STATE_CHILDREN = "children"
+val STATE_COLOR = "color"
+
+val PREF_COLOR = "status_bar_color"
+val PREF_USER_AGENT = "user_agent"
+val PREF_ENABLE_JS = "enable_js"
+val PREF_ORIENTATION = "screen_orientation"
+val PREF_FULL_SCREEN = "full_screen"
+val PREF_CLEAR_DATA = "clear_data"
+val PREF_CLEAR_CACHE = "clear_cache"
+val PREF_LAUNCH_MODE = "launch_mode"
diff --git a/app/src/main/java/com/github/drunlin/webappbox/common/HtmlCompact.kt b/app/src/main/java/com/github/drunlin/webappbox/common/HtmlCompact.kt
new file mode 100644
index 0000000..b67af57
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/common/HtmlCompact.kt
@@ -0,0 +1,18 @@
+package com.github.drunlin.webappbox.common
+
+import android.os.Build
+import android.text.Html
+import android.text.Spanned
+
+class HtmlCompact {
+ companion object {
+ fun fromHtml(source: String): Spanned {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ Html.fromHtml(source, Html.FROM_HTML_MODE_LEGACY)
+ } else {
+ @Suppress("DEPRECATION")
+ Html.fromHtml(source)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/common/Utils.kt b/app/src/main/java/com/github/drunlin/webappbox/common/Utils.kt
new file mode 100644
index 0000000..85cef59
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/common/Utils.kt
@@ -0,0 +1,149 @@
+package com.github.drunlin.webappbox.common
+
+import android.app.Activity
+import android.app.ActivityManager
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.Bitmap.CompressFormat.PNG
+import android.graphics.BitmapFactory
+import android.graphics.drawable.BitmapDrawable
+import android.net.Uri
+import android.os.Bundle
+import android.support.annotation.IdRes
+import android.support.v4.app.DialogFragment
+import android.support.v4.app.Fragment
+import android.support.v4.app.FragmentManager
+import android.support.v7.app.AlertDialog
+import android.util.Patterns
+import android.util.TypedValue
+import android.webkit.URLUtil
+import android.widget.Button
+import android.widget.ImageView
+import android.widget.TextView
+import com.github.drunlin.webappbox.AppApplication
+import com.github.drunlin.webappbox.BuildConfig
+import rx.Observable
+import rx.android.schedulers.AndroidSchedulers
+import rx.schedulers.Schedulers
+import java.io.ByteArrayOutputStream
+
+private var autoIncrement: Long = 0
+
+fun generateId() = ++autoIncrement
+
+fun asyncCall(asyncBlock: () -> T, block: (T) -> Unit) {
+ Observable.fromCallable { asyncBlock() }
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe { block(it) }
+}
+
+fun runOnIoThread(action: () -> Unit) {
+ Schedulers.io().createWorker().schedule(action)
+}
+
+fun Iterable.findNullableIndexedValue(predicate: (T) -> Boolean): IndexedValue {
+ var index = -1
+ val value = find { ++index; predicate(it) }
+ return IndexedValue(value?.let { index } ?: -1, value)
+}
+
+fun Iterable.findIndexedValue(predicate: (T) -> Boolean): IndexedValue {
+ return withIndex().find { predicate(it.value) }!!
+}
+
+fun Intent.setClass(clazz: Class<*>) = setClassName(BuildConfig.APPLICATION_ID, clazz.name)!!
+
+fun Intent.getLongExtra(name: String) = getLongExtra(name, -1)
+
+fun Bitmap.toByteArray(): ByteArray {
+ return ByteArrayOutputStream().apply { compress(PNG, 0, this) }.toByteArray()
+}
+
+fun ByteArray.toBitmap() = BitmapFactory.decodeByteArray(this, 0, size)!!
+
+fun Context.getBitmap(id: Int) = BitmapFactory.decodeResource(resources, id)!!
+
+fun Context.getDimension(unit: Int, value: Float): Float {
+ return TypedValue.applyDimension(unit, value, resources.displayMetrics)
+}
+
+fun Context.getRawText(id: Int) = resources.openRawResource(id).reader().readText()
+
+fun Context.getResourceId(attr: Int): Int {
+ val array = obtainStyledAttributes(intArrayOf(attr))
+ val resId = array.getResourceId(0, 0)
+ array.recycle()
+ return resId
+}
+
+val Context.iconSize: Int
+ get() = (getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).launcherLargeIconSize
+
+val Activity.app: AppApplication get() = application as AppApplication
+
+fun Activity.startWebBrowser(url: String) {
+ safeStartActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
+}
+
+fun Activity.safeStartActivity(intent: Intent) {
+ try {
+ startActivity(intent)
+ } catch (e: ActivityNotFoundException) {
+ //do nothing
+ }
+}
+
+fun FragmentManager.add(@IdRes viewId: Int, fragment: T) : T {
+ beginTransaction().add(viewId, fragment).commit()
+ return fragment
+}
+
+fun FragmentManager.remove(fragment: Fragment) {
+ beginTransaction().remove(fragment).commit()
+}
+
+val Fragment.app: AppApplication get() = activity.application as AppApplication
+
+fun Fragment.getSystemService(name: String): Any? = context.getSystemService(name)
+
+var Fragment.friendFragment: Fragment
+ set(value) {
+ arguments ?: run { arguments = Bundle() }
+
+ var depth = 0
+ var fragment: Fragment? = value
+ do {
+ fragment!!.fragmentManager.putFragment(arguments, "FRIEND_FRAGMENT${depth++}", fragment)
+ fragment = fragment.parentFragment
+ } while (fragment != null)
+
+ arguments.putInt("FRIEND_FRAGMENT_DEPTH", depth)
+ }
+ get() {
+ var fragment: Fragment? = null
+ val depth = arguments.getInt("FRIEND_FRAGMENT_DEPTH")
+ (depth downTo 0).forEach {
+ val fm = fragment?.childFragmentManager ?: activity.supportFragmentManager
+ fragment = fm.getFragment(arguments, "FRIEND_FRAGMENT$it")
+ }
+ return fragment!!
+ }
+
+fun Fragment.showDialog(dialogFragment: DialogFragment) {
+ dialogFragment.show(childFragmentManager)
+}
+
+fun DialogFragment.show(manager: FragmentManager) {
+ show(manager, null)
+}
+
+val AlertDialog.positiveButton : Button get() = getButton(AlertDialog.BUTTON_POSITIVE)
+
+val ImageView.bitmap: Bitmap get() = (drawable as BitmapDrawable).bitmap
+
+val TextView.string: String get() = text.toString()
+
+fun String.isValidUrl() = URLUtil.isValidUrl(this) && Patterns.WEB_URL.matcher(this).matches()
diff --git a/app/src/main/java/com/github/drunlin/webappbox/data/LaunchMode.kt b/app/src/main/java/com/github/drunlin/webappbox/data/LaunchMode.kt
new file mode 100644
index 0000000..3aa5816
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/data/LaunchMode.kt
@@ -0,0 +1,3 @@
+package com.github.drunlin.webappbox.data
+
+enum class LaunchMode { STANDARD, NEW_WINDOW, CLEAR_TOP, SINGLE_TOP, SINGLE_TASK }
diff --git a/app/src/main/java/com/github/drunlin/webappbox/data/Orientation.kt b/app/src/main/java/com/github/drunlin/webappbox/data/Orientation.kt
new file mode 100644
index 0000000..8eebf43
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/data/Orientation.kt
@@ -0,0 +1,3 @@
+package com.github.drunlin.webappbox.data
+
+enum class Orientation { NORMAL, LANDSCAPE, PORTRAIT }
diff --git a/app/src/main/java/com/github/drunlin/webappbox/data/Policy.kt b/app/src/main/java/com/github/drunlin/webappbox/data/Policy.kt
new file mode 100644
index 0000000..050cd05
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/data/Policy.kt
@@ -0,0 +1,3 @@
+package com.github.drunlin.webappbox.data
+
+enum class Policy { ASK, ALLOW, DENY }
diff --git a/app/src/main/java/com/github/drunlin/webappbox/data/Rule.kt b/app/src/main/java/com/github/drunlin/webappbox/data/Rule.kt
new file mode 100644
index 0000000..bb0ff22
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/data/Rule.kt
@@ -0,0 +1,10 @@
+package com.github.drunlin.webappbox.data
+
+data class Rule(override var id: Long,
+ var pattern: URLPattern,
+ var color: Int,
+ var launchMode: LaunchMode,
+ var orientation: Orientation,
+ var fullScreen: Boolean,
+ var userAgent: UserAgent,
+ var jsEnabled: Boolean) : Unique
diff --git a/app/src/main/java/com/github/drunlin/webappbox/data/Shortcut.kt b/app/src/main/java/com/github/drunlin/webappbox/data/Shortcut.kt
new file mode 100644
index 0000000..a879043
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/data/Shortcut.kt
@@ -0,0 +1,5 @@
+package com.github.drunlin.webappbox.data
+
+import android.graphics.Bitmap
+
+data class Shortcut(var id: Long, val uuid: String, var icon: Bitmap, var name: String)
diff --git a/app/src/main/java/com/github/drunlin/webappbox/data/URLPattern.kt b/app/src/main/java/com/github/drunlin/webappbox/data/URLPattern.kt
new file mode 100644
index 0000000..ed48fe5
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/data/URLPattern.kt
@@ -0,0 +1,9 @@
+package com.github.drunlin.webappbox.data
+
+import java.net.URL
+
+data class URLPattern(override var id: Long, var pattern: String, var regex: Boolean) : Unique {
+ constructor(pattern: String = ".*", regex: Boolean = true) : this(0, pattern, regex)
+
+ fun matches(url: String) = if (regex) Regex(pattern).matches(url) else URL(pattern) == URL(url)
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/data/Unique.kt b/app/src/main/java/com/github/drunlin/webappbox/data/Unique.kt
new file mode 100644
index 0000000..9b6a0a4
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/data/Unique.kt
@@ -0,0 +1,5 @@
+package com.github.drunlin.webappbox.data
+
+interface Unique {
+ val id: Long
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/data/UserAgent.kt b/app/src/main/java/com/github/drunlin/webappbox/data/UserAgent.kt
new file mode 100644
index 0000000..df60320
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/data/UserAgent.kt
@@ -0,0 +1,3 @@
+package com.github.drunlin.webappbox.data
+
+data class UserAgent(override var id: Long, var name: String, var value: String) : Unique
diff --git a/app/src/main/java/com/github/drunlin/webappbox/data/Webapp.kt b/app/src/main/java/com/github/drunlin/webappbox/data/Webapp.kt
new file mode 100644
index 0000000..bb662dc
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/data/Webapp.kt
@@ -0,0 +1,9 @@
+package com.github.drunlin.webappbox.data
+
+import android.graphics.Bitmap
+
+data class Webapp(override var id: Long,
+ var url: String,
+ var icon: Bitmap,
+ var name: String,
+ var locationPolicy: Policy) : Unique
diff --git a/app/src/main/java/com/github/drunlin/webappbox/fragment/AboutFragment.kt b/app/src/main/java/com/github/drunlin/webappbox/fragment/AboutFragment.kt
new file mode 100644
index 0000000..dd5002a
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/fragment/AboutFragment.kt
@@ -0,0 +1,54 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.Intent
+import android.databinding.DataBindingUtil
+import android.databinding.ViewDataBinding
+import android.net.Uri
+import android.os.Bundle
+import android.support.design.widget.Snackbar
+import android.view.View
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.common.getSystemService
+import com.github.drunlin.webappbox.common.safeStartActivity
+import com.github.drunlin.webappbox.common.startWebBrowser
+import kotlinx.android.synthetic.main.fragment_about.*
+
+class AboutFragment : SecondaryFragment() {
+ override val titleResId = R.string.about
+ override val contentViewResId = R.layout.fragment_about
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ DataBindingUtil.bind(contentView)
+
+ githubItem.setOnClickListener {
+ activity.startWebBrowser(getString(R.string.project_homepage))
+ }
+
+ developerItem.setOnClickListener {
+ val intent = Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:"))
+ .putExtra(Intent.EXTRA_EMAIL, arrayOf(getString(R.string.author_email)))
+ .putExtra(Intent.EXTRA_SUBJECT, getString(R.string.app_name))
+ activity.safeStartActivity(intent)
+ }
+
+ licensesItem.setOnClickListener { activity.replaceContentFragment(LicensesFragment()) }
+
+ manualItem.setOnClickListener { activity.replaceContentFragment(ManualFragment()) }
+
+ donationItem.setOnClickListener {
+ Snackbar.make(view, R.string.copy_to_clipboard, Snackbar.LENGTH_LONG)
+ .setAction(R.string.copy) { copyEmailAddress() }
+ .show()
+ }
+ }
+
+ private fun copyEmailAddress() {
+ val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ clipboard.primaryClip = ClipData.newPlainText(null, getString(R.string.author_email))
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/fragment/AppBarFragment.kt b/app/src/main/java/com/github/drunlin/webappbox/fragment/AppBarFragment.kt
new file mode 100644
index 0000000..c77e361
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/fragment/AppBarFragment.kt
@@ -0,0 +1,45 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.os.Bundle
+import android.support.v4.app.Fragment
+import android.support.v7.widget.Toolbar
+import android.view.LayoutInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.activity.FragmentActivity
+import kotlinx.android.synthetic.main.fragment_toolbar.*
+import kotlinx.android.synthetic.main.fragment_toolbar.view.*
+import kotlinx.android.synthetic.main.toolbar.*
+
+abstract class AppBarFragment : Fragment(), Toolbar.OnMenuItemClickListener {
+ open protected val titleResId: Int? = null
+ open protected val menuResId: Int? = null
+ open protected val viewResId = R.layout.fragment_toolbar
+ open protected val contentViewResId: Int? = null
+
+ protected val activity: FragmentActivity get() = getActivity() as FragmentActivity
+ protected val contentView: View get() = container.getChildAt(0)
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?): View? {
+ val view = inflater.inflate(viewResId, container, false)
+ contentViewResId?.run { inflater.inflate(this, view.container) }
+ return view
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ view.setBackgroundResource(android.R.color.background_light)
+
+ titleResId?.run { toolbar.setTitle(this) }
+ if (menuResId != null) {
+ toolbar.inflateMenu(menuResId!!)
+ toolbar.setOnMenuItemClickListener(this)
+ }
+ }
+
+ override fun onMenuItemClick(item: MenuItem) = false
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/fragment/ColorPickerFragment.kt b/app/src/main/java/com/github/drunlin/webappbox/fragment/ColorPickerFragment.kt
new file mode 100644
index 0000000..0ef25d9
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/fragment/ColorPickerFragment.kt
@@ -0,0 +1,37 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.app.Dialog
+import android.os.Bundle
+import android.support.v7.app.AlertDialog
+import android.view.View
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.common.ARGUMENT_COLOR
+import com.thebluealliance.spectrum.SpectrumPalette
+
+class ColorPickerFragment() : CustomViewDialogFragment() {
+ override val layoutResId = R.layout.dialog_color_picker
+
+ constructor(color: Int) : this() {
+ arguments = Bundle().apply { putInt(ARGUMENT_COLOR, color) }
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ return AlertDialog.Builder(context)
+ .setTitle(R.string.status_bar_color)
+ .setNegativeButton(R.string.cancel, null)
+ .create()
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ val palette = view.findViewById(R.id.palette) as SpectrumPalette
+ palette.setSelectedColor(arguments.getInt(ARGUMENT_COLOR))
+ palette.setOnColorSelectedListener {
+ dismiss()
+ (parentFragment as OnColorSelectedListener?)?.onColorSelected(it)
+ }
+ }
+
+ interface OnColorSelectedListener {
+ fun onColorSelected(color: Int)
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/fragment/CustomViewDialogFragment.kt b/app/src/main/java/com/github/drunlin/webappbox/fragment/CustomViewDialogFragment.kt
new file mode 100644
index 0000000..ede67f2
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/fragment/CustomViewDialogFragment.kt
@@ -0,0 +1,23 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.os.Bundle
+import android.support.v4.app.DialogFragment
+import android.support.v7.app.AlertDialog
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+
+abstract class CustomViewDialogFragment : DialogFragment() {
+ abstract protected val layoutResId: Int
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?): View? {
+ return inflater.inflate(layoutResId, container, false)
+ }
+
+ override fun onActivityCreated(savedInstanceState: Bundle?) {
+ (dialog as? AlertDialog)?.setView(view)
+
+ super.onActivityCreated(savedInstanceState)
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/fragment/EditorDialogFragment.kt b/app/src/main/java/com/github/drunlin/webappbox/fragment/EditorDialogFragment.kt
new file mode 100644
index 0000000..5d8176b
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/fragment/EditorDialogFragment.kt
@@ -0,0 +1,44 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.app.Dialog
+import android.databinding.DataBindingUtil
+import android.databinding.ViewDataBinding
+import android.os.Bundle
+import android.support.v7.app.AlertDialog
+import android.view.View
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.common.ARGUMENT_ID
+
+abstract class EditorDialogFragment(id: Long?) : CustomViewDialogFragment() {
+ abstract protected val data: T?
+ abstract protected val titleResId: Int
+
+ protected val id by lazy { arguments?.getLong(ARGUMENT_ID) }
+
+ init {
+ id?.run { arguments = Bundle().apply { putLong(ARGUMENT_ID, id) } }
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ return AlertDialog.Builder(context)
+ .setTitle(titleResId)
+ .setNegativeButton(R.string.cancel, null)
+ .setPositiveButton(id?.let { R.string.ok } ?: R.string.add) { d, i -> onCommit() }
+ .create()
+ .apply { setOnShowListener { onDialogCreated(this, savedInstanceState) } }
+ }
+
+ protected abstract fun onCommit()
+
+ open protected fun onDialogCreated(dialog: AlertDialog, savedInstanceState: Bundle?) {
+ dialog.setOnShowListener(null)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ val binding = DataBindingUtil.bind(view)!!
+ savedInstanceState ?: data?.run { onBindData(binding, this) }
+ binding.executePendingBindings()
+ }
+
+ abstract protected fun onBindData(binding: ViewDataBinding, data: T)
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/fragment/EditorFragment.kt b/app/src/main/java/com/github/drunlin/webappbox/fragment/EditorFragment.kt
new file mode 100644
index 0000000..3f49505
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/fragment/EditorFragment.kt
@@ -0,0 +1,60 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.databinding.DataBindingUtil
+import android.databinding.ViewDataBinding
+import android.os.Bundle
+import android.view.MenuItem
+import android.view.View
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.common.ARGUMENT_ID
+import kotlinx.android.synthetic.main.fragment_preview.*
+
+abstract class EditorFragment(id: Long?) : SecondaryFragment() {
+ abstract protected val data: T?
+
+ override val menuResId = R.menu.fragment_editor
+
+ protected val id by lazy { arguments?.getLong(ARGUMENT_ID) }
+
+ lateinit protected var binding: ViewDataBinding
+
+ protected var confirmMenu: MenuItem? = null
+ private set
+
+ init {
+ id?.run { arguments = Bundle().apply { putLong(ARGUMENT_ID, id) } }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ confirmMenu = toolbar.menu.findItem(R.id.menu_confirm)
+ confirmMenu!!.isEnabled = id != null
+
+ view.findFocus() ?: view.focusSearch(View.FOCUS_FORWARD)?.requestFocus()
+
+ binding = DataBindingUtil.bind(contentView)
+ savedInstanceState ?: data?.run { onBindData(binding, this) }
+ binding.executePendingBindings()
+ }
+
+ abstract protected fun onBindData(binding: ViewDataBinding, data: T)
+
+ override fun onActivityCreated(savedInstanceState: Bundle?) {
+ super.onActivityCreated(savedInstanceState)
+
+ savedInstanceState ?: data?.run { onConfigureView(this) }
+ }
+
+ abstract protected fun onConfigureView(data: T)
+
+ override fun onMenuItemClick(item: MenuItem): Boolean {
+ if (item.itemId == R.id.menu_confirm) {
+ onCommit()
+ activity.onBackPressed()
+ }
+ return true
+ }
+
+ abstract protected fun onCommit()
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/fragment/IconChooserFragment.kt b/app/src/main/java/com/github/drunlin/webappbox/fragment/IconChooserFragment.kt
new file mode 100644
index 0000000..cfaf4f0
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/fragment/IconChooserFragment.kt
@@ -0,0 +1,25 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.app.Dialog
+import android.os.Bundle
+import android.support.v4.app.DialogFragment
+import android.support.v7.app.AlertDialog
+import com.github.drunlin.webappbox.R
+
+class IconChooserFragment : DialogFragment() {
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ return AlertDialog.Builder(activity)
+ .setTitle(R.string.change_icon)
+ .setItems(R.array.icon_chooser) { dialog, which -> onClick(which) }
+ .setNegativeButton(R.string.cancel, null)
+ .create()
+ }
+
+ private fun onClick(which: Int) {
+ (parentFragment as OnSelectedListener?)?.onSelected(which)
+ }
+
+ interface OnSelectedListener {
+ fun onSelected(which: Int)
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/fragment/IconLoaderFragment.kt b/app/src/main/java/com/github/drunlin/webappbox/fragment/IconLoaderFragment.kt
new file mode 100644
index 0000000..fa38df6
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/fragment/IconLoaderFragment.kt
@@ -0,0 +1,53 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.app.Dialog
+import android.graphics.Bitmap
+import android.os.Bundle
+import android.support.v4.app.DialogFragment
+import android.support.v7.app.AlertDialog
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.common.ARGUMENT_URL
+import com.github.drunlin.webappbox.common.app
+import com.github.drunlin.webappbox.common.asyncCall
+import com.github.drunlin.webappbox.model.IconLoader
+import javax.inject.Inject
+
+class IconLoaderFragment() : DialogFragment() {
+ @Inject lateinit var loader: IconLoader
+
+ init {
+ retainInstance = true
+ }
+
+ constructor(url: String) : this() {
+ arguments = Bundle().apply { putString(ARGUMENT_URL, url) }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ app.component.inject(this)
+
+ asyncCall({ loader.load(arguments.getString(ARGUMENT_URL)) }) {
+ dismiss()
+ if (!loader.canceled) (parentFragment as OnIconLoadedListener?)?.onIconLoaded(it)
+ }
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ return AlertDialog.Builder(context)
+ .setTitle(R.string.please_wait)
+ .setView(R.layout.dialog_progress)
+ .create()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+
+ loader.cancel()
+ }
+
+ interface OnIconLoadedListener {
+ fun onIconLoaded(icon: Bitmap?)
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/fragment/LicensesFragment.kt b/app/src/main/java/com/github/drunlin/webappbox/fragment/LicensesFragment.kt
new file mode 100644
index 0000000..2d8732d
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/fragment/LicensesFragment.kt
@@ -0,0 +1,18 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.os.Bundle
+import android.view.View
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.common.getRawText
+import kotlinx.android.synthetic.main.text_content.*
+
+class LicensesFragment : SecondaryFragment() {
+ override val titleResId = R.string.licenses
+ override val contentViewResId = R.layout.text_content
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ text.text = context.getRawText(R.raw.licenses)
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/fragment/ListFragment.kt b/app/src/main/java/com/github/drunlin/webappbox/fragment/ListFragment.kt
new file mode 100644
index 0000000..77f9886
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/fragment/ListFragment.kt
@@ -0,0 +1,135 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.databinding.DataBindingUtil
+import android.databinding.ViewDataBinding
+import android.os.Bundle
+import android.support.v7.widget.LinearLayoutManager
+import android.view.*
+import com.github.drunlin.webappbox.BR
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.common.asyncCall
+import com.github.drunlin.webappbox.data.Unique
+import com.github.drunlin.webappbox.model.DataManager
+import com.github.drunlin.webappbox.widget.adapter.ListAdapter
+import kotlinx.android.synthetic.main.editable_list_content.*
+import kotlinx.android.synthetic.main.list_content.*
+import java.util.*
+
+abstract class ListFragment> :
+ SecondaryFragment(), ActionMode.Callback {
+
+ abstract protected val manager: M
+
+ override val contentViewResId = R.layout.editable_list_content
+ abstract protected val itemResId: Int
+
+ protected val adapter by lazy { Adapter().apply { asyncCall({ manager.data }) { list = it } } }
+
+ protected var actionModel: ActionMode? = null
+ protected var selectedSet: HashSet? = null
+
+ override fun onActivityCreated(savedInstanceState: Bundle?) {
+ super.onActivityCreated(savedInstanceState)
+
+ registerListeners()
+ }
+
+ open protected fun registerListeners() {
+ manager.onInsert.add(this) { adapter.notifyItemInserted(it) }
+ manager.onRemove.add(this) { adapter.notifyDataSetChanged() }
+ manager.onUpdate.add(this) { adapter.notifyItemChanged(it) }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ recyclerView.layoutManager = LinearLayoutManager(context)
+ recyclerView.adapter = adapter
+
+ fab.setOnClickListener { onInsert() }
+ }
+
+ abstract fun onInsert()
+
+ open protected fun ViewHolder.onItemCreated() = Unit
+
+ abstract protected fun ViewHolder.onItemClick()
+
+ abstract protected fun ViewHolder.onBindItem(data: T)
+
+ override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
+ actionModel = mode
+ selectedSet = HashSet()
+
+ mode.menuInflater.inflate(R.menu.list_action_mode, menu)
+ return true
+ }
+
+ override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean = false
+
+ override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
+ onRemove()
+ mode.finish()
+ return true
+ }
+
+ open fun onRemove() {
+ manager.remove(selectedSet!!)
+ }
+
+ override fun onDestroyActionMode(mode: ActionMode?) {
+ actionModel = null
+ selectedSet = null
+
+ adapter.notifyDataSetChanged()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+
+ unregisterListeners()
+ }
+
+ open protected fun unregisterListeners() {
+ manager.onInsert.add(this) { adapter.notifyItemInserted(it) }
+ manager.onRemove.add(this) { adapter.notifyDataSetChanged() }
+ manager.onUpdate.add(this) { adapter.notifyItemChanged(it) }
+ }
+
+ protected inner class Adapter : ListAdapter() {
+ override fun onCreateViewHolder(parent: ViewGroup, type: Int) : ListFragment.ViewHolder {
+ return ViewHolder(DataBindingUtil
+ .inflate(activity.layoutInflater, itemResId, parent, false))
+ }
+ }
+
+ protected inner class ViewHolder(val binding: ViewDataBinding) :
+ ListAdapter.ViewHolder(binding.root) {
+
+ init {
+ onItemCreated()
+
+ itemView.setOnClickListener {
+ actionModel?.run { setSelected(!itemView.isSelected) } ?: onItemClick()
+ }
+ itemView.setOnLongClickListener {
+ actionModel?.run { finish() } ?: run {
+ activity.startActionMode(this@ListFragment)
+ setSelected(true)
+ }
+ return@setOnLongClickListener true
+ }
+ }
+
+ private fun setSelected(selected: Boolean) {
+ if (selected) selectedSet?.add(data!!.id) else selectedSet?.remove(data!!.id)
+ itemView.isSelected = selected
+ }
+
+ override fun onBind(data: T) {
+ onBindItem(data)
+ binding.setVariable(BR.selected, selectedSet?.contains(data.id) ?: false)
+ binding.executePendingBindings()
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/fragment/ManualFragment.kt b/app/src/main/java/com/github/drunlin/webappbox/fragment/ManualFragment.kt
new file mode 100644
index 0000000..76944ad
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/fragment/ManualFragment.kt
@@ -0,0 +1,19 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.os.Bundle
+import android.view.View
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.common.HtmlCompact
+import com.github.drunlin.webappbox.common.getRawText
+import kotlinx.android.synthetic.main.text_content.*
+
+class ManualFragment : SecondaryFragment() {
+ override val titleResId = R.string.manual
+ override val contentViewResId = R.layout.text_content
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ text.text = HtmlCompact.fromHtml(context.getRawText(R.raw.manual))
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/fragment/PatternEditorFragment.kt b/app/src/main/java/com/github/drunlin/webappbox/fragment/PatternEditorFragment.kt
new file mode 100644
index 0000000..d2e4a3c
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/fragment/PatternEditorFragment.kt
@@ -0,0 +1,46 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.databinding.ViewDataBinding
+import android.os.Bundle
+import android.support.v7.app.AlertDialog
+import com.github.drunlin.webappbox.BR
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.common.positiveButton
+import com.github.drunlin.webappbox.data.URLPattern
+import com.github.drunlin.webappbox.model.PatternManager
+import kotlinx.android.synthetic.main.fragment_pattern_editor.*
+import javax.inject.Inject
+
+class PatternEditorFragment(id: Long?) : EditorDialogFragment(id) {
+ @Inject lateinit var patternManager: PatternManager
+
+ override val data by lazy { id?.let { patternManager.getPattern(it) } }
+
+ override val titleResId by lazy { id?.let { R.string.edit_pattern } ?: R.string.add_pattern }
+ override val layoutResId = R.layout.fragment_pattern_editor
+
+ constructor() : this(null)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ (context as WebappContext).component.inject(this)
+ }
+
+ override fun onDialogCreated(dialog: AlertDialog, savedInstanceState: Bundle?) {
+ super.onDialogCreated(dialog, savedInstanceState)
+
+ editor.onStateChange = { dialog.positiveButton.isEnabled = it }
+ editor.isExisted = { v, b -> patternManager.isExited(v, b) }
+ editor.requestValidate()
+ }
+
+ override fun onCommit() {
+ id?.run { patternManager.update(this, editor.value, editor.regex) }
+ ?: patternManager.insert(editor.value, editor.regex)
+ }
+
+ override fun onBindData(binding: ViewDataBinding, data: URLPattern) {
+ binding.setVariable(BR.pattern, data)
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/fragment/PatternsFragment.kt b/app/src/main/java/com/github/drunlin/webappbox/fragment/PatternsFragment.kt
new file mode 100644
index 0000000..8991748
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/fragment/PatternsFragment.kt
@@ -0,0 +1,34 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.os.Bundle
+import com.github.drunlin.webappbox.BR
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.common.showDialog
+import com.github.drunlin.webappbox.data.URLPattern
+import com.github.drunlin.webappbox.model.PatternManager
+import javax.inject.Inject
+
+class PatternsFragment : ListFragment() {
+ @Inject override lateinit var manager: PatternManager
+
+ override val titleResId = R.string.url_patterns
+ override val itemResId = R.layout.item_pattern
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ (context as WebappContext).component.inject(this)
+ }
+
+ override fun onInsert() {
+ showDialog(PatternEditorFragment())
+ }
+
+ override fun ListFragment.ViewHolder.onItemClick() {
+ showDialog(PatternEditorFragment(data!!.id))
+ }
+
+ override fun ListFragment.ViewHolder.onBindItem(data: URLPattern) {
+ binding.setVariable(BR.pattern, data)
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/fragment/PreferencesFragment.kt b/app/src/main/java/com/github/drunlin/webappbox/fragment/PreferencesFragment.kt
new file mode 100644
index 0000000..89a2899
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/fragment/PreferencesFragment.kt
@@ -0,0 +1,84 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.os.Build
+import android.os.Bundle
+import android.support.design.widget.Snackbar
+import android.support.v7.preference.Preference
+import android.support.v7.preference.PreferenceFragmentCompat
+import android.view.View
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.activity.FragmentActivity
+import com.github.drunlin.webappbox.common.*
+import com.github.drunlin.webappbox.data.UserAgent
+import com.github.drunlin.webappbox.model.PreferenceModel
+import com.github.drunlin.webappbox.model.WebappManager
+import com.thebluealliance.spectrum.SpectrumPreferenceCompat
+import javax.inject.Inject
+
+class PreferencesFragment : PreferenceFragmentCompat(), UserAgentsFragment.OnChangeListener {
+ @Inject lateinit var preferenceModel: PreferenceModel
+ @Inject lateinit var webappManager: WebappManager
+
+ private val userAgent: UserAgent get() = preferenceModel.defaultRule.userAgent
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ app.component.inject(this)
+ }
+
+ override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
+ preferenceManager.sharedPreferencesName = PreferenceModel.PREFERENCE_NAME
+ addPreferencesFromResource(R.xml.settings)
+ }
+
+ override fun onViewCreated(view: View?, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT)
+ findPreference(PREF_COLOR).isVisible = false
+
+ updateUserAgentSummary()
+ }
+
+ private fun updateUserAgentSummary() {
+ findPreference(PREF_USER_AGENT).summary = userAgent.name
+ }
+
+ override fun onUserAgentChange(userAgent: UserAgent?) {
+ preferenceModel.setUserAgent(userAgent)
+
+ updateUserAgentSummary()
+ }
+
+ override fun onDisplayPreferenceDialog(preference: Preference) {
+ if (!SpectrumPreferenceCompat.onDisplayPreferenceDialog(preference, this))
+ super.onDisplayPreferenceDialog(preference)
+ }
+
+ override fun onPreferenceTreeClick(preference: Preference): Boolean {
+ when (preference.key) {
+ PREF_USER_AGENT -> startUserAgentsFragment()
+ PREF_CLEAR_DATA -> clearData()
+ PREF_CLEAR_CACHE -> clearCache()
+ else -> return super.onPreferenceTreeClick(preference)
+ }
+ return true
+ }
+
+ private fun startUserAgentsFragment() {
+ (activity as FragmentActivity).replaceContentFragment(UserAgentsFragment(userAgent.id, this))
+ }
+
+ private fun clearData() {
+ webappManager.clearData()
+
+ Snackbar.make(view!!, R.string.data_cleared, Snackbar.LENGTH_SHORT).show()
+ }
+
+ private fun clearCache() {
+ webappManager.clearCache()
+
+ Snackbar.make(view!!, R.string.cache_cleared, Snackbar.LENGTH_SHORT).show()
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/fragment/PreviewFragment.kt b/app/src/main/java/com/github/drunlin/webappbox/fragment/PreviewFragment.kt
new file mode 100644
index 0000000..0e95f22
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/fragment/PreviewFragment.kt
@@ -0,0 +1,90 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.app.ActivityManager
+import android.content.pm.ActivityInfo
+import android.os.Build
+import android.os.Bundle
+import android.view.MenuItem
+import android.view.View
+import android.view.inputmethod.EditorInfo
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.common.add
+import com.github.drunlin.webappbox.common.showDialog
+import com.github.drunlin.webappbox.common.string
+import com.github.drunlin.webappbox.model.WebappModel
+import kotlinx.android.synthetic.main.fragment_preview.*
+import javax.inject.Inject
+
+class PreviewFragment() : AppBarFragment() {
+ @Inject lateinit var webappModel: WebappModel
+
+ override val menuResId = R.menu.fragment_priview
+ override val viewResId = R.layout.fragment_preview
+
+ private val webapp by lazy { webappModel.webapp }
+
+ private val webappFragment by lazy {
+ childFragmentManager.findFragmentById(R.id.webapp) as WebappFragment?
+ ?: childFragmentManager.add(R.id.webapp, WebappFragment())
+ }
+
+ init {
+ retainInstance = true
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ (activity as WebappContext).component.inject(this)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ webappFragment.onUrlChange = { urlEdit.setText(it) }
+
+ urlEdit.setText(webapp.url)
+ urlEdit.setOnEditorActionListener { v, actionId, event ->
+ if (actionId == EditorInfo.IME_ACTION_GO) {
+ webappFragment.loadUrl(urlEdit.string)
+ return@setOnEditorActionListener true
+ }
+ return@setOnEditorActionListener false
+ }
+ }
+
+ override fun onMenuItemClick(item: MenuItem): Boolean {
+ when (item.itemId) {
+ R.id.menu_restart -> webappFragment.loadUrl(webapp.url)
+ R.id.menu_new_rule -> activity.replaceContentFragment(RuleEditorFragment())
+ R.id.menu_rules -> activity.replaceContentFragment(RulesFragment())
+ R.id.menu_new_pattern -> showDialog(PatternEditorFragment())
+ R.id.menu_patterns -> activity.replaceContentFragment(PatternsFragment())
+ R.id.menu_set_url -> webappModel.setUrl(urlEdit.string)
+ R.id.menu_exit -> activity.onBackPressed()
+ }
+ return true
+ }
+
+ override fun onHiddenChanged(hidden: Boolean) {
+ if (hidden) restoreSystemUi()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+
+ restoreSystemUi()
+ }
+
+ private fun restoreSystemUi() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
+ activity.setTaskDescription(ActivityManager.TaskDescription())
+
+ activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
+ activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ else
+ activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/fragment/RuleEditorFragment.kt b/app/src/main/java/com/github/drunlin/webappbox/fragment/RuleEditorFragment.kt
new file mode 100644
index 0000000..e1eb0fa
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/fragment/RuleEditorFragment.kt
@@ -0,0 +1,109 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.databinding.ViewDataBinding
+import android.os.Build
+import android.os.Bundle
+import android.view.View
+import com.github.drunlin.webappbox.BR
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.common.BUNDLE_UA
+import com.github.drunlin.webappbox.common.showDialog
+import com.github.drunlin.webappbox.data.LaunchMode
+import com.github.drunlin.webappbox.data.Orientation
+import com.github.drunlin.webappbox.data.Rule
+import com.github.drunlin.webappbox.data.UserAgent
+import com.github.drunlin.webappbox.databinding.FragmentRuleEditorBinding
+import com.github.drunlin.webappbox.model.RuleManager
+import com.github.drunlin.webappbox.model.UserAgentManager
+import kotlinx.android.synthetic.main.checkable_item.view.*
+import kotlinx.android.synthetic.main.fragment_rule_editor.*
+import javax.inject.Inject
+
+class RuleEditorFragment(id: Long?) : EditorFragment(id),
+ ColorPickerFragment.OnColorSelectedListener, UserAgentsFragment.OnChangeListener {
+
+ @Inject lateinit var ruleManager: RuleManager
+ @Inject lateinit var userAgentManager: UserAgentManager
+
+ override val data by lazy { id?.let { ruleManager.getRule(it) } }
+
+ override val titleResId = id?.let { R.string.edit_rule } ?: R.string.add_rule
+ override val contentViewResId = R.layout.fragment_rule_editor
+
+ private val userAgents by lazy { userAgentManager.userAgents }
+
+ private var userAgent: UserAgent? = null
+
+ constructor() : this(null)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ (activity as WebappContext).component.inject(this)
+
+ val ua = savedInstanceState?.run { userAgentManager.getUserAgent(getLong(BUNDLE_UA)) }
+ ?: data?.userAgent
+ userAgent = if (ua in userAgents) ua else null
+ }
+
+ override fun onSaveInstanceState(outState: Bundle?) {
+ super.onSaveInstanceState(outState)
+
+ userAgent?.run { outState?.putLong(BUNDLE_UA, id) }
+ }
+
+ override fun onBindData(binding: ViewDataBinding, data: Rule) {
+ binding.setVariable(BR.rule, data)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ editor.onStateChange = { confirmMenu?.isEnabled = it }
+ editor.isExisted = { v, b -> ruleManager.isExited(v, b) }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT)
+ colorItem.setOnClickListener { showDialog(ColorPickerFragment(colorView.color)) }
+ else
+ colorItem.visibility = View.GONE
+
+ updateUserAgentSummary()
+ userAgentItem.setOnClickListener {
+ activity.replaceContentFragment(UserAgentsFragment(userAgent?.id, this))
+ }
+ }
+
+ private fun updateUserAgentSummary() {
+ (binding as FragmentRuleEditorBinding).userAgentItem.summary = userAgent?.name
+ }
+
+ override fun onConfigureView(data: Rule) {
+ launchModeSpinner.value = data.launchMode.name
+
+ orientationSpinner.value = data.orientation.name
+ }
+
+ override fun onColorSelected(color: Int) {
+ colorView.color = color
+ }
+
+ override fun onUserAgentChange(userAgent: UserAgent?) {
+ this.userAgent = userAgent
+
+ updateUserAgentSummary()
+ }
+
+ override fun onCommit() {
+ val pattern = editor.value
+ val regex = editor.regex
+ val color = colorView.color
+ val lm = LaunchMode.valueOf(launchModeSpinner.value)
+ val so = Orientation.valueOf(orientationSpinner.value)
+ val ua = userAgent ?: data?.userAgent ?: userAgents.getOrNull(0)
+ val fs = fullScreenItem.switcher.isChecked
+ val js = enableJavascriptItem.switcher.isChecked
+
+ id?.run { ruleManager.update(this, pattern, regex, color, lm, so, fs, ua, js) }
+ ?: ruleManager.insert(pattern, regex, color, lm, so, fs, ua, js)
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/fragment/RulesFragment.kt b/app/src/main/java/com/github/drunlin/webappbox/fragment/RulesFragment.kt
new file mode 100644
index 0000000..adbebce
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/fragment/RulesFragment.kt
@@ -0,0 +1,86 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.os.Bundle
+import android.support.v7.widget.RecyclerView
+import android.support.v7.widget.helper.ItemTouchHelper
+import android.view.MotionEvent
+import android.view.View
+import com.github.drunlin.webappbox.BR
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.data.Rule
+import com.github.drunlin.webappbox.model.RuleManager
+import kotlinx.android.synthetic.main.item_rule.view.*
+import kotlinx.android.synthetic.main.list_content.*
+import javax.inject.Inject
+
+class RulesFragment : ListFragment() {
+ @Inject override lateinit var manager: RuleManager
+
+ override val titleResId = R.string.rules
+ override val itemResId = R.layout.item_rule
+
+ private val itemDragHelper by lazy { ItemTouchHelper(ItemTouchHelperCallback()) }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ (context as WebappContext).component.inject(this)
+ }
+
+ override fun registerListeners() {
+ super.registerListeners()
+
+ manager.onMove.add(this) { from, to -> adapter.notifyItemMoved(from, to) }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ itemDragHelper.attachToRecyclerView(recyclerView)
+ }
+
+ override fun onInsert() {
+ activity.replaceContentFragment(RuleEditorFragment())
+ }
+
+ override fun ListFragment.ViewHolder.onItemCreated() {
+ itemView.dragButton.setOnTouchListener { view, event ->
+ if (event.action == MotionEvent.ACTION_DOWN) itemDragHelper.startDrag(this)
+ return@setOnTouchListener true
+ }
+ }
+
+ override fun ListFragment.ViewHolder.onItemClick() {
+ activity.replaceContentFragment(RuleEditorFragment(data!!.id))
+ }
+
+ override fun ListFragment.ViewHolder.onBindItem(data: Rule) {
+ binding.setVariable(BR.rule, data)
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+
+ itemDragHelper.attachToRecyclerView(null)
+ }
+
+ override fun unregisterListeners() {
+ super.unregisterListeners()
+
+ manager.onMove.remove(this)
+ }
+
+ private inner class ItemTouchHelperCallback :
+ ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) {
+
+ override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder,
+ target: RecyclerView.ViewHolder): Boolean {
+ manager.swap(viewHolder.adapterPosition, target.adapterPosition)
+ return true
+ }
+
+ override fun isLongPressDragEnabled() = false
+
+ override fun onSwiped(viewHolder: RecyclerView.ViewHolder?, direction: Int) = Unit
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/fragment/SecondaryFragment.kt b/app/src/main/java/com/github/drunlin/webappbox/fragment/SecondaryFragment.kt
new file mode 100644
index 0000000..160d6e5
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/fragment/SecondaryFragment.kt
@@ -0,0 +1,16 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.os.Bundle
+import android.view.View
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.common.getResourceId
+import kotlinx.android.synthetic.main.toolbar.*
+
+abstract class SecondaryFragment : AppBarFragment() {
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ toolbar.setNavigationIcon(context.getResourceId(R.attr.homeAsUpIndicator))
+ toolbar.setNavigationOnClickListener { activity.onBackPressed() }
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/fragment/SettingsFragment.kt b/app/src/main/java/com/github/drunlin/webappbox/fragment/SettingsFragment.kt
new file mode 100644
index 0000000..12b6576
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/fragment/SettingsFragment.kt
@@ -0,0 +1,16 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.os.Bundle
+import android.view.View
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.common.add
+
+class SettingsFragment : SecondaryFragment() {
+ override val titleResId = R.string.settings
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ savedInstanceState ?: childFragmentManager.add(R.id.container, PreferencesFragment())
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/fragment/UserAgentEditorFragment.kt b/app/src/main/java/com/github/drunlin/webappbox/fragment/UserAgentEditorFragment.kt
new file mode 100644
index 0000000..6596a9f
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/fragment/UserAgentEditorFragment.kt
@@ -0,0 +1,82 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.databinding.ViewDataBinding
+import android.os.Bundle
+import android.support.v7.app.AlertDialog
+import com.github.drunlin.webappbox.BR
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.common.app
+import com.github.drunlin.webappbox.common.positiveButton
+import com.github.drunlin.webappbox.common.string
+import com.github.drunlin.webappbox.data.UserAgent
+import com.github.drunlin.webappbox.model.UserAgentManager
+import com.jakewharton.rxbinding.widget.RxTextView
+import kotlinx.android.synthetic.main.fragment_user_agnet_editor.*
+import kotlinx.android.synthetic.main.text_input.view.*
+import rx.Observable
+import javax.inject.Inject
+
+class UserAgentEditorFragment(id: Long?) : EditorDialogFragment(id) {
+ @Inject lateinit var userAgentManager: UserAgentManager
+
+ override val data by lazy { id?.let { userAgentManager.getUserAgent(it) } }
+
+ override val titleResId = id?.let { R.string.edit_user_agent } ?: R.string.add_user_agent
+ override val layoutResId = R.layout.fragment_user_agnet_editor
+
+ constructor() : this(null)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ app.component.inject(this)
+ }
+
+ override fun onDialogCreated(dialog: AlertDialog, savedInstanceState: Bundle?) {
+ super.onDialogCreated(dialog, savedInstanceState)
+
+ val nameObserver = RxTextView.textChanges(nameInput.edit)
+ .map { it.trim().toString() }
+ .map { onNameChange(it) }
+
+ val uaObserver = RxTextView.textChanges(uaInput.edit)
+ .map { it.trim().toString() }
+ .map { onUserAgentChange(it) }
+
+ Observable.combineLatest(nameObserver, uaObserver, { a, b -> a && b })
+ .subscribe { dialog.positiveButton.isEnabled = it }
+ }
+
+ private fun onUserAgentChange(userAgent: String): Boolean {
+ if (userAgent.isEmpty()) {
+ uaInput.layout.isErrorEnabled = false
+ } else if (userAgent != data?.value && userAgentManager.isValueExited(userAgent)) {
+ uaInput.layout.error = getString(R.string.exited_ua)
+ } else {
+ uaInput.layout.isErrorEnabled = false
+ return true
+ }
+ return false
+ }
+
+ private fun onNameChange(name: String): Boolean {
+ if (name.isEmpty()) {
+ nameInput.layout.isErrorEnabled = false
+ } else if (name != data?.name && userAgentManager.isNameExited(name)) {
+ nameInput.layout.error = getString(R.string.exited_name)
+ } else {
+ nameInput.layout.isErrorEnabled = false
+ return true
+ }
+ return false
+ }
+
+ override fun onCommit() {
+ id?.run { userAgentManager.update(this, nameInput.edit.string, uaInput.edit.string) }
+ ?: userAgentManager.insert(nameInput.edit.string, uaInput.edit.string)
+ }
+
+ override fun onBindData(binding: ViewDataBinding, data: UserAgent) {
+ binding.setVariable(BR.userAgent, data)
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/fragment/UserAgentsFragment.kt b/app/src/main/java/com/github/drunlin/webappbox/fragment/UserAgentsFragment.kt
new file mode 100644
index 0000000..833612b
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/fragment/UserAgentsFragment.kt
@@ -0,0 +1,72 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.os.Bundle
+import android.support.v4.app.Fragment
+import com.github.drunlin.webappbox.BR
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.common.ARGUMENT_ID
+import com.github.drunlin.webappbox.common.app
+import com.github.drunlin.webappbox.common.friendFragment
+import com.github.drunlin.webappbox.common.showDialog
+import com.github.drunlin.webappbox.data.UserAgent
+import com.github.drunlin.webappbox.model.UserAgentManager
+import kotlinx.android.synthetic.main.item_user_agent.view.*
+import javax.inject.Inject
+
+class UserAgentsFragment() : ListFragment() {
+ @Inject override lateinit var manager: UserAgentManager
+
+ override val titleResId = R.string.user_agents
+ override val itemResId = R.layout.item_user_agent
+
+ private var currentId: Long
+ set(value) { arguments.putLong(ARGUMENT_ID, value) }
+ get() = arguments.getLong(ARGUMENT_ID)
+
+ private val listener: OnChangeListener get() = friendFragment as OnChangeListener
+
+ constructor(id: Long?, listener: Fragment) : this() {
+ arguments = Bundle()
+ currentId = id ?: -1
+ friendFragment = listener
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ app.component.inject(this)
+ }
+
+ override fun ListFragment.ViewHolder.onItemCreated() {
+ itemView.button.setOnClickListener {
+ if (data?.id != currentId) {
+ currentId = data!!.id
+ listener.onUserAgentChange(data)
+ adapter.notifyDataSetChanged()
+ }
+ }
+ }
+
+ override fun ListFragment.ViewHolder.onItemClick() {
+ showDialog(UserAgentEditorFragment(data!!.id))
+ }
+
+ override fun ListFragment.ViewHolder.onBindItem(data: UserAgent) {
+ binding.setVariable(BR.userAgent, data)
+ binding.setVariable(BR.checked, data.id == currentId)
+ }
+
+ override fun onInsert() {
+ showDialog(UserAgentEditorFragment())
+ }
+
+ override fun onRemove() {
+ super.onRemove()
+
+ if (selectedSet!!.contains(currentId)) listener.onUserAgentChange(null)
+ }
+
+ interface OnChangeListener {
+ fun onUserAgentChange(userAgent: UserAgent?)
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/fragment/WebappContext.kt b/app/src/main/java/com/github/drunlin/webappbox/fragment/WebappContext.kt
new file mode 100644
index 0000000..8e74243
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/fragment/WebappContext.kt
@@ -0,0 +1,7 @@
+package com.github.drunlin.webappbox.fragment
+
+import com.github.drunlin.webappbox.module.WebappComponent
+
+interface WebappContext {
+ val component: WebappComponent
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/fragment/WebappEditorFragment.kt b/app/src/main/java/com/github/drunlin/webappbox/fragment/WebappEditorFragment.kt
new file mode 100644
index 0000000..e593f18
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/fragment/WebappEditorFragment.kt
@@ -0,0 +1,202 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.Manifest
+import android.app.Activity
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.databinding.ViewDataBinding
+import android.graphics.Bitmap
+import android.net.Uri
+import android.os.Bundle
+import android.provider.MediaStore
+import android.provider.MediaStore.Images.Media
+import android.support.design.widget.Snackbar
+import android.support.v4.content.ContextCompat
+import android.view.View
+import android.widget.Toast
+import com.github.drunlin.webappbox.BR
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.common.*
+import com.github.drunlin.webappbox.data.Policy
+import com.github.drunlin.webappbox.data.Webapp
+import com.github.drunlin.webappbox.model.PatternManager
+import com.github.drunlin.webappbox.model.RuleManager
+import com.github.drunlin.webappbox.model.WebappManager
+import com.github.drunlin.webappbox.model.WebappModel
+import com.jakewharton.rxbinding.widget.RxTextView
+import kotlinx.android.synthetic.main.fragment_webapp_editor.*
+import kotlinx.android.synthetic.main.text_input.view.*
+import rx.android.schedulers.AndroidSchedulers
+import java.io.File
+import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+
+class WebappEditorFragment(id: Long?) : EditorFragment(id),
+ IconLoaderFragment.OnIconLoadedListener, IconChooserFragment.OnSelectedListener {
+
+ @Inject lateinit var webappManager: WebappManager
+ @Inject lateinit var webappModel: WebappModel
+ @Inject lateinit var patternManager: PatternManager
+ @Inject lateinit var ruleManager: RuleManager
+
+ override val titleResId = id?.let { R.string.edit_webapp } ?: R.string.new_webapp
+ override val contentViewResId = R.layout.fragment_webapp_editor
+
+ override val data by lazy { id?.let { webappModel.webapp } }
+
+ private var pendingImageUri: Uri? = null
+
+ constructor() : this(null)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ (activity as WebappContext).component.inject(this)
+
+ pendingImageUri = savedInstanceState?.getParcelable(BUNDLE_IMAGE)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ webappModel.onUrlChange.add(this) { urlInput.edit.setText(it) }
+
+ RxTextView.textChanges(urlInput.edit)
+ .skip(1)
+ .doOnNext { confirmMenu?.isEnabled = false }
+ .map { it.trim().toString() }
+ .debounce(500, TimeUnit.MILLISECONDS)
+ .observeOn(AndroidSchedulers.mainThread())
+ .map { onUrlChange(it) }
+ .subscribe { confirmMenu?.isEnabled = it }
+
+ savedInstanceState?.run { iconImage.setImageBitmap(getParcelable(BUNDLE_ICON)) }
+ iconImage.setOnClickListener { showDialog(IconChooserFragment()) }
+
+ patternsItem.setOnClickListener { activity.replaceContentFragment(PatternsFragment()) }
+
+ rulesItem.setOnClickListener { activity.replaceContentFragment(RulesFragment()) }
+
+ previewItem.setOnClickListener {
+ synchronize()
+ activity.replaceContentFragment(PreviewFragment())
+ }
+ }
+
+ override fun onBindData(binding: ViewDataBinding, data: Webapp) {
+ binding.setVariable(BR.webapp, data)
+ }
+
+ override fun onConfigureView(data: Webapp) {
+ locationSpinner.value = data.locationPolicy.name
+ }
+
+ private fun synchronize() {
+ webappModel.update(urlInput.edit.string, iconImage.bitmap, nameInput.edit.string,
+ Policy.valueOf(locationSpinner.value))
+ }
+
+ private fun onUrlChange(url: String): Boolean {
+ if (url.isEmpty()) {
+ urlInput.layout.isErrorEnabled = false
+ } else if (!url.isValidUrl()) {
+ urlInput.layout.error = getString(R.string.invalid_url)
+ } else if (url != webappModel.originalUrl && webappManager.isExisted(url)) {
+ urlInput.layout.error = getString(R.string.exited_url)
+ } else {
+ urlInput.layout.isErrorEnabled = false
+ return true
+ }
+ return false
+ }
+
+ override fun onSelected(which: Int) {
+ when (which) {
+ 0 -> iconImage.setImageResource(R.mipmap.ic_webapp)
+ 1 -> loadIcon()
+ 2 -> pickPicture()
+ }
+ }
+
+ private fun loadIcon() {
+ if (urlInput.edit.string.isValidUrl())
+ showDialog(IconLoaderFragment(urlInput.edit.string))
+ else
+ Snackbar.make(view!!, R.string.invalid_url, Toast.LENGTH_SHORT).show()
+ }
+
+ override fun onIconLoaded(icon: Bitmap?) {
+ icon?.run { iconImage.setImageBitmap(this) }
+ ?: Snackbar.make(view!!, R.string.download_failed, Snackbar.LENGTH_SHORT).show()
+ }
+
+ private fun pickPicture() {
+ val uri = Uri.fromFile(File(context.externalCacheDir, "icon"))
+ val intent = Intent(Intent.ACTION_PICK, Media.EXTERNAL_CONTENT_URI)
+ .setType("image/*")
+ .putExtra("crop", "true")
+ .putExtra("outputX", context.iconSize)
+ .putExtra("outputY", context.iconSize)
+ .putExtra("aspectX", 1)
+ .putExtra("aspectY", 1)
+ .putExtra("scale", true)
+ .putExtra(MediaStore.EXTRA_OUTPUT, uri)
+ val chooser = Intent.createChooser(intent, getText(R.string.pick_image))
+ if (chooser.resolveActivity(context.packageManager) != null)
+ startActivityForResult(chooser, REQUEST_PICK_PICTURE)
+ else
+ Snackbar.make(view!!, R.string.gallery_not_found, Snackbar.LENGTH_SHORT).show()
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ if (resultCode == Activity.RESULT_OK && requestCode == REQUEST_PICK_PICTURE) {
+ try {
+ iconImage.setImageBitmap(Media.getBitmap(activity.contentResolver, data!!.data))
+ } catch (e: SecurityException) {
+ pendingImageUri = data!!.data
+ if (ContextCompat.checkSelfPermission(context,
+ Manifest.permission.READ_EXTERNAL_STORAGE)
+ != PackageManager.PERMISSION_GRANTED) {
+ val permissions = arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
+ requestPermissions(permissions, PERMISSIONS_REQUEST_STORAGE)
+ }
+ } catch (e: Exception) {
+ //do nothing
+ }
+ }
+ }
+
+ override fun onRequestPermissionsResult(requestCode: Int, permissions: Array,
+ grantResults: IntArray) {
+ if (requestCode == PERMISSIONS_REQUEST_STORAGE
+ && grantResults.getOrNull(0) == PackageManager.PERMISSION_GRANTED) {
+ try {
+ iconImage.setImageBitmap(Media.getBitmap(activity.contentResolver, pendingImageUri))
+ } catch (e: Exception) {
+ //do nothing
+ }
+ pendingImageUri = null
+ }
+ }
+
+ override fun onSaveInstanceState(outState: Bundle?) {
+ if (outState == null) return
+
+ iconImage?.run { outState.putParcelable(BUNDLE_ICON, bitmap) }
+ pendingImageUri?.run { outState.putParcelable(BUNDLE_IMAGE, this) }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+
+ webappModel.onUrlChange.remove(this)
+ }
+
+ override fun onCommit() {
+ synchronize()
+
+ val comp = (activity as WebappContext).component
+ val addShortcut = shortcutItem.isChecked
+ id?.run { webappManager.update(comp, addShortcut) } ?: webappManager.insert(comp, addShortcut)
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/fragment/WebappFragment.kt b/app/src/main/java/com/github/drunlin/webappbox/fragment/WebappFragment.kt
new file mode 100644
index 0000000..30e2687
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/fragment/WebappFragment.kt
@@ -0,0 +1,174 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.content.Context
+import android.os.Bundle
+import android.support.v4.app.Fragment
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.inputmethod.InputMethodManager
+import android.widget.FrameLayout
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.activity.FragmentActivity
+import com.github.drunlin.webappbox.common.BUNDLE_COUNT
+import com.github.drunlin.webappbox.common.findNullableIndexedValue
+import com.github.drunlin.webappbox.common.getSystemService
+import com.github.drunlin.webappbox.data.LaunchMode
+import com.github.drunlin.webappbox.model.RuleManager
+import com.github.drunlin.webappbox.model.WebappModel
+import java.util.*
+import javax.inject.Inject
+
+class WebappFragment : Fragment() {
+ @Inject lateinit var ruleManager: RuleManager
+ @Inject lateinit var webappModel: WebappModel
+
+ var onUrlChange: ((String) -> Unit)? = null
+
+ private val webapp by lazy { webappModel.webapp }
+
+ private val fragments = LinkedList()
+ private val topFragment: WebappWindowFragment get() = fragments.last
+
+ private val activity: FragmentActivity get() = getActivity() as FragmentActivity
+
+ private var pendingUrl: String? = null
+
+ init {
+ retainInstance = true
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ (context as WebappContext).component.inject(this)
+
+ repeat(savedInstanceState?.getInt(BUNDLE_COUNT) ?: 0) {
+ fragments.add(childFragmentManager
+ .getFragment(savedInstanceState, key(it)) as WebappWindowFragment)
+ }
+ }
+
+ private fun key(index: Int) = "${WebappWindowFragment::class.java.simpleName}$index"
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+
+ for ((i, fragment) in fragments.withIndex()) {
+ childFragmentManager.putFragment(outState, key(i), fragment)
+ }
+ outState.putInt(BUNDLE_COUNT, fragments.size)
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?): FrameLayout {
+ return FrameLayout(context).apply { id = R.id.content }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ if (pendingUrl != null) {
+ loadUrl(if (pendingUrl!!.isEmpty()) webapp.url else pendingUrl!!)
+ pendingUrl = null
+ } else if (savedInstanceState == null) {
+ loadUrl(webapp.url)
+ }
+ }
+
+ fun loadUrl(url: String) {
+ if (!isAdded) {
+ pendingUrl = url
+ return
+ }
+ fragments.clear()
+ onLoadUrl(url)
+ }
+
+ fun onLoadUrl(url: String): Boolean {
+ val rule = ruleManager.getRule(url)
+ val (index, fragment) = fragments.findNullableIndexedValue { it.rule == rule }
+
+ if (fragment == null || rule.launchMode == LaunchMode.NEW_WINDOW) {
+ push(WebappWindowFragment(url))
+ } else if (fragment == topFragment) {
+ fragment.loadUrl(url)
+ return false
+ } else if(rule.launchMode == LaunchMode.STANDARD || rule.launchMode == LaunchMode.SINGLE_TOP) {
+ push(WebappWindowFragment(url))
+ } else {
+ @Suppress("NON_EXHAUSTIVE_WHEN")
+ when (rule.launchMode) {
+ LaunchMode.CLEAR_TOP -> clearTop(index)
+ LaunchMode.SINGLE_TASK -> moveToTop(index)
+ }
+ fragment.loadUrl(url)
+ }
+ return true
+ }
+
+ private fun hideSoftKeyboard() {
+ if (isAdded && activity.supportFragmentManager == fragmentManager) {
+ val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
+ imm.hideSoftInputFromWindow(view!!.windowToken, 0)
+ }
+ }
+
+ private fun push(fragment: WebappWindowFragment) {
+ hideSoftKeyboard()
+
+ childFragmentManager.beginTransaction().apply {
+ if (fragments.isNotEmpty()) {
+ setCustomAnimations(0, activity.openExitAnimation)
+ hide(topFragment)
+ setCustomAnimations(activity.openEnterAnimation, 0)
+ }
+ add(R.id.content, fragment)
+ }.commit()
+
+ fragments.add(fragment)
+ }
+
+ private fun clearTop(index: Int) {
+ if (index == fragments.lastIndex) return
+
+ hideSoftKeyboard()
+
+ childFragmentManager.beginTransaction()
+ .setCustomAnimations(0, activity.openExitAnimation)
+ .remove(fragments.removeLast())
+ .setCustomAnimations(0, 0)
+ .apply { repeat(fragments.lastIndex - index) { remove(fragments.removeLast()) } }
+ .setCustomAnimations(activity.openEnterAnimation, 0)
+ .show(topFragment)
+ .commit()
+ }
+
+ private fun pop() {
+ hideSoftKeyboard()
+
+ childFragmentManager.beginTransaction()
+ .setCustomAnimations(0, activity.closeExitAnimation)
+ .remove(fragments.removeLast())
+ .setCustomAnimations(activity.closeEnterAnimation, 0)
+ .show(topFragment)
+ .commit()
+
+ onUrlChange?.invoke(topFragment.currentUrl ?: "")
+ }
+
+ private fun moveToTop(index: Int) {
+ if (index == fragments.lastIndex) return
+
+ hideSoftKeyboard()
+
+ childFragmentManager.beginTransaction()
+ .setCustomAnimations(0, activity.openExitAnimation)
+ .hide(topFragment)
+ .setCustomAnimations(activity.openEnterAnimation, 0)
+ .show(fragments[index])
+ .commit()
+
+ Collections.swap(fragments, index, fragments.lastIndex)
+ }
+
+ fun goBack() = if (fragments.size == 1) activity.onBackPressed() else pop()
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/fragment/WebappWindowFragment.kt b/app/src/main/java/com/github/drunlin/webappbox/fragment/WebappWindowFragment.kt
new file mode 100644
index 0000000..519f6d6
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/fragment/WebappWindowFragment.kt
@@ -0,0 +1,475 @@
+package com.github.drunlin.webappbox.fragment
+
+import android.Manifest
+import android.annotation.TargetApi
+import android.app.ActivityManager
+import android.app.DownloadManager
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ActivityInfo
+import android.content.pm.PackageManager
+import android.graphics.Bitmap
+import android.graphics.Color
+import android.location.LocationManager
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.os.Environment
+import android.provider.Settings
+import android.support.design.widget.BottomSheetBehavior
+import android.support.design.widget.Snackbar
+import android.support.v4.app.Fragment
+import android.support.v4.content.ContextCompat
+import android.util.TypedValue
+import android.view.KeyEvent
+import android.view.LayoutInflater
+import android.view.View
+import android.view.View.OnKeyListener
+import android.view.ViewGroup
+import android.view.ViewTreeObserver.OnGlobalLayoutListener
+import android.webkit.*
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.activity.FragmentActivity
+import com.github.drunlin.webappbox.common.*
+import com.github.drunlin.webappbox.data.LaunchMode
+import com.github.drunlin.webappbox.data.Orientation
+import com.github.drunlin.webappbox.data.Policy
+import com.github.drunlin.webappbox.data.Rule
+import com.github.drunlin.webappbox.model.PatternManager
+import com.github.drunlin.webappbox.model.PreferenceModel
+import com.github.drunlin.webappbox.model.RuleManager
+import com.github.drunlin.webappbox.model.WebappModel
+import kotlinx.android.synthetic.main.fragment_webapp_window.*
+import kotlinx.android.synthetic.main.fragment_webapp_window.view.*
+import kotlinx.android.synthetic.main.status_bar.*
+import java.io.File
+import javax.inject.Inject
+
+class WebappWindowFragment() : Fragment(), DownloadListener, OnKeyListener, OnGlobalLayoutListener {
+ @Inject lateinit var webappModel: WebappModel
+ @Inject lateinit var preferenceModel: PreferenceModel
+ @Inject lateinit var ruleManager: RuleManager
+ @Inject lateinit var patternManager: PatternManager
+
+ var rule: Rule? = null
+ private set
+ var currentUrl: String? = null
+ private set
+
+ private val activity: FragmentActivity get() = getActivity() as FragmentActivity
+ private val manager: WebappFragment get() = parentFragment as WebappFragment
+
+ private var pendingUrl: String? = null
+
+ private var uploadFileCallback: ValueCallback? = null
+ private var filePathCallback: ValueCallback>? = null
+
+ private var pendingDownloadInfo: DownloadInfo? = null
+
+ private var pendingGeolocationPermissionsPrompt: GeolocationPermissionsPrompt? = null
+
+ private var contentView: View? = null
+ private var bottomSheetBehavior: BottomSheetBehavior? = null
+
+ private var softKeyboardVisible = false
+
+ init {
+ retainInstance = true
+ }
+
+ constructor(url: String) : this() {
+ pendingUrl = url
+ }
+
+ fun loadUrl(url: String) {
+ webview?.loadUrl(url) ?: run { pendingUrl = url }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ (context as WebappContext).component.inject(this)
+
+ ruleManager.onInsert.add(this) { onRulesChange() }
+ ruleManager.onRemove.add(this) { onRulesChange() }
+ ruleManager.onUpdate.add(this) { onRulesChange() }
+ ruleManager.onMove.add(this) { f, t -> onRulesChange() }
+ preferenceModel.onChange.add(this) { onRulesChange() }
+ }
+
+ private fun onRulesChange() {
+ if (isResumed && !isHidden) updateSystemUi()
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?): View? {
+ if (contentView == null)
+ contentView = inflater.inflate(R.layout.fragment_webapp_window, container, false)
+ else
+ (contentView?.parent as ViewGroup?)?.removeView(contentView)
+ return contentView
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ statusBarBackground?.setBackgroundColor(Color.WHITE)
+
+ refreshLayout.setOnRefreshListener { webview.reload() }
+
+ webview.settings.setAppCachePath(File(context.cacheDir, "webapp").absolutePath)
+ webview.settings.setAppCacheEnabled(true)
+ webview.settings.databaseEnabled = true
+ webview.settings.domStorageEnabled = true
+ webview.settings.loadWithOverviewMode = true
+ webview.settings.useWideViewPort = true
+
+ webview.setWebViewClient(AppWebViewClient())
+ webview.setWebChromeClient(AppWebChromeClient())
+ webview.setDownloadListener(this)
+ webview.setOnKeyListener(this)
+
+ if (pendingUrl != null) {
+ webview.loadUrl(pendingUrl)
+ pendingUrl = null
+ } else if (webview.url.isNullOrEmpty() && savedInstanceState != null) {
+ webview.restoreState(savedInstanceState)
+ }
+
+ bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet)
+ if (savedInstanceState == null)
+ bottomSheetBehavior!!.state = BottomSheetBehavior.STATE_HIDDEN
+ closeButton.setOnClickListener {
+ bottomSheetBehavior!!.state = BottomSheetBehavior.STATE_HIDDEN
+ }
+ }
+
+ override fun onKey(v: View, keyCode: Int, event: KeyEvent): Boolean {
+ if (keyCode == KeyEvent.KEYCODE_BACK) {
+ if (event.action == KeyEvent.ACTION_UP) {
+ if (rule?.launchMode == LaunchMode.STANDARD && webview.canGoBack())
+ webview.goBack()
+ else
+ manager.goBack()
+ }
+ return true
+ }
+ return false
+ }
+
+ override fun onDownloadStart(url: String?, userAgent: String?, contentDisposition: String?,
+ mimetype: String?, contentLength: Long) {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ != PackageManager.PERMISSION_GRANTED) {
+ pendingDownloadInfo = DownloadInfo(url, contentDisposition, mimetype)
+ requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
+ PERMISSIONS_REQUEST_STORAGE)
+ } else {
+ downloadFile(url, contentDisposition, mimetype)
+ }
+ }
+
+ private fun downloadFile(url: String?, contentDisposition: String?, mimetype: String?) {
+ val request = DownloadManager.Request(Uri.parse(url))
+ request.allowScanningByMediaScanner()
+ request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
+ request.setTitle(contentDisposition)
+ val name = URLUtil.guessFileName(url, contentDisposition, mimetype)
+ request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, name)
+ val dm = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
+ dm.enqueue(request)
+ }
+
+ override fun onResume() {
+ super.onResume()
+
+ resume()
+ }
+
+ private fun resume() {
+ activity.onWindowFocusChanged.add(this) { if (it) updateSystemUi() }
+ activity.contentView.viewTreeObserver.addOnGlobalLayoutListener(this)
+
+ updateSystemUi()
+
+ webview.requestFocus()
+ webview.onResume()
+ }
+
+ override fun onPause() {
+ super.onPause()
+
+ pause()
+ }
+
+ private fun pause() {
+ activity.onWindowFocusChanged.remove(this)
+ activity.contentView.viewTreeObserver.removeOnGlobalLayoutListener(this)
+
+ webview.onPause()
+ }
+
+ override fun onHiddenChanged(hidden: Boolean) {
+ super.onHiddenChanged(hidden)
+
+ if (hidden) pause() else resume()
+ }
+
+ override fun onGlobalLayout() {
+ if (activity.contentView.paddingBottom
+ > context.getDimension(TypedValue.COMPLEX_UNIT_DIP, 72f)) {
+ softKeyboardVisible = true
+ } else if (softKeyboardVisible) {
+ softKeyboardVisible = false
+
+ updateSystemUi()
+ }
+ }
+
+ private fun updateSystemUi() {
+ currentUrl?.run { updateSystemUi(this) }
+ }
+
+ private fun updateSystemUi(url: String) {
+ rule = ruleManager.getRule(url)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
+ statusBarBackground.setBackgroundColor(rule!!.color)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ val webapp = webappModel.webapp
+ activity.setTaskDescription(
+ ActivityManager.TaskDescription(webapp.name, webapp.icon, rule!!.color))
+ }
+ }
+
+ activity.requestedOrientation = when (rule!!.orientation) {
+ Orientation.NORMAL -> ActivityInfo.SCREEN_ORIENTATION_USER
+ Orientation.PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+ Orientation.LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
+ }
+
+ activity.window.decorView.systemUiVisibility = if (rule!!.fullScreen) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT)
+ (View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+ or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
+ or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
+ or View.SYSTEM_UI_FLAG_FULLSCREEN
+ or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY)
+ else
+ (View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+ or View.SYSTEM_UI_FLAG_FULLSCREEN
+ or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ or View.SYSTEM_UI_FLAG_LOW_PROFILE)
+ } else {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
+ View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ else
+ View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+ }
+ }
+
+ private fun grantGeolocationPermissions() {
+ pendingGeolocationPermissionsPrompt?.run { callback.invoke(origin, true, false) }
+ pendingGeolocationPermissionsPrompt = null
+ }
+
+ private fun requestGeolocationPermissions() {
+ if (grantOrRequestSystemGeolocationPermissions()) return
+
+ Snackbar.make(refreshLayout, R.string.turn_on_location, Snackbar.LENGTH_LONG)
+ .setAction(R.string.turn_on) {
+ val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
+ startActivityForResult(intent, REQUEST_LOCATION_SETTINGS)
+ }.setCallback(object : Snackbar.Callback() {
+ override fun onDismissed(snackbar: Snackbar, event: Int) {
+ if (event != DISMISS_EVENT_ACTION) grantGeolocationPermissions()
+ }
+ }).show()
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ when (requestCode) {
+ REQUEST_GET_CONTENT -> onGetContentResult(data, resultCode)
+ REQUEST_LOCATION_SETTINGS -> onLocationSettingsResult()
+ }
+ }
+
+ private fun onLocationSettingsResult() {
+ if (!grantOrRequestSystemGeolocationPermissions()) grantGeolocationPermissions()
+ }
+
+ private fun onGetContentResult(data: Intent?, resultCode: Int) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ val result = WebChromeClient.FileChooserParams.parseResult(resultCode, data)
+ filePathCallback?.onReceiveValue(result)
+ filePathCallback = null
+ } else {
+ uploadFileCallback?.onReceiveValue(data?.data)
+ uploadFileCallback = null
+ }
+ }
+
+ private fun grantOrRequestSystemGeolocationPermissions(): Boolean {
+ val lm = getSystemService(Context.LOCATION_SERVICE) as LocationManager
+ if (lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
+ || lm.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission_group.LOCATION)
+ == PackageManager.PERMISSION_GRANTED) {
+ grantGeolocationPermissions()
+ } else {
+ val permissions = arrayOf(
+ Manifest.permission.ACCESS_FINE_LOCATION,
+ Manifest.permission.ACCESS_COARSE_LOCATION)
+ requestPermissions(permissions, PERMISSIONS_REQUEST_LOCATION)
+ }
+ return true
+ }
+ return false
+ }
+
+ override fun onRequestPermissionsResult(requestCode: Int, permissions: Array,
+ grantResults: IntArray) {
+ when (requestCode) {
+ PERMISSIONS_REQUEST_LOCATION -> grantGeolocationPermissions()
+ PERMISSIONS_REQUEST_STORAGE -> onRequestStoragePermissionsResult(grantResults)
+ }
+ }
+
+ private fun onRequestStoragePermissionsResult(grantResults: IntArray) {
+ if (grantResults.getOrNull(0) == PackageManager.PERMISSION_GRANTED) {
+ pendingDownloadInfo?.run { downloadFile(url, contentDisposition, mimetype) }
+ pendingDownloadInfo = null
+ }
+ }
+
+ override fun onSaveInstanceState(outState: Bundle?) {
+ super.onSaveInstanceState(outState)
+
+ webview?.saveState(outState)
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+
+ webview.setWebViewClient(null)
+ webview.setWebChromeClient(null)
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+
+ ruleManager.onInsert.remove(this)
+ ruleManager.onRemove.remove(this)
+ ruleManager.onUpdate.remove(this)
+ ruleManager.onMove.remove(this)
+ preferenceModel.onChange.remove(this)
+
+ contentView!!.webview.loadUrl("about:blank")
+ contentView = null
+ }
+
+ private data class DownloadInfo(val url: String?,
+ val contentDisposition: String?,
+ val mimetype: String?)
+
+ private inner class AppWebViewClient : WebViewClient() {
+ private var loading = false
+
+ @Suppress("OverridingDeprecatedMember")
+ override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
+ if (!URLUtil.isValidUrl(url)) {
+ return true
+ } else if (!patternManager.matches(url)) {
+ activity.startWebBrowser(url)
+ return true
+ }
+ return manager.onLoadUrl(url)
+ }
+
+ override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
+ currentUrl = url
+
+ updateSystemUi(url)
+
+ view.settings.userAgentString = rule!!.userAgent.value
+ view.settings.javaScriptEnabled = rule!!.jsEnabled
+
+ loading = true
+ view.postDelayed({ refreshLayout?.isRefreshing = loading }, 1000)
+
+ manager.onUrlChange?.invoke(url)
+ }
+
+ private fun hideProgressBar() {
+ loading = false
+ refreshLayout?.isRefreshing = false
+ }
+
+ @TargetApi(Build.VERSION_CODES.M)
+ override fun onPageCommitVisible(view: WebView, url: String) {
+ hideProgressBar()
+ }
+
+ override fun onPageFinished(view: WebView, url: String) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) hideProgressBar()
+ }
+ }
+
+ private data class GeolocationPermissionsPrompt(val origin: String?,
+ val callback: GeolocationPermissions.Callback)
+
+ private inner class AppWebChromeClient : WebChromeClient() {
+ override fun onGeolocationPermissionsShowPrompt(origin: String?,
+ callback: GeolocationPermissions.Callback) {
+ when (webappModel.webapp.locationPolicy) {
+ Policy.ASK -> showGeolocationPermissionsPrompt(origin, callback)
+ Policy.ALLOW -> callback.invoke(origin, true, false)
+ Policy.DENY -> callback.invoke(origin, false, false)
+ }
+ }
+
+ private fun showGeolocationPermissionsPrompt(origin: String?,
+ callback: GeolocationPermissions.Callback) {
+ bottomSheetBehavior?.state = BottomSheetBehavior.STATE_EXPANDED
+
+ denyButton.setOnClickListener {
+ webappModel.setLocationPolicy(Policy.DENY)
+ callback.invoke(origin, false, false)
+ bottomSheetBehavior?.state = BottomSheetBehavior.STATE_HIDDEN
+ }
+
+ allowButton.setOnClickListener {
+ webappModel.setLocationPolicy(Policy.ALLOW)
+ pendingGeolocationPermissionsPrompt = GeolocationPermissionsPrompt(origin, callback)
+ requestGeolocationPermissions()
+ bottomSheetBehavior?.state = BottomSheetBehavior.STATE_HIDDEN
+ }
+ }
+
+ override fun onGeolocationPermissionsHidePrompt() {
+ bottomSheetBehavior?.state = BottomSheetBehavior.STATE_HIDDEN
+ }
+
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ override fun onShowFileChooser(webView: WebView,
+ callback: ValueCallback>,
+ fileChooserParams: FileChooserParams): Boolean {
+ filePathCallback = callback
+
+ startActivityForResult(fileChooserParams.createIntent(), REQUEST_GET_CONTENT)
+ return true
+ }
+
+ //override system hidden api
+ @Suppress("UNUSED")
+ fun openFileChooser(callback: ValueCallback, acceptType: String?,
+ @Suppress("UNUSED_PARAMETER") capture: String?) {
+ uploadFileCallback = callback
+
+ val intent = Intent(Intent.ACTION_GET_CONTENT)
+ .setType(acceptType)
+ .addCategory(Intent.CATEGORY_OPENABLE)
+ startActivityForResult(intent, REQUEST_GET_CONTENT)
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/model/DataManager.kt b/app/src/main/java/com/github/drunlin/webappbox/model/DataManager.kt
new file mode 100644
index 0000000..1647a18
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/model/DataManager.kt
@@ -0,0 +1,25 @@
+package com.github.drunlin.webappbox.model
+
+import com.github.drunlin.webappbox.common.Callback
+import com.github.drunlin.webappbox.data.Unique
+import java.util.*
+
+abstract class DataManager : ObservableModel() {
+ val onRemove: Callback<() -> Unit> = Callback()
+
+ open val data: MutableList = LinkedList()
+
+ open protected fun insert(value: T) {
+ data.add(value)
+ onInsert.invoke { it(data.lastIndex) }
+ }
+
+ open protected fun update(index: Int, value: T) {
+ onUpdate.invoke { it(index) }
+ }
+
+ open fun remove(ids: Set) {
+ data.removeAll { ids.contains(it.id) }
+ onRemove.invoke { it() }
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/model/DatabaseManager.kt b/app/src/main/java/com/github/drunlin/webappbox/model/DatabaseManager.kt
new file mode 100644
index 0000000..e840949
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/model/DatabaseManager.kt
@@ -0,0 +1,309 @@
+package com.github.drunlin.webappbox.model
+
+import android.content.ContentValues
+import android.content.Context
+import android.database.Cursor
+import android.database.sqlite.SQLiteDatabase
+import android.database.sqlite.SQLiteOpenHelper
+import android.provider.BaseColumns
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.common.getRawText
+import com.github.drunlin.webappbox.common.toBitmap
+import com.github.drunlin.webappbox.common.toByteArray
+import com.github.drunlin.webappbox.data.*
+import dagger.Lazy
+import java.util.*
+import javax.inject.Inject
+
+class DatabaseManager(private val context: Context) :
+ SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {
+
+ companion object {
+ val DB_NAME = "app.db"
+ val DB_VERSION = 1
+
+ val TABLE_APP = "app"
+ val TABLE_RULE = "rule"
+ val TABLE_PATTERN = "pattern"
+ val TABLE_USER_AGENT = "user_agent"
+
+ val ID = BaseColumns._ID
+ val UUID = "uuid"
+ val APP_ID = "app_id"
+ val ORDER = "_order"
+ val URL = "url"
+ val NAME = "name"
+ val ICON = "icon"
+ val PATTERN = "pattern"
+ val REGEX = "regex"
+ val VALUE = "value"
+ val COLOR = "color"
+ val JS_ENABLE = "js_enable"
+ val USER_AGENT = "user_agent"
+ val ORIENTATION = "orientation"
+ val FULL_SCREEN = "full_screen"
+ val LAUNCH_MODE = "launch_mode"
+ val LOCATION = "location"
+ }
+
+ @Inject lateinit var userAgentManager: Lazy
+
+ override fun onCreate(db: SQLiteDatabase) {
+ val sql = """
+ begin transaction;
+ create table $TABLE_APP (
+ $ID integer primary key,
+ $UUID text not null,
+ $URL text not null,
+ $NAME text not null,
+ $ICON blob not null,
+ $LOCATION text not null
+ );
+ create table $TABLE_PATTERN (
+ $ID integer primary key,
+ $APP_ID integer not null,
+ $PATTERN text not null,
+ $REGEX integer not null
+ );
+ create table $TABLE_USER_AGENT (
+ $ID integer primary key,
+ $NAME text not null,
+ $VALUE text not null
+ );
+ create table $TABLE_RULE (
+ $ID integer primary key autoincrement,
+ $APP_ID integer not null,
+ $PATTERN text not null,
+ $REGEX integer not null,
+ $COLOR integer not null,
+ $LAUNCH_MODE text not null,
+ $ORIENTATION text not null,
+ $FULL_SCREEN integer not null,
+ $USER_AGENT integer not null,
+ $JS_ENABLE integer not null,
+ $ORDER integer default (last_insert_rowid())
+ );
+ ${context.getRawText(R.raw.setup)}
+ commit;
+ """
+ sql.split(";\n").filter(String::isNotBlank).map { "$it;" }.forEach { db.execSQL(it) }
+ }
+
+ override fun onUpgrade(p0: SQLiteDatabase?, p1: Int, p2: Int) = Unit
+
+ private fun query(table: String, columns: Array, selection: String?,
+ selectionArgs: Array?): Cursor {
+ return readableDatabase.query(table, columns, selection, selectionArgs, null, null, null)
+ }
+
+ private fun query(table: String, columns: Array, selection: String?): Cursor {
+ return query(table, columns, selection, null)
+ }
+
+ private fun query(table: String, columns: Array): Cursor {
+ return query(table, columns, null)
+ }
+
+ private fun query(table: String, columns: Array, selection: String?,
+ orderBy: String): Cursor {
+ return readableDatabase.query(table, columns, selection, null, null, null, orderBy)
+ }
+
+ private fun delete(table: String, whereClause: String): Int {
+ return writableDatabase.delete(table, whereClause, null)
+ }
+
+ private fun delete(table: String, ids: Set) {
+ delete(table, ids.map { "$ID=$it" }.joinToString(" or "))
+ }
+
+ private fun insert(table: String, values: ContentValues): Long {
+ return writableDatabase.insert(table, null, values)
+ }
+
+ private fun update(table: String, values: ContentValues, whereClause: String): Int {
+ return writableDatabase.update(table, values, whereClause, null)
+ }
+
+ private fun Cursor.getBoolean(columnIndex: Int) = getInt(columnIndex) != 0
+
+ private fun Webapp.toContentValues(): ContentValues {
+ val values = ContentValues()
+ values.put(URL, url)
+ values.put(ICON, icon.toByteArray())
+ values.put(NAME, name)
+ values.put(LOCATION, locationPolicy.name)
+ return values
+ }
+
+ fun insert(uuid: String, webapp: Webapp): Long {
+ val values = webapp.toContentValues()
+ values.put(UUID, uuid)
+ return insert(TABLE_APP, values)
+ }
+
+ fun update(webapp: Webapp) {
+ update(TABLE_APP, webapp.toContentValues(), "$ID=${webapp.id}")
+ }
+
+ fun updateLocationPolicy(id: Long, policy: Policy) {
+ val values = ContentValues()
+ values.put(LOCATION, policy.name)
+ update(TABLE_APP, values, "$ID=$id")
+ }
+
+ fun deleteWebapp(id: Long) {
+ delete(TABLE_APP, "$ID=$id")
+ delete(TABLE_PATTERN, "$APP_ID=$id")
+ delete(TABLE_RULE, "$APP_ID=$id")
+ }
+
+ fun getWebapp(id: Long): Webapp {
+ val cursor = query(DatabaseManager.TABLE_APP, arrayOf(URL, ICON, NAME, LOCATION), "$ID=$id")
+ cursor.moveToFirst()
+ val webapp = Webapp(id, cursor.getString(0), cursor.getBlob(1).toBitmap(),
+ cursor.getString(2), Policy.valueOf(cursor.getString(3)))
+ cursor.close()
+ return webapp
+ }
+
+ fun getPatterns(id: Long): MutableList {
+ val list = LinkedList()
+ val cursor = query(TABLE_PATTERN, arrayOf(ID, PATTERN, REGEX), "$APP_ID=$id")
+ if (cursor.moveToFirst())
+ do
+ list.add(URLPattern(cursor.getLong(0), cursor.getString(1), cursor.getBoolean(2)))
+ while (cursor.moveToNext())
+ cursor.close()
+ return list
+ }
+
+ private fun URLPattern.toContentValues(): ContentValues {
+ val values = ContentValues()
+ values.put(PATTERN, pattern)
+ values.put(REGEX, regex)
+ return values
+ }
+
+ fun insert(id: Long, pattern: URLPattern): Long {
+ val values = pattern.toContentValues()
+ values.put(APP_ID, id)
+ return insert(TABLE_PATTERN, values)
+ }
+
+ fun update(pattern: URLPattern) {
+ update(TABLE_PATTERN, pattern.toContentValues(), "$ID=${pattern.id}")
+ }
+
+ fun deletePatterns(ids: Set) {
+ delete(TABLE_PATTERN, ids)
+ }
+
+ private fun Rule.toContentValues(): ContentValues {
+ val values = ContentValues()
+ values.put(PATTERN, pattern.pattern)
+ values.put(REGEX, pattern.regex)
+ values.put(COLOR, color)
+ values.put(LAUNCH_MODE, launchMode.name)
+ values.put(ORIENTATION, orientation.name)
+ values.put(FULL_SCREEN, fullScreen)
+ values.put(USER_AGENT, userAgent.id)
+ values.put(JS_ENABLE, jsEnabled)
+ return values
+ }
+
+ fun insert(id: Long, rule: Rule): Long {
+ val values = rule.toContentValues()
+ values.put(APP_ID, id)
+ return insert(TABLE_RULE, values)
+ }
+
+ fun update(rule: Rule) {
+ update(TABLE_RULE, rule.toContentValues(), "$ID=${rule.id}")
+ }
+
+ fun deleteRules(ids: Set) {
+ delete(TABLE_RULE, ids)
+ }
+
+ fun getRules(id: Long): MutableList {
+ val list = LinkedList()
+ val columns = arrayOf(ID, PATTERN, REGEX, COLOR, LAUNCH_MODE, ORIENTATION, FULL_SCREEN,
+ USER_AGENT, JS_ENABLE)
+ val cursor = query(TABLE_RULE, columns, "$APP_ID=$id", ORDER)
+ if (cursor.moveToFirst())
+ do
+ list.add(Rule(cursor.getLong(0), URLPattern(cursor.getString(1), cursor.getBoolean(2)),
+ cursor.getInt(3), LaunchMode.valueOf(cursor.getString(4)),
+ Orientation.valueOf(cursor.getString(5)), cursor.getBoolean(6),
+ userAgentManager.get().getUserAgent(cursor.getLong(7)), cursor.getBoolean(8)))
+ while (cursor.moveToNext())
+ cursor.close()
+ return list
+ }
+
+ fun swapRules(from: Long, to: Long) {
+ val sql = """
+ update $TABLE_RULE
+ set $ORDER = (select sum($ORDER) from $TABLE_RULE where $ID in ($from, $to)) - $ORDER
+ where $ID in ($from, $to);
+ """
+ writableDatabase.execSQL(sql)
+ }
+
+ private fun UserAgent.toContentValues(): ContentValues {
+ val values = ContentValues()
+ values.put(NAME, name)
+ values.put(VALUE, value)
+ return values
+ }
+
+ fun insert(userAgent: UserAgent): Long {
+ return insert(TABLE_USER_AGENT, userAgent.toContentValues())
+ }
+
+ fun update(userAgent: UserAgent) {
+ update(TABLE_USER_AGENT, userAgent.toContentValues(), "$ID=${userAgent.id}")
+ }
+
+ fun deleteUserAgents(ids: Set) {
+ delete(TABLE_USER_AGENT, ids)
+ }
+
+ fun getUserAgents(): MutableList {
+ val list = LinkedList()
+ val cursor = query(TABLE_USER_AGENT, arrayOf(ID, NAME, VALUE))
+ if (cursor.moveToFirst())
+ do
+ list.add(UserAgent(cursor.getLong(0), cursor.getString(1), cursor.getString(2)))
+ while (cursor.moveToNext())
+ cursor.close()
+ return list
+ }
+
+ fun getShortcuts(): MutableList {
+ val list = LinkedList()
+ val cursor = query(TABLE_APP, arrayOf(ID, UUID, ICON, NAME), null, NAME)
+ if (cursor.moveToFirst())
+ do
+ list.add(Shortcut(cursor.getLong(0), cursor.getString(1),
+ cursor.getBlob(2).toBitmap(), cursor.getString(3)))
+ while (cursor.moveToNext())
+ cursor.close()
+ return list
+ }
+
+ fun isWebappExisted(url: String): Boolean {
+ val cursor = query(TABLE_APP, arrayOf(ID), "$URL=?", arrayOf(url))
+ val existed = cursor.moveToFirst()
+ cursor.close()
+ return existed
+ }
+
+ fun getWebappId(uuid: String): Long? {
+ val cursor = query(TABLE_APP, arrayOf(ID), "$UUID=?", arrayOf(uuid))
+ val id = if (cursor.moveToFirst()) cursor.getLong(0) else null
+ cursor.close()
+ return id
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/model/IconLoader.kt b/app/src/main/java/com/github/drunlin/webappbox/model/IconLoader.kt
new file mode 100644
index 0000000..f1f063c
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/model/IconLoader.kt
@@ -0,0 +1,101 @@
+package com.github.drunlin.webappbox.model
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Canvas
+import android.graphics.RectF
+import android.util.TypedValue
+import com.github.drunlin.webappbox.common.getDimension
+import com.github.drunlin.webappbox.common.iconSize
+import org.jsoup.Jsoup
+import org.jsoup.nodes.Element
+import java.net.HttpURLConnection
+import java.net.URL
+import javax.inject.Inject
+
+class IconLoader {
+ @Inject lateinit var context: Context
+
+ @Volatile var canceled: Boolean = false
+ private set
+
+ fun cancel() {
+ canceled = true
+ }
+
+ private fun checkCanceled() {
+ if (canceled) throw RuntimeException()
+ }
+
+ fun load(url: String): Bitmap? = try {
+ getIcon(getIconData(getIconUrl(url)))
+ } catch (e: Exception) {
+ null
+ }
+
+ private fun getIcon(data: ByteArray): Bitmap {
+ checkCanceled()
+
+ val opt = BitmapFactory.Options()
+ opt.inJustDecodeBounds = true
+ BitmapFactory.decodeByteArray(data, 0, data.size, opt)
+ opt.inSampleSize = 2
+ while (opt.outWidth / opt.inSampleSize > context.iconSize
+ || opt.outHeight / opt.inSampleSize > context.iconSize) {
+ opt.inSampleSize *= 2
+ }
+ opt.inSampleSize /= 2
+ opt.inJustDecodeBounds = false
+
+ val padding = context.getDimension(TypedValue.COMPLEX_UNIT_DIP, 5f)
+ val bounderSize = context.getDimension(TypedValue.COMPLEX_UNIT_DIP, 48f)
+ val iconSize = context.getDimension(TypedValue.COMPLEX_UNIT_DIP, 38f)
+ val src = BitmapFactory.decodeByteArray(data, 0, data.size, opt)
+ val rect = RectF(padding, padding, padding + iconSize, padding + iconSize)
+ val dest = Bitmap.createBitmap(bounderSize.toInt(), bounderSize.toInt(), src.config)
+ Canvas(dest).drawBitmap(src, null, rect, null)
+ return dest
+ }
+
+ private fun getIconData(url: URL): ByteArray {
+ checkCanceled()
+
+ val connection = url.openConnection() as HttpURLConnection
+ connection.connectTimeout = 3000
+ connection.readTimeout = 7000
+ connection.connect()
+ return connection.inputStream.readBytes()
+ }
+
+ private fun getIconUrl(url: String): URL {
+ checkCanceled()
+
+ val ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 10_0 like Mac OS X) " +
+ "AppleWebKit/602.1.38 (KHTML, like Gecko) Version/10.0 Mobile/14A5297c Safari/602.1"
+ val document = Jsoup.connect(url).userAgent(ua).timeout(10000).get()
+ val selectors = "link[rel='icon'], link[rel='apple-touch-icon'], " +
+ "link[rel='apple-touch-icon-precomposed'], link[rel='shortcut icon']"
+ val urlContext = URL(url)
+ val icons = document.select(selectors)
+ .map { parse(urlContext, it) }
+ .filter { it.url.openConnection().contentType == "image/png" }
+ .sortedBy { it.order }
+ return (icons.find { it.size >= context.iconSize } ?: icons.last()).url
+ }
+
+ private fun parse(context: URL, element: Element): Icon {
+ val sizes = element.attr("sizes")
+ val size = if (sizes.isEmpty()) 0 else sizes.split('x').map(String::toInt).max()!!
+ val priority = when (element.attr("rel")) {
+ "icon" -> 4
+ "apple-touch-icon-precomposed" -> 3
+ "apple-touch-icon" -> 2
+ "shortcut icon" -> 1
+ else -> 0
+ }
+ return Icon(URL(context, element.attr("href")), size, size * 10 + priority)
+ }
+
+ private class Icon(val url: URL, val size: Int, val order: Int)
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/model/ObservableModel.kt b/app/src/main/java/com/github/drunlin/webappbox/model/ObservableModel.kt
new file mode 100644
index 0000000..16843a0
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/model/ObservableModel.kt
@@ -0,0 +1,8 @@
+package com.github.drunlin.webappbox.model
+
+import com.github.drunlin.webappbox.common.Callback
+
+abstract class ObservableModel {
+ val onInsert: Callback<(Int) -> Unit> = Callback()
+ val onUpdate: Callback<(Int) -> Unit> = Callback()
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/model/PatternManager.kt b/app/src/main/java/com/github/drunlin/webappbox/model/PatternManager.kt
new file mode 100644
index 0000000..4e7b811
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/model/PatternManager.kt
@@ -0,0 +1,27 @@
+package com.github.drunlin.webappbox.model
+
+import com.github.drunlin.webappbox.common.findIndexedValue
+import com.github.drunlin.webappbox.common.generateId
+import com.github.drunlin.webappbox.data.URLPattern
+
+open class PatternManager : DataManager() {
+ val patterns by lazy { data }
+
+ fun insert(value: String, regex: Boolean) {
+ insert(URLPattern(generateId(), value, regex))
+ }
+
+ fun update(id: Long, value: String, regex: Boolean) {
+ val (index, pattern) = patterns.findIndexedValue { it.id == id }
+ pattern.pattern = value
+ pattern.regex = regex
+ update(index, pattern)
+ }
+
+ fun matches(url: String) = patterns.isEmpty() || patterns.any { it.matches(url) }
+
+ fun getPattern(id: Long) = patterns.find { it.id == id }!!
+
+ fun isExited(pattern: String, regex: Boolean)
+ = patterns.any { it.regex == regex && it.pattern == pattern }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/model/PersistentPatternManager.kt b/app/src/main/java/com/github/drunlin/webappbox/model/PersistentPatternManager.kt
new file mode 100644
index 0000000..befadd5
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/model/PersistentPatternManager.kt
@@ -0,0 +1,30 @@
+package com.github.drunlin.webappbox.model
+
+import com.github.drunlin.webappbox.common.asyncCall
+import com.github.drunlin.webappbox.common.runOnIoThread
+import com.github.drunlin.webappbox.data.URLPattern
+import javax.inject.Inject
+
+class PersistentPatternManager(private val id: Long) : PatternManager() {
+ @Inject lateinit var databaseManager: DatabaseManager
+
+ override val data by lazy { databaseManager.getPatterns(id) }
+
+ override fun insert(value: URLPattern) {
+ super.insert(value)
+
+ asyncCall({ databaseManager.insert(id, value) }) { value.id = it }
+ }
+
+ override fun update(index: Int, value: URLPattern) {
+ super.update(index, value)
+
+ runOnIoThread { databaseManager.update(value) }
+ }
+
+ override fun remove(ids: Set) {
+ super.remove(ids)
+
+ runOnIoThread { databaseManager.deletePatterns(ids) }
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/model/PersistentRuleManager.kt b/app/src/main/java/com/github/drunlin/webappbox/model/PersistentRuleManager.kt
new file mode 100644
index 0000000..021343b
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/model/PersistentRuleManager.kt
@@ -0,0 +1,36 @@
+package com.github.drunlin.webappbox.model
+
+import com.github.drunlin.webappbox.common.asyncCall
+import com.github.drunlin.webappbox.common.runOnIoThread
+import com.github.drunlin.webappbox.data.Rule
+import javax.inject.Inject
+
+class PersistentRuleManager(private val id: Long) : RuleManager() {
+ @Inject lateinit var databaseManager: DatabaseManager
+
+ override val data by lazy { databaseManager.getRules(id) }
+
+ override fun insert(value: Rule) {
+ super.insert(value)
+
+ asyncCall({ databaseManager.insert(id, value) }) { value.id = it }
+ }
+
+ override fun remove(ids: Set) {
+ super.remove(ids)
+
+ runOnIoThread { databaseManager.deleteRules(ids) }
+ }
+
+ override fun swap(from: Int, to: Int) {
+ super.swap(from, to)
+
+ runOnIoThread { databaseManager.swapRules(rules[from].id, rules[to].id) }
+ }
+
+ override fun update(index: Int, value: Rule) {
+ super.update(index, value)
+
+ runOnIoThread { databaseManager.update(value) }
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/model/PersistentWebappModel.kt b/app/src/main/java/com/github/drunlin/webappbox/model/PersistentWebappModel.kt
new file mode 100644
index 0000000..253163f
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/model/PersistentWebappModel.kt
@@ -0,0 +1,19 @@
+package com.github.drunlin.webappbox.model
+
+import com.github.drunlin.webappbox.common.runOnIoThread
+import com.github.drunlin.webappbox.data.Policy
+import javax.inject.Inject
+
+class PersistentWebappModel(id: Long) : WebappModel(id) {
+ @Inject lateinit var databaseManager: DatabaseManager
+ @Inject lateinit var webappManager: WebappManager
+
+ override val webapp by lazy { databaseManager.getWebapp(id) }
+ override val originalUrl by lazy { webapp.url }
+
+ override fun setLocationPolicy(policy: Policy) {
+ super.setLocationPolicy(policy)
+
+ runOnIoThread { databaseManager.updateLocationPolicy(id, policy) }
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/model/PreferenceModel.kt b/app/src/main/java/com/github/drunlin/webappbox/model/PreferenceModel.kt
new file mode 100644
index 0000000..32bfcea
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/model/PreferenceModel.kt
@@ -0,0 +1,61 @@
+package com.github.drunlin.webappbox.model
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.graphics.Color
+import com.github.drunlin.webappbox.common.*
+import com.github.drunlin.webappbox.data.*
+import com.github.drunlin.webappbox.module.Injectable
+import javax.inject.Inject
+
+class PreferenceModel : Injectable, SharedPreferences.OnSharedPreferenceChangeListener {
+ companion object {
+ val PREFERENCE_NAME = "settings"
+ }
+
+ @Inject lateinit var context: Context
+ @Inject lateinit var userAgentManager: UserAgentManager
+
+ var onChange: Callback<(String) -> Unit> = Callback()
+
+ private val preferences by lazy {
+ context.getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE)
+ }
+
+ lateinit private var userAgent: UserAgent
+
+ private var _defaultRule: Rule? = null
+ val defaultRule: Rule
+ get() {
+ if (_defaultRule == null) {
+ val launchMode = LaunchMode.valueOf(
+ preferences.getString(PREF_LAUNCH_MODE, LaunchMode.STANDARD.name))
+ val orientation = Orientation.valueOf(
+ preferences.getString(PREF_ORIENTATION, Orientation.NORMAL.name))
+ _defaultRule = Rule(-1, URLPattern(), preferences.getInt(PREF_COLOR, Color.BLACK),
+ launchMode, orientation, preferences.getBoolean(PREF_FULL_SCREEN, false),
+ userAgent, preferences.getBoolean(PREF_ENABLE_JS, true))
+ }
+ return _defaultRule!!
+ }
+
+ override fun init() {
+ userAgent = userAgentManager.getUserAgent(preferences.getLong(PREF_USER_AGENT, -1))
+
+ preferences.registerOnSharedPreferenceChangeListener(this)
+
+ userAgentManager.onRemove.add(this) {
+ if (userAgent !in userAgentManager.userAgents) setUserAgent(null)
+ }
+ }
+
+ override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
+ _defaultRule = null
+ onChange.invoke { it(key) }
+ }
+
+ fun setUserAgent(value: UserAgent?) {
+ userAgent = value ?: userAgentManager.defaultUserAgent
+ preferences.edit().putLong(PREF_USER_AGENT, value?.id ?: -1).apply()
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/model/RuleManager.kt b/app/src/main/java/com/github/drunlin/webappbox/model/RuleManager.kt
new file mode 100644
index 0000000..142b11b
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/model/RuleManager.kt
@@ -0,0 +1,51 @@
+package com.github.drunlin.webappbox.model
+
+import com.github.drunlin.webappbox.common.Callback
+import com.github.drunlin.webappbox.common.findIndexedValue
+import com.github.drunlin.webappbox.common.generateId
+import com.github.drunlin.webappbox.data.*
+import java.util.*
+import javax.inject.Inject
+
+open class RuleManager : DataManager() {
+ @Inject lateinit var settingsModel: PreferenceModel
+ @Inject lateinit var userAgentManager: UserAgentManager
+
+ val onMove: Callback<(Int, Int) -> Unit> = Callback()
+
+ val rules by lazy { data }
+
+ fun getRule(url: String) = rules.find { it.pattern.matches(url) } ?: settingsModel.defaultRule
+
+ fun getRule(id: Long) = rules.find { it.id == id }!!
+
+ fun isExited(pattern: String, regex: Boolean)
+ = rules.any { it.pattern.pattern == pattern && it.pattern.regex == regex }
+
+ open fun swap(from: Int, to: Int) {
+ Collections.swap(rules, from, to)
+ onMove.invoke { it(from, to) }
+ }
+
+ fun update(id: Long, pattern: String, regex: Boolean, color: Int, launchMode: LaunchMode,
+ orientation: Orientation, fullScreen: Boolean, userAgent: UserAgent?, enableJS: Boolean) {
+ val (index, rule) = rules.findIndexedValue { it.id == id }
+ rule.pattern.pattern = pattern
+ rule.pattern.regex = regex
+ rule.color = color
+ rule.launchMode = launchMode
+ rule.orientation = orientation
+ rule.fullScreen = fullScreen
+ rule.userAgent = userAgent ?: userAgentManager.defaultUserAgent
+ rule.jsEnabled = enableJS
+ update(index, rule)
+ }
+
+ fun insert(pattern: String, regex: Boolean, color: Int, launchMode: LaunchMode,
+ orientation: Orientation, fullScreen: Boolean,
+ userAgent: UserAgent?, enableJS: Boolean) {
+ val rule = Rule(generateId(), URLPattern(pattern, regex), color, launchMode, orientation,
+ fullScreen, userAgent ?: userAgentManager.defaultUserAgent, enableJS)
+ insert(rule)
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/model/StoredWebappModel.kt b/app/src/main/java/com/github/drunlin/webappbox/model/StoredWebappModel.kt
new file mode 100644
index 0000000..8637b68
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/model/StoredWebappModel.kt
@@ -0,0 +1,10 @@
+package com.github.drunlin.webappbox.model
+
+import javax.inject.Inject
+
+class StoredWebappModel(id: Long) : WebappModel(id) {
+ @Inject lateinit var databaseManager: DatabaseManager
+
+ override val webapp by lazy { databaseManager.getWebapp(id) }
+ override val originalUrl by lazy { webapp.url }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/model/UserAgentManager.kt b/app/src/main/java/com/github/drunlin/webappbox/model/UserAgentManager.kt
new file mode 100644
index 0000000..7a05f99
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/model/UserAgentManager.kt
@@ -0,0 +1,52 @@
+package com.github.drunlin.webappbox.model
+
+import com.github.drunlin.webappbox.common.asyncCall
+import com.github.drunlin.webappbox.common.findIndexedValue
+import com.github.drunlin.webappbox.common.generateId
+import com.github.drunlin.webappbox.common.runOnIoThread
+import com.github.drunlin.webappbox.data.UserAgent
+import javax.inject.Inject
+
+class UserAgentManager : DataManager() {
+ @Inject lateinit var databaseManager: DatabaseManager
+
+ override val data by lazy { databaseManager.getUserAgents() }
+
+ val userAgents by lazy { data }
+ val defaultUserAgent by lazy { UserAgent(0, "", "") }
+
+ fun insert(name: String, value: String) {
+ insert(UserAgent(generateId(), name, value))
+ }
+
+ override fun insert(value: UserAgent) {
+ super.insert(value)
+
+ asyncCall({ databaseManager.insert(value) }) { value.id = it }
+ }
+
+ fun update(id: Long, name: String, value: String) {
+ val (index, userAgent) = data.findIndexedValue { it.id == id }
+ userAgent.name = name
+ userAgent.value = value
+ update(index, userAgent)
+ }
+
+ override fun update(index: Int, value: UserAgent) {
+ super.update(index, value)
+
+ runOnIoThread { databaseManager.update(value) }
+ }
+
+ override fun remove(ids: Set) {
+ super.remove(ids)
+
+ runOnIoThread { databaseManager.deleteUserAgents(ids) }
+ }
+
+ fun isNameExited(name: String) = data.any { it.name == name }
+
+ fun isValueExited(value: String) = data.any { it.value == value }
+
+ fun getUserAgent(id: Long) = data.find { it.id == id } ?: defaultUserAgent
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/model/WebappManager.kt b/app/src/main/java/com/github/drunlin/webappbox/model/WebappManager.kt
new file mode 100644
index 0000000..c3c116b
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/model/WebappManager.kt
@@ -0,0 +1,112 @@
+package com.github.drunlin.webappbox.model
+
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.webkit.*
+import com.github.drunlin.webappbox.activity.WebappActivity
+import com.github.drunlin.webappbox.common.*
+import com.github.drunlin.webappbox.data.Shortcut
+import com.github.drunlin.webappbox.module.WebappComponent
+import java.util.*
+import javax.inject.Inject
+
+class WebappManager : ObservableModel() {
+ @Inject lateinit var context: Context
+ @Inject lateinit var databaseManager: DatabaseManager
+
+ val onRemove: Callback<(Int) -> Unit> = Callback()
+
+ val shortcuts by lazy { databaseManager.getShortcuts() }
+
+ fun insert(component: WebappComponent, addShortcut: Boolean) {
+ val uuid = UUID.randomUUID().toString().replace("-", "")
+ val webapp = component.webappModel.webapp
+ asyncCall({
+ val id = databaseManager.insert(uuid, webapp)
+ component.patternManager.patterns.forEach { databaseManager.insert(id, it) }
+ component.ruleManager.rules.forEach { databaseManager.insert(id, it) }
+ return@asyncCall id
+ }) {
+ val shortcut = Shortcut(it, uuid, webapp.icon, webapp.name)
+ shortcuts.add(shortcut)
+ onInsert.invoke { it(shortcuts.lastIndex) }
+ if (addShortcut) installShortcut(shortcut)
+ }
+ }
+
+ fun update(component: WebappComponent, addShortcut: Boolean) {
+ val webapp = component.webappModel.webapp
+ val (index, shortcut) = shortcuts.findIndexedValue { it.id == webapp.id }
+ shortcut.icon = webapp.icon
+ shortcut.name = webapp.name
+ onUpdate.invoke { it(index) }
+ if (addShortcut) installShortcut(shortcut)
+
+ runOnIoThread { databaseManager.update(webapp) }
+ }
+
+ fun delete(id: Long) {
+ val (index, shortcut) = shortcuts.findIndexedValue { it.id == id }
+ shortcuts.removeAt(index)
+ onRemove.invoke { it(index) }
+ uninstallShortcut(shortcut)
+
+ runOnIoThread { databaseManager.deleteWebapp(id) }
+ }
+
+ fun isExisted(url: String) = databaseManager.isWebappExisted(url)
+
+ fun getWebappId(uuid: String) = databaseManager.getWebappId(uuid)
+
+ fun filter(name: String): List {
+ return if (name.isEmpty()) shortcuts else shortcuts.filter { it.name.contains(name) }
+ }
+
+ fun installShortcut(id: Long) {
+ installShortcut(shortcuts.find { it.id == id }!!)
+ }
+
+ fun installShortcut(shortcut: Shortcut) {
+ val intent = Intent("com.android.launcher.action.INSTALL_SHORTCUT")
+ .putExtra(Intent.EXTRA_SHORTCUT_INTENT, WebappActivity.start(shortcut.uuid))
+ .putExtra(Intent.EXTRA_SHORTCUT_NAME, shortcut.name)
+ .putExtra(Intent.EXTRA_SHORTCUT_ICON, shortcut.icon)
+ .putExtra(EXTRA_SHORTCUT_DUPLICATE, true)
+ context.sendBroadcast(intent)
+ }
+
+ fun uninstallShortcut(shortcut: Shortcut) {
+ val intent = Intent("com.android.launcher.action.UNINSTALL_SHORTCUT")
+ .putExtra(Intent.EXTRA_SHORTCUT_INTENT, WebappActivity.start(shortcut.uuid))
+ .putExtra(Intent.EXTRA_SHORTCUT_NAME, shortcut.name)
+ .putExtra(EXTRA_SHORTCUT_DUPLICATE, true)
+ context.sendBroadcast(intent)
+ }
+
+ @Suppress("DEPRECATION")
+ fun clearData() {
+ clearCache()
+
+ val db = WebViewDatabase.getInstance(context)
+ db.clearFormData()
+ db.clearHttpAuthUsernamePassword()
+
+ WebStorage.getInstance().deleteAllData()
+
+ val cm = CookieManager.getInstance()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ cm.removeAllCookies { runOnIoThread { cm.flush() } }
+ } else {
+ val sm = CookieSyncManager.createInstance(context)
+ sm.startSync()
+ cm.removeAllCookie()
+ sm.stopSync()
+ sm.sync()
+ }
+ }
+
+ fun clearCache() {
+ WebView(context).clearCache(true)
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/model/WebappModel.kt b/app/src/main/java/com/github/drunlin/webappbox/model/WebappModel.kt
new file mode 100644
index 0000000..bdeca01
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/model/WebappModel.kt
@@ -0,0 +1,35 @@
+package com.github.drunlin.webappbox.model
+
+import android.content.Context
+import android.graphics.Bitmap
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.common.Callback
+import com.github.drunlin.webappbox.common.getBitmap
+import com.github.drunlin.webappbox.data.Policy
+import com.github.drunlin.webappbox.data.Webapp
+import javax.inject.Inject
+
+open class WebappModel(protected val id: Long) {
+ @Inject lateinit var context: Context
+
+ val onUrlChange = Callback<(String) -> Unit>()
+
+ open val originalUrl = ""
+ open val webapp by lazy { Webapp(id, "", context.getBitmap(R.mipmap.ic_webapp), "", Policy.ASK) }
+
+ open fun setUrl(url: String) {
+ webapp.url = url
+ onUrlChange.invoke { it(url) }
+ }
+
+ open fun setLocationPolicy(policy: Policy) {
+ webapp.locationPolicy = policy
+ }
+
+ fun update(url: String, icon: Bitmap, name: String, locationPolicy: Policy) {
+ webapp.url = url
+ webapp.icon = icon
+ webapp.name = name
+ webapp.locationPolicy = locationPolicy
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/module/AppComponent.kt b/app/src/main/java/com/github/drunlin/webappbox/module/AppComponent.kt
new file mode 100644
index 0000000..ae306cc
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/module/AppComponent.kt
@@ -0,0 +1,31 @@
+package com.github.drunlin.webappbox.module
+
+import com.github.drunlin.webappbox.activity.LauncherActivity
+import com.github.drunlin.webappbox.activity.MainActivity
+import com.github.drunlin.webappbox.activity.WebappActivity
+import com.github.drunlin.webappbox.fragment.IconLoaderFragment
+import com.github.drunlin.webappbox.fragment.PreferencesFragment
+import com.github.drunlin.webappbox.fragment.UserAgentEditorFragment
+import com.github.drunlin.webappbox.fragment.UserAgentsFragment
+import com.github.drunlin.webappbox.model.*
+import dagger.Component
+import javax.inject.Singleton
+
+@Singleton
+@Component(modules = arrayOf(AppModule::class))
+interface AppComponent {
+ fun inject(model: DatabaseManager): DatabaseManager
+ fun inject(manager: WebappManager): WebappManager
+ fun inject(activity: MainActivity)
+ fun inject(activity: LauncherActivity)
+ fun inject(activity: WebappActivity)
+ fun inject(model: PreferenceModel): PreferenceModel
+ fun inject(fragment: PreferencesFragment)
+ fun inject(manager: UserAgentManager): UserAgentManager
+ fun inject(fragment: UserAgentEditorFragment)
+ fun inject(fragment: UserAgentsFragment)
+ fun inject(model: IconLoader): IconLoader
+ fun inject(fragment: IconLoaderFragment)
+
+ fun webappComponent(module: WebappModule): WebappComponent
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/module/AppModule.kt b/app/src/main/java/com/github/drunlin/webappbox/module/AppModule.kt
new file mode 100644
index 0000000..1829bf2
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/module/AppModule.kt
@@ -0,0 +1,33 @@
+package com.github.drunlin.webappbox.module
+
+import android.content.Context
+import com.github.drunlin.webappbox.model.*
+import dagger.Module
+import dagger.Provides
+import javax.inject.Singleton
+
+@Module
+class AppModule(val context: Context) {
+ @Singleton
+ @Provides
+ fun provideContext() = context
+
+ @Singleton
+ @Provides
+ fun provideWebappManager(comp: AppComponent) = comp.inject(WebappManager())
+
+ @Singleton
+ @Provides
+ fun providePreferenceModel(comp: AppComponent) = comp.inject(PreferenceModel()).apply { init() }
+
+ @Singleton
+ @Provides
+ fun provideUserAgentManager(comp: AppComponent) = comp.inject(UserAgentManager())
+
+ @Singleton
+ @Provides
+ fun provideDbHelper(comp: AppComponent) = comp.inject(DatabaseManager(context))
+
+ @Provides
+ fun provideIconLoader(comp: AppComponent) = comp.inject(IconLoader())
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/module/Factory.kt b/app/src/main/java/com/github/drunlin/webappbox/module/Factory.kt
new file mode 100644
index 0000000..d08d4bf
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/module/Factory.kt
@@ -0,0 +1,13 @@
+package com.github.drunlin.webappbox.module
+
+import java.lang.ref.WeakReference
+import java.util.*
+
+class Factory {
+ private val map = HashMap>()
+
+ fun get(key: K, generator: () -> V): V {
+ map.filter { it.value.isEnqueued }.forEach { map.remove(it.key) }
+ return map[key]?.get() ?: generator().apply { map[key] = WeakReference(this) }
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/module/Injectable.kt b/app/src/main/java/com/github/drunlin/webappbox/module/Injectable.kt
new file mode 100644
index 0000000..3431802
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/module/Injectable.kt
@@ -0,0 +1,5 @@
+package com.github.drunlin.webappbox.module
+
+interface Injectable {
+ fun init()
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/module/WebappComponent.kt b/app/src/main/java/com/github/drunlin/webappbox/module/WebappComponent.kt
new file mode 100644
index 0000000..12aaeb9
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/module/WebappComponent.kt
@@ -0,0 +1,35 @@
+package com.github.drunlin.webappbox.module
+
+import com.github.drunlin.webappbox.activity.WebappActivity
+import com.github.drunlin.webappbox.activity.WebappEditorActivity
+import com.github.drunlin.webappbox.fragment.*
+import com.github.drunlin.webappbox.model.*
+import dagger.Subcomponent
+import javax.inject.Singleton
+
+@Singleton
+@Subcomponent(modules = arrayOf(WebappModule::class))
+interface WebappComponent {
+ val webappId: Long
+ val webappModel: WebappModel
+ val ruleManager: RuleManager
+ val patternManager: PatternManager
+
+ fun inject(activity: WebappActivity)
+ fun inject(activity: WebappEditorActivity)
+ fun inject(fragment: WebappEditorFragment)
+ fun inject(fragment: PatternsFragment)
+ fun inject(fragment: PatternEditorFragment)
+ fun inject(fragment: RulesFragment)
+ fun inject(fragment: RuleEditorFragment)
+ fun inject(fragment: WebappWindowFragment)
+ fun inject(fragment: PreviewFragment)
+ fun inject(fragment: WebappFragment)
+ fun inject(model: PersistentWebappModel): PersistentWebappModel
+ fun inject(model: WebappModel): WebappModel
+ fun inject(model: StoredWebappModel): StoredWebappModel
+ fun inject(model: PersistentPatternManager): PersistentPatternManager
+ fun inject(model: PatternManager): PatternManager
+ fun inject(model: RuleManager): RuleManager
+ fun inject(model: PersistentRuleManager): PersistentRuleManager
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/module/WebappModule.kt b/app/src/main/java/com/github/drunlin/webappbox/module/WebappModule.kt
new file mode 100644
index 0000000..b4f7962
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/module/WebappModule.kt
@@ -0,0 +1,43 @@
+package com.github.drunlin.webappbox.module
+
+import com.github.drunlin.webappbox.model.*
+import com.github.drunlin.webappbox.module.WebappModule.Flag
+import dagger.Module
+import dagger.Provides
+import javax.inject.Singleton
+
+@Module
+class WebappModule(private val id: Long, private val flag: Flag = Flag.NORMAL) {
+ companion object {
+ private val patterManagers = Factory()
+ private val ruleManagers = Factory()
+ }
+
+ @Singleton
+ @Provides
+ fun provideWebappId() = id
+
+ @Singleton
+ @Provides
+ fun provideWebappModel(comp: WebappComponent) = when (flag) {
+ Flag.NORMAL -> comp.inject(PersistentWebappModel(id))
+ Flag.NEW -> comp.inject(WebappModel(id))
+ Flag.EDIT -> comp.inject(StoredWebappModel(id))
+ }
+
+ @Singleton
+ @Provides
+ fun providePatternManager(comp: WebappComponent) = if (flag == Flag.NEW)
+ comp.inject(PatternManager())
+ else
+ comp.inject(patterManagers.get(id) { PersistentPatternManager(id) })
+
+ @Singleton
+ @Provides
+ fun provideRuleManager(comp: WebappComponent) = if (flag == Flag.NEW)
+ comp.inject(RuleManager())
+ else
+ comp.inject(ruleManagers.get(id) { PersistentRuleManager(id) })
+
+ enum class Flag { NORMAL, NEW, EDIT }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/widget/AppEditText.kt b/app/src/main/java/com/github/drunlin/webappbox/widget/AppEditText.kt
new file mode 100644
index 0000000..3a76ff8
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/widget/AppEditText.kt
@@ -0,0 +1,13 @@
+package com.github.drunlin.webappbox.widget
+
+import android.content.Context
+import android.support.design.widget.TextInputEditText
+import android.util.AttributeSet
+
+class AppEditText(context: Context, attar: AttributeSet) : TextInputEditText(context, attar) {
+ override fun setText(text: CharSequence?, type: BufferType?) {
+ super.setText(text, type)
+
+ setSelection(length())
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/widget/AppSpinner.kt b/app/src/main/java/com/github/drunlin/webappbox/widget/AppSpinner.kt
new file mode 100644
index 0000000..979a6a4
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/widget/AppSpinner.kt
@@ -0,0 +1,30 @@
+package com.github.drunlin.webappbox.widget
+
+import android.content.Context
+import android.support.v7.widget.AppCompatSpinner
+import android.util.AttributeSet
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.widget.adapter.SpinnerAdapter
+
+class AppSpinner(context: Context, attar: AttributeSet) : AppCompatSpinner(context, attar) {
+ private var _values: Array?
+ var values: Array
+ set(value) { _values = value }
+ get() = _values!!
+
+ var value: String
+ set(value) { setSelection(values.indexOf(value)) }
+ get() = values[selectedItemPosition]
+
+ init {
+ val array = context.obtainStyledAttributes(attar, R.styleable.AppSpinner)
+
+ val title = array.getString(R.styleable.AppSpinner_title) ?: ""
+ val entries = array.getTextArray(R.styleable.AppSpinner_entries)
+ entries?.run { adapter = SpinnerAdapter(context, title, toList()) }
+
+ _values = array.getTextArray(R.styleable.AppSpinner_values)?.map { "$it" }?.toTypedArray()
+
+ array.recycle()
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/widget/ColorView.kt b/app/src/main/java/com/github/drunlin/webappbox/widget/ColorView.kt
new file mode 100644
index 0000000..c1fd730
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/widget/ColorView.kt
@@ -0,0 +1,46 @@
+package com.github.drunlin.webappbox.widget
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.os.Bundle
+import android.os.Parcelable
+import android.util.AttributeSet
+import android.view.View
+import com.github.drunlin.webappbox.common.STATE_COLOR
+import com.github.drunlin.webappbox.common.STATE_SUPER
+
+class ColorView(context: Context, attrs: AttributeSet) : View(context, attrs) {
+ private val paint: Paint
+
+ init {
+ paint = Paint(Paint.ANTI_ALIAS_FLAG)
+ paint.color = Color.BLACK
+ }
+
+ var color: Int
+ get() = paint.color
+ set(color) {
+ paint.color = color
+ invalidate()
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ val radius = (measuredWidth / 2).toFloat()
+ canvas.drawCircle(radius, radius, radius, paint)
+ }
+
+ override fun onSaveInstanceState(): Parcelable {
+ val bundle = Bundle()
+ bundle.putParcelable(STATE_SUPER, super.onSaveInstanceState())
+ bundle.putInt(STATE_COLOR, color)
+ return bundle
+ }
+
+ override fun onRestoreInstanceState(state: Parcelable) {
+ val bundle = state as Bundle
+ super.onRestoreInstanceState(bundle.getParcelable(STATE_SUPER))
+ color = state.getInt(STATE_COLOR)
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/widget/ContentLayout.kt b/app/src/main/java/com/github/drunlin/webappbox/widget/ContentLayout.kt
new file mode 100644
index 0000000..ce7fac8
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/widget/ContentLayout.kt
@@ -0,0 +1,64 @@
+package com.github.drunlin.webappbox.widget
+
+import android.annotation.TargetApi
+import android.content.Context
+import android.graphics.Rect
+import android.os.Build
+import android.util.AttributeSet
+import android.util.Log
+import android.view.View
+import android.view.WindowInsets
+import android.widget.FrameLayout
+import com.github.drunlin.webappbox.common.Callback
+
+class ContentLayout(context: Context, attar: AttributeSet?) : FrameLayout(context, attar) {
+ companion object {
+ val LOG_TAG = ContentLayout::class.java.name!!
+ }
+
+ val onStatusBarHeightChange = Callback<(Int) -> Unit>()
+
+ var statusBarHeight = 0
+ private set
+
+ init {
+ fitsSystemWindows = true
+ }
+
+ constructor(context: Context) : this(context, null)
+
+ private fun onSystemInsetTopChange(height: Int) {
+ statusBarHeight = height
+ onStatusBarHeightChange.invoke { it(height) }
+ }
+
+ @Suppress("OverridingDeprecatedMember", "DEPRECATION")
+ override fun fitSystemWindows(insets: Rect): Boolean {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT_WATCH) {
+ if (rootView.systemUiVisibility and View.SYSTEM_UI_FLAG_FULLSCREEN != 0) {
+ onSystemInsetTopChange(0)
+ insets.bottom = 0
+ } else {
+ onSystemInsetTopChange(insets.top)
+ }
+ insets.top = 0
+ insets.left = 0
+ insets.right = 0
+ }
+ return super.fitSystemWindows(insets)
+ }
+
+ @TargetApi(Build.VERSION_CODES.KITKAT_WATCH)
+ override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
+ Log.d(LOG_TAG, "onApplyWindowInsets(insets = $insets)")
+
+ var bottom = insets.systemWindowInsetBottom
+ if (rootView.systemUiVisibility and View.SYSTEM_UI_FLAG_FULLSCREEN != 0) {
+ onSystemInsetTopChange(0)
+ bottom = 0
+ } else {
+ onSystemInsetTopChange(insets.systemWindowInsetTop)
+ }
+ return super.onApplyWindowInsets(insets.replaceSystemWindowInsets(0, 0, 0, bottom))
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/widget/NestedScrollingWebView.kt b/app/src/main/java/com/github/drunlin/webappbox/widget/NestedScrollingWebView.kt
new file mode 100644
index 0000000..76fc27e
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/widget/NestedScrollingWebView.kt
@@ -0,0 +1,126 @@
+package com.github.drunlin.webappbox.widget
+
+import android.content.Context
+import android.os.Build
+import android.support.v4.view.NestedScrollingChild
+import android.support.v4.view.NestedScrollingChildHelper
+import android.support.v4.view.ViewCompat
+import android.util.AttributeSet
+import android.view.MotionEvent
+import android.webkit.WebView
+
+class NestedScrollingWebView(context: Context, attar: AttributeSet) :
+ WebView(context, attar), NestedScrollingChild {
+
+ private val nestedScrollingHelper by lazy { NestedScrollingChildHelper(this) }
+
+ private var nestedScrollY = 0
+ private var onNestedScrolling = false
+
+ init {
+ isNestedScrollingEnabled = true
+ }
+
+ override fun onOverScrolled(scrollX: Int, scrollY: Int, clampedX: Boolean, clampedY: Boolean) {
+ super.onOverScrolled(scrollX, scrollY, clampedX, clampedY)
+
+ onNestedScrolling = true
+ }
+
+ override fun onTouchEvent(event: MotionEvent): Boolean {
+ when (event.action) {
+ MotionEvent.ACTION_DOWN -> startNestedScroll(event)
+ MotionEvent.ACTION_MOVE -> if (onNestedScrolling && nestedScroll(event)) return true
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> stopNestedScroll()
+ }
+ return super.onTouchEvent(event)
+ }
+
+ private fun startNestedScroll(event: MotionEvent) {
+ nestedScrollY = event.y.toInt()
+ onNestedScrolling = false
+ startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL)
+ }
+
+ private fun nestedScroll(event: MotionEvent): Boolean {
+ val y = event.y.toInt()
+ val dy = nestedScrollY - y
+ nestedScrollY = y
+ return dispatchNestedPreScroll(0, dy, null, null)
+ || !canScrollVertically(dy) && dispatchNestedScroll(0, 0, 0, dy, null)
+ }
+
+ override fun setNestedScrollingEnabled(enabled: Boolean) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
+ super.setNestedScrollingEnabled(enabled)
+ else
+ nestedScrollingHelper.isNestedScrollingEnabled = true
+ }
+
+ override fun isNestedScrollingEnabled(): Boolean {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
+ return super.isNestedScrollingEnabled()
+ else
+ return nestedScrollingHelper.isNestedScrollingEnabled
+ }
+
+ override fun startNestedScroll(axes: Int): Boolean {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
+ return super.startNestedScroll(axes)
+ else
+ return nestedScrollingHelper.startNestedScroll(axes)
+ }
+
+ override fun stopNestedScroll() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
+ super.stopNestedScroll()
+ else
+ nestedScrollingHelper.stopNestedScroll()
+ }
+
+ override fun hasNestedScrollingParent(): Boolean {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
+ return super.hasNestedScrollingParent()
+ else
+ return nestedScrollingHelper.hasNestedScrollingParent()
+ }
+
+ override fun dispatchNestedPreScroll(dx: Int, dy: Int, consumed: IntArray?,
+ offsetInWindow: IntArray?): Boolean {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
+ return super.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow)
+ else
+ return nestedScrollingHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow)
+ }
+
+ override fun dispatchNestedScroll(dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int,
+ dyUnconsumed: Int, offsetInWindow: IntArray?): Boolean {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
+ return super.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed,
+ dyUnconsumed, offsetInWindow)
+ else
+ return nestedScrollingHelper.dispatchNestedScroll(dxConsumed, dyConsumed,
+ dxUnconsumed, dyUnconsumed, offsetInWindow)
+ }
+
+ override fun dispatchNestedPreFling(velocityX: Float, velocityY: Float): Boolean {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
+ return super.dispatchNestedPreFling(velocityX, velocityY)
+ else
+ return nestedScrollingHelper.dispatchNestedPreFling(velocityX, velocityY)
+ }
+
+ override fun dispatchNestedFling(velocityX: Float, velocityY: Float, consumed: Boolean): Boolean {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
+ return super.dispatchNestedFling(velocityX, velocityY, consumed)
+ else
+ return nestedScrollingHelper.dispatchNestedFling(velocityX, velocityY, consumed)
+ }
+
+ override fun onDetachedFromWindow() {
+ super.onDetachedFromWindow()
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP)
+ nestedScrollingHelper.onDetachedFromWindow()
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/widget/PatternEditor.kt b/app/src/main/java/com/github/drunlin/webappbox/widget/PatternEditor.kt
new file mode 100644
index 0000000..dbecbf0
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/widget/PatternEditor.kt
@@ -0,0 +1,83 @@
+package com.github.drunlin.webappbox.widget
+
+import android.content.Context
+import android.util.AttributeSet
+import com.github.drunlin.webappbox.BR
+import com.github.drunlin.webappbox.R
+import com.github.drunlin.webappbox.common.isValidUrl
+import com.github.drunlin.webappbox.common.string
+import com.github.drunlin.webappbox.data.URLPattern
+import com.github.drunlin.webappbox.databinding.WidgetPatternBinding
+import com.jakewharton.rxbinding.widget.RxCompoundButton
+import com.jakewharton.rxbinding.widget.RxTextView
+import kotlinx.android.synthetic.main.text_input.view.*
+import kotlinx.android.synthetic.main.widget_pattern.view.*
+import rx.Observable
+import java.util.regex.Pattern
+import java.util.regex.PatternSyntaxException
+
+class PatternEditor(context: Context, attar: AttributeSet) : ViewStateLayout(context, attar) {
+ var isExisted: ((String, Boolean) -> Boolean)? = null
+ var onStateChange: ((Boolean) -> Unit)? = null
+
+ val value: String get() = textInput.edit.string
+ val regex: Boolean get() = checkbox.isChecked
+
+ var pattern: URLPattern? = null
+ set(value) {
+ field = value
+ binding.setVariable(BR.pattern, value)
+ binding.executePendingBindings()
+ }
+
+ private lateinit var binding: WidgetPatternBinding
+
+ init {
+ inflate(context, R.layout.widget_pattern, this)
+
+ if (!isInEditMode) init()
+ }
+
+ private fun init() {
+ binding = WidgetPatternBinding.bind(getChildAt(0))
+
+ val editObservable = RxTextView.textChanges(textInput.edit).map { it.toString().trim() }
+ val checkboxObservable = RxCompoundButton.checkedChanges(checkbox)
+ Observable.combineLatest(editObservable, checkboxObservable, { a, b -> arrayOf(a, b) })
+ .skip(1)
+ .map { onChange(it[0] as String, it[1] as Boolean) }
+ .subscribe { onStateChange?.invoke(it) }
+ }
+
+ fun requestValidate() {
+ onStateChange?.invoke(onChange(textInput.edit.string, checkbox.isChecked))
+ }
+
+ private fun onChange(value: String, regex: Boolean): Boolean {
+ if (value.isEmpty()) {
+ layout.isErrorEnabled = false
+ return false
+ }
+
+ if (regex) {
+ try {
+ Pattern.compile(value)
+ } catch (e: PatternSyntaxException) {
+ textInput.layout.error = context.getString(R.string.invalid_regex)
+ return false
+ }
+ } else if (!value.isValidUrl()) {
+ textInput.layout.error = context.getString(R.string.invalid_url)
+ return false
+ }
+
+ if ((pattern?.pattern != value || pattern?.regex != regex)
+ && isExisted?.invoke(value, regex) ?: false) {
+ textInput.layout.error = context.getString(R.string.exited_pattern)
+ return false
+ }
+
+ textInput.layout.isErrorEnabled = false
+ return true
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/widget/ScrollLinearLayout.kt b/app/src/main/java/com/github/drunlin/webappbox/widget/ScrollLinearLayout.kt
new file mode 100644
index 0000000..3b9105c
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/widget/ScrollLinearLayout.kt
@@ -0,0 +1,61 @@
+package com.github.drunlin.webappbox.widget
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
+import android.widget.LinearLayout
+import android.widget.ScrollView
+
+class ScrollLinearLayout(context: Context, attar: AttributeSet) : ScrollView(context, attar) {
+ private var inflating = true
+ private var addingView = false
+
+ private val container: LinearLayout
+
+ init {
+ container = LinearLayout(context)
+ container.orientation = LinearLayout.VERTICAL
+ super.addView(container, -1, ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT))
+ }
+
+ override fun getChildCount(): Int {
+ return if (addingView) 0 else if (inflating) container.childCount else super.getChildCount()
+ }
+
+ override fun getChildAt(index: Int): View? {
+ return if (inflating) container.getChildAt(index) else super.getChildAt(index)
+ }
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ inflating = false
+
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
+ }
+
+ override fun addView(child: View) {
+ addingView = true
+
+ super.addView(child)
+ }
+
+ override fun addView(child: View, index: Int) {
+ addingView = true
+
+ super.addView(child, index)
+ }
+
+ override fun addView(child: View, params: ViewGroup.LayoutParams) {
+ addingView = true
+
+ super.addView(child, params)
+ }
+
+ override fun addView(child: View, index: Int, params: ViewGroup.LayoutParams) {
+ container.addView(child, index, LinearLayout.LayoutParams(params as MarginLayoutParams))
+
+ addingView = false
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/widget/StatusBarBackground.kt b/app/src/main/java/com/github/drunlin/webappbox/widget/StatusBarBackground.kt
new file mode 100644
index 0000000..d2610e0
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/widget/StatusBarBackground.kt
@@ -0,0 +1,34 @@
+package com.github.drunlin.webappbox.widget
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.View
+import android.view.ViewParent
+
+class StatusBarBackground(context: Context, attar: AttributeSet) : View(context, attar) {
+ private lateinit var contentLayout: ContentLayout
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+
+ if (isInEditMode) return
+
+ contentLayout = findContentLayout()
+ contentLayout.onStatusBarHeightChange.add(this) { setHeight(it) }
+ setHeight(contentLayout.statusBarHeight)
+ }
+
+ tailrec fun findContentLayout(parent: ViewParent = getParent()): ContentLayout
+ = parent as? ContentLayout ?: findContentLayout(parent.parent)
+
+ private fun setHeight(height: Int) {
+ layoutParams.height = height
+ requestLayout()
+ }
+
+ override fun onDetachedFromWindow() {
+ super.onDetachedFromWindow()
+
+ contentLayout.onStatusBarHeightChange.remove(this)
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/widget/ViewStateLayout.kt b/app/src/main/java/com/github/drunlin/webappbox/widget/ViewStateLayout.kt
new file mode 100644
index 0000000..a715fff
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/widget/ViewStateLayout.kt
@@ -0,0 +1,38 @@
+package com.github.drunlin.webappbox.widget
+
+import android.content.Context
+import android.os.Bundle
+import android.os.Parcelable
+import android.util.AttributeSet
+import android.util.SparseArray
+import android.widget.FrameLayout
+import com.github.drunlin.webappbox.common.STATE_CHILDREN
+
+open class ViewStateLayout(context: Context, attar: AttributeSet) : FrameLayout(context, attar) {
+ override fun dispatchSaveInstanceState(container: SparseArray) {
+ if (id == NO_ID) {
+ super.dispatchSaveInstanceState(container)
+ return
+ }
+
+ val state = Bundle()
+ val array = SparseArray()
+ state.putSparseParcelableArray(STATE_CHILDREN, array)
+ container.put(id, state)
+
+ getSaveFromParentEnabledChildren().forEach { it.saveHierarchyState(array) }
+ }
+
+ private fun getSaveFromParentEnabledChildren()
+ = (0..childCount - 1).map { getChildAt(it) }.filter { it.isSaveFromParentEnabled }
+
+ override fun dispatchRestoreInstanceState(container: SparseArray) {
+ if (id == NO_ID) {
+ super.dispatchRestoreInstanceState(container)
+ return
+ }
+
+ val array = (container[id] as Bundle).getSparseParcelableArray(STATE_CHILDREN)
+ getSaveFromParentEnabledChildren().forEach { it.restoreHierarchyState(array) }
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/widget/adapter/ListAdapter.kt b/app/src/main/java/com/github/drunlin/webappbox/widget/adapter/ListAdapter.kt
new file mode 100644
index 0000000..5b1d82c
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/widget/adapter/ListAdapter.kt
@@ -0,0 +1,32 @@
+package com.github.drunlin.webappbox.widget.adapter
+
+import android.support.v7.widget.RecyclerView
+import android.view.View
+
+abstract class ListAdapter> : RecyclerView.Adapter() {
+ var list: List? = null
+ set(value) {
+ field = value
+ notifyDataSetChanged()
+ }
+
+ override fun getItemCount(): Int = list?.size ?: 0
+
+ override fun onBindViewHolder(holder: VH, position: Int) {
+ onBindViewHolder(holder, list!![position], position)
+ }
+
+ open fun onBindViewHolder(holder: VH, data: T, position: Int) {
+ holder.data = data
+ }
+
+ abstract class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ var data: T? = null
+ set(value) {
+ field = value
+ onBind(value!!)
+ }
+
+ abstract fun onBind(data: T)
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/widget/adapter/SpinnerAdapter.kt b/app/src/main/java/com/github/drunlin/webappbox/widget/adapter/SpinnerAdapter.kt
new file mode 100644
index 0000000..436b010
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/widget/adapter/SpinnerAdapter.kt
@@ -0,0 +1,50 @@
+package com.github.drunlin.webappbox.widget.adapter
+
+import android.content.Context
+import android.view.View
+import android.view.ViewGroup
+import android.widget.SimpleAdapter
+import android.widget.Spinner
+import com.github.drunlin.webappbox.R
+import java.util.*
+
+class SpinnerAdapter(context: Context, title: String,
+ data: MutableList> = LinkedList()) :
+ SimpleAdapter(context, data, R.layout.clickable_item, arrayOf(TITLE, SUMMARY, SUMMARY),
+ intArrayOf(R.id.titleText, R.id.summaryText, R.id.text)) {
+
+ companion object {
+ private val TITLE = "title"
+ private val SUMMARY = "summary"
+ }
+
+ var title: String = title
+ set(value) {
+ field = value
+ data.forEach { it[TITLE] = value }
+ notifyDataSetChanged()
+ }
+
+ var data: MutableList> = data
+ set(value) {
+ field.clear()
+ field.addAll(value)
+ notifyDataSetChanged()
+ }
+
+ init {
+ setDropDownViewResource(R.layout.clickable_text)
+ }
+
+ constructor(context: Context, title: String, collection: Iterable) : this(context, title) {
+ data = collection.map { item(it) }.toMutableList()
+ }
+
+ fun item(value: Any) = mutableMapOf(TITLE to title, SUMMARY to value)
+
+ override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
+ val view = super.getView(position, convertView, parent)
+ view.setOnClickListener { (view.parent as Spinner).performClick() }
+ return view
+ }
+}
diff --git a/app/src/main/java/com/github/drunlin/webappbox/widget/databinding/Binding.kt b/app/src/main/java/com/github/drunlin/webappbox/widget/databinding/Binding.kt
new file mode 100644
index 0000000..3d55760
--- /dev/null
+++ b/app/src/main/java/com/github/drunlin/webappbox/widget/databinding/Binding.kt
@@ -0,0 +1,29 @@
+package com.github.drunlin.webappbox.widget.databinding
+
+import android.databinding.BindingAdapter
+import android.databinding.BindingMethod
+import android.databinding.BindingMethods
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.view.View
+import android.widget.ImageView
+
+@BindingMethods(
+ BindingMethod(type = ImageView::class, attribute = "android:src", method = "setImageBitmap")
+)
+class MethodBinding
+
+@BindingAdapter("app:src")
+fun setImage(view: ImageView, value: T) {
+ val bitmap = when (value) {
+ is Bitmap -> value
+ is Int -> BitmapFactory.decodeResource(view.resources, value)
+ else -> throw UnsupportedOperationException()
+ }
+ view.setImageBitmap(bitmap)
+}
+
+@BindingAdapter("app:selected")
+fun setSelected(view: View, selected: Boolean) {
+ view.isSelected = selected
+}
diff --git a/app/src/main/res/drawable-hdpi/ic_add_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_add_white_24dp.png
new file mode 100644
index 0000000..694179b
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_add_white_24dp.png differ
diff --git a/app/src/main/res/drawable-hdpi/ic_close_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_close_black_24dp.png
new file mode 100644
index 0000000..1a9cd75
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_close_black_24dp.png differ
diff --git a/app/src/main/res/drawable-hdpi/ic_delete_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_delete_black_24dp.png
new file mode 100644
index 0000000..dbbb602
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_delete_black_24dp.png differ
diff --git a/app/src/main/res/drawable-hdpi/ic_delete_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_delete_white_24dp.png
new file mode 100644
index 0000000..4a9f769
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_delete_white_24dp.png differ
diff --git a/app/src/main/res/drawable-hdpi/ic_done_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_done_white_24dp.png
new file mode 100644
index 0000000..c278b6c
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_done_white_24dp.png differ
diff --git a/app/src/main/res/drawable-hdpi/ic_done_white_24dp_disable.png b/app/src/main/res/drawable-hdpi/ic_done_white_24dp_disable.png
new file mode 100644
index 0000000..31f4852
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_done_white_24dp_disable.png differ
diff --git a/app/src/main/res/drawable-hdpi/ic_reorder_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_reorder_black_24dp.png
new file mode 100644
index 0000000..142d715
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_reorder_black_24dp.png differ
diff --git a/app/src/main/res/drawable-hdpi/ic_search_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_search_white_24dp.png
new file mode 100644
index 0000000..bbfbc96
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_search_white_24dp.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_add_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_add_white_24dp.png
new file mode 100644
index 0000000..3856041
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_add_white_24dp.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_close_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_close_black_24dp.png
new file mode 100644
index 0000000..40a1a84
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_close_black_24dp.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_delete_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_delete_black_24dp.png
new file mode 100644
index 0000000..999aa4c
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_delete_black_24dp.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_delete_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_delete_white_24dp.png
new file mode 100644
index 0000000..e2f5f35
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_delete_white_24dp.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_done_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_done_white_24dp.png
new file mode 100644
index 0000000..6d84e14
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_done_white_24dp.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_done_white_24dp_disable.png b/app/src/main/res/drawable-mdpi/ic_done_white_24dp_disable.png
new file mode 100644
index 0000000..0da4430
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_done_white_24dp_disable.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_reorder_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_reorder_black_24dp.png
new file mode 100644
index 0000000..d18997c
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_reorder_black_24dp.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_search_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_search_white_24dp.png
new file mode 100644
index 0000000..faefc59
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_search_white_24dp.png differ
diff --git a/app/src/main/res/drawable-v21/bg_selectable_item.xml b/app/src/main/res/drawable-v21/bg_selectable_item.xml
new file mode 100644
index 0000000..2d21b05
--- /dev/null
+++ b/app/src/main/res/drawable-v21/bg_selectable_item.xml
@@ -0,0 +1,13 @@
+
+
+ -
+
+
-
+
+
+
+
+ -
+
+
+
diff --git a/app/src/main/res/drawable-xhdpi/ic_add_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_add_white_24dp.png
new file mode 100644
index 0000000..67bb598
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_add_white_24dp.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_close_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_close_black_24dp.png
new file mode 100644
index 0000000..6bc4372
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_close_black_24dp.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_delete_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_delete_black_24dp.png
new file mode 100644
index 0000000..796ccd2
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_delete_black_24dp.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_delete_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_delete_white_24dp.png
new file mode 100644
index 0000000..388b5b0
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_delete_white_24dp.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_done_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_done_white_24dp.png
new file mode 100644
index 0000000..3b2b65d
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_done_white_24dp.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_done_white_24dp_disable.png b/app/src/main/res/drawable-xhdpi/ic_done_white_24dp_disable.png
new file mode 100644
index 0000000..d73498a
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_done_white_24dp_disable.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_reorder_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_reorder_black_24dp.png
new file mode 100644
index 0000000..0b080a1
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_reorder_black_24dp.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_search_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_search_white_24dp.png
new file mode 100644
index 0000000..bfc3e39
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_search_white_24dp.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_add_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_add_white_24dp.png
new file mode 100644
index 0000000..0fdced8
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_add_white_24dp.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_close_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_close_black_24dp.png
new file mode 100644
index 0000000..51b4401
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_close_black_24dp.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_delete_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_delete_black_24dp.png
new file mode 100644
index 0000000..6d7cb81
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_delete_black_24dp.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_delete_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_delete_white_24dp.png
new file mode 100644
index 0000000..3fcdfdb
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_delete_white_24dp.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_done_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_done_white_24dp.png
new file mode 100644
index 0000000..0ebb555
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_done_white_24dp.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_done_white_24dp_disable.png b/app/src/main/res/drawable-xxhdpi/ic_done_white_24dp_disable.png
new file mode 100644
index 0000000..ec2981d
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_done_white_24dp_disable.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_reorder_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_reorder_black_24dp.png
new file mode 100644
index 0000000..0a66529
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_reorder_black_24dp.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_search_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_search_white_24dp.png
new file mode 100644
index 0000000..abbb989
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_search_white_24dp.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_add_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_add_white_24dp.png
new file mode 100644
index 0000000..d64c22e
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_add_white_24dp.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_close_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_close_black_24dp.png
new file mode 100644
index 0000000..df42fee
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_close_black_24dp.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_delete_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_delete_black_24dp.png
new file mode 100644
index 0000000..f2b75c3
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_delete_black_24dp.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_delete_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_delete_white_24dp.png
new file mode 100644
index 0000000..8d322aa
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_delete_white_24dp.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_done_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_done_white_24dp.png
new file mode 100644
index 0000000..d670618
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_done_white_24dp.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_done_white_24dp_disable.png b/app/src/main/res/drawable-xxxhdpi/ic_done_white_24dp_disable.png
new file mode 100644
index 0000000..71f9500
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_done_white_24dp_disable.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_reorder_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_reorder_black_24dp.png
new file mode 100644
index 0000000..56a5bc8
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_reorder_black_24dp.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_search_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_search_white_24dp.png
new file mode 100644
index 0000000..dd5adfc
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_search_white_24dp.png differ
diff --git a/app/src/main/res/drawable/bg_selectable_item.xml b/app/src/main/res/drawable/bg_selectable_item.xml
new file mode 100644
index 0000000..01d5db4
--- /dev/null
+++ b/app/src/main/res/drawable/bg_selectable_item.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/btn_done.xml b/app/src/main/res/drawable/btn_done.xml
new file mode 100644
index 0000000..39b6253
--- /dev/null
+++ b/app/src/main/res/drawable/btn_done.xml
@@ -0,0 +1,7 @@
+
+
+
+ -
+
+
+
diff --git a/app/src/main/res/drawable/fg_app_icon.xml b/app/src/main/res/drawable/fg_app_icon.xml
new file mode 100644
index 0000000..8d07ab6
--- /dev/null
+++ b/app/src/main/res/drawable/fg_app_icon.xml
@@ -0,0 +1,6 @@
+
+
+ -
+
+
+
diff --git a/app/src/main/res/drawable/shadow_top.xml b/app/src/main/res/drawable/shadow_top.xml
new file mode 100644
index 0000000..623fab4
--- /dev/null
+++ b/app/src/main/res/drawable/shadow_top.xml
@@ -0,0 +1,7 @@
+
+
+
diff --git a/app/src/main/res/layout-v17/preference_category.xml b/app/src/main/res/layout-v17/preference_category.xml
new file mode 100644
index 0000000..c5b3d66
--- /dev/null
+++ b/app/src/main/res/layout-v17/preference_category.xml
@@ -0,0 +1,11 @@
+
+
diff --git a/app/src/main/res/layout-v19/app_bar.xml b/app/src/main/res/layout-v19/app_bar.xml
new file mode 100644
index 0000000..5d66f2c
--- /dev/null
+++ b/app/src/main/res/layout-v19/app_bar.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout-v19/status_bar.xml b/app/src/main/res/layout-v19/status_bar.xml
new file mode 100644
index 0000000..53a92d1
--- /dev/null
+++ b/app/src/main/res/layout-v19/status_bar.xml
@@ -0,0 +1,7 @@
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..45ded87
--- /dev/null
+++ b/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/app_bar.xml b/app/src/main/res/layout/app_bar.xml
new file mode 100644
index 0000000..b4f9b5f
--- /dev/null
+++ b/app/src/main/res/layout/app_bar.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/layout/checkable_item.xml b/app/src/main/res/layout/checkable_item.xml
new file mode 100644
index 0000000..5343880
--- /dev/null
+++ b/app/src/main/res/layout/checkable_item.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/clickable_item.xml b/app/src/main/res/layout/clickable_item.xml
new file mode 100644
index 0000000..226599d
--- /dev/null
+++ b/app/src/main/res/layout/clickable_item.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/clickable_text.xml b/app/src/main/res/layout/clickable_text.xml
new file mode 100644
index 0000000..ed49feb
--- /dev/null
+++ b/app/src/main/res/layout/clickable_text.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/dialog_progress.xml b/app/src/main/res/layout/dialog_progress.xml
new file mode 100644
index 0000000..8ceebda
--- /dev/null
+++ b/app/src/main/res/layout/dialog_progress.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/editable_list_content.xml b/app/src/main/res/layout/editable_list_content.xml
new file mode 100644
index 0000000..b4b0e61
--- /dev/null
+++ b/app/src/main/res/layout/editable_list_content.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_about.xml b/app/src/main/res/layout/fragment_about.xml
new file mode 100644
index 0000000..5fa5229
--- /dev/null
+++ b/app/src/main/res/layout/fragment_about.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_pattern_editor.xml b/app/src/main/res/layout/fragment_pattern_editor.xml
new file mode 100644
index 0000000..b7a1291
--- /dev/null
+++ b/app/src/main/res/layout/fragment_pattern_editor.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_preview.xml b/app/src/main/res/layout/fragment_preview.xml
new file mode 100644
index 0000000..d46506b
--- /dev/null
+++ b/app/src/main/res/layout/fragment_preview.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_rule_editor.xml b/app/src/main/res/layout/fragment_rule_editor.xml
new file mode 100644
index 0000000..dfc4b2b
--- /dev/null
+++ b/app/src/main/res/layout/fragment_rule_editor.xml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml
new file mode 100644
index 0000000..5255cf9
--- /dev/null
+++ b/app/src/main/res/layout/fragment_settings.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_toolbar.xml b/app/src/main/res/layout/fragment_toolbar.xml
new file mode 100644
index 0000000..1e8e946
--- /dev/null
+++ b/app/src/main/res/layout/fragment_toolbar.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_user_agnet_editor.xml b/app/src/main/res/layout/fragment_user_agnet_editor.xml
new file mode 100644
index 0000000..5f0636b
--- /dev/null
+++ b/app/src/main/res/layout/fragment_user_agnet_editor.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_webapp_editor.xml b/app/src/main/res/layout/fragment_webapp_editor.xml
new file mode 100644
index 0000000..9340941
--- /dev/null
+++ b/app/src/main/res/layout/fragment_webapp_editor.xml
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_webapp_window.xml b/app/src/main/res/layout/fragment_webapp_window.xml
new file mode 100644
index 0000000..a3c35a5
--- /dev/null
+++ b/app/src/main/res/layout/fragment_webapp_window.xml
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_main.xml b/app/src/main/res/layout/item_main.xml
new file mode 100644
index 0000000..e19a5e8
--- /dev/null
+++ b/app/src/main/res/layout/item_main.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_pattern.xml b/app/src/main/res/layout/item_pattern.xml
new file mode 100644
index 0000000..c648a8d
--- /dev/null
+++ b/app/src/main/res/layout/item_pattern.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_rule.xml b/app/src/main/res/layout/item_rule.xml
new file mode 100644
index 0000000..d58e8eb
--- /dev/null
+++ b/app/src/main/res/layout/item_rule.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_user_agent.xml b/app/src/main/res/layout/item_user_agent.xml
new file mode 100644
index 0000000..ba0fba9
--- /dev/null
+++ b/app/src/main/res/layout/item_user_agent.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/list_content.xml b/app/src/main/res/layout/list_content.xml
new file mode 100644
index 0000000..f4f8dec
--- /dev/null
+++ b/app/src/main/res/layout/list_content.xml
@@ -0,0 +1,10 @@
+
+
diff --git a/app/src/main/res/layout/preference_category.xml b/app/src/main/res/layout/preference_category.xml
new file mode 100644
index 0000000..2eed365
--- /dev/null
+++ b/app/src/main/res/layout/preference_category.xml
@@ -0,0 +1,11 @@
+
+
diff --git a/app/src/main/res/layout/status_bar.xml b/app/src/main/res/layout/status_bar.xml
new file mode 100644
index 0000000..dc2851c
--- /dev/null
+++ b/app/src/main/res/layout/status_bar.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/app/src/main/res/layout/text_content.xml b/app/src/main/res/layout/text_content.xml
new file mode 100644
index 0000000..152eab2
--- /dev/null
+++ b/app/src/main/res/layout/text_content.xml
@@ -0,0 +1,17 @@
+
+
+
+
diff --git a/app/src/main/res/layout/text_input.xml b/app/src/main/res/layout/text_input.xml
new file mode 100644
index 0000000..3242ea0
--- /dev/null
+++ b/app/src/main/res/layout/text_input.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/toolbar.xml b/app/src/main/res/layout/toolbar.xml
new file mode 100644
index 0000000..71ab8a4
--- /dev/null
+++ b/app/src/main/res/layout/toolbar.xml
@@ -0,0 +1,11 @@
+
+
diff --git a/app/src/main/res/layout/widget_pattern.xml b/app/src/main/res/layout/widget_pattern.xml
new file mode 100644
index 0000000..48e7950
--- /dev/null
+++ b/app/src/main/res/layout/widget_pattern.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/menu/activity_main.xml b/app/src/main/res/menu/activity_main.xml
new file mode 100644
index 0000000..0489de3
--- /dev/null
+++ b/app/src/main/res/menu/activity_main.xml
@@ -0,0 +1,20 @@
+
diff --git a/app/src/main/res/menu/fragment_editor.xml b/app/src/main/res/menu/fragment_editor.xml
new file mode 100644
index 0000000..6e0dc47
--- /dev/null
+++ b/app/src/main/res/menu/fragment_editor.xml
@@ -0,0 +1,8 @@
+
diff --git a/app/src/main/res/menu/fragment_priview.xml b/app/src/main/res/menu/fragment_priview.xml
new file mode 100644
index 0000000..e3c04e3
--- /dev/null
+++ b/app/src/main/res/menu/fragment_priview.xml
@@ -0,0 +1,24 @@
+
+
diff --git a/app/src/main/res/menu/item_webapp.xml b/app/src/main/res/menu/item_webapp.xml
new file mode 100644
index 0000000..d44c9f5
--- /dev/null
+++ b/app/src/main/res/menu/item_webapp.xml
@@ -0,0 +1,12 @@
+
+
diff --git a/app/src/main/res/menu/list_action_mode.xml b/app/src/main/res/menu/list_action_mode.xml
new file mode 100644
index 0000000..76b8c04
--- /dev/null
+++ b/app/src/main/res/menu/list_action_mode.xml
@@ -0,0 +1,9 @@
+
+
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..5fff696
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_webapp.png b/app/src/main/res/mipmap-hdpi/ic_webapp.png
new file mode 100644
index 0000000..42d56f2
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_webapp.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..0035adb
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_webapp.png b/app/src/main/res/mipmap-mdpi/ic_webapp.png
new file mode 100644
index 0000000..1313362
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_webapp.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..447e238
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_webapp.png b/app/src/main/res/mipmap-xhdpi/ic_webapp.png
new file mode 100644
index 0000000..7a78284
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_webapp.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..cfb3854
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_webapp.png b/app/src/main/res/mipmap-xxhdpi/ic_webapp.png
new file mode 100644
index 0000000..caf3af2
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_webapp.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..baaddf4
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_webapp.png b/app/src/main/res/mipmap-xxxhdpi/ic_webapp.png
new file mode 100644
index 0000000..e775143
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_webapp.png differ
diff --git a/app/src/main/res/raw-zh/manual.html b/app/src/main/res/raw-zh/manual.html
new file mode 100644
index 0000000..893762d
--- /dev/null
+++ b/app/src/main/res/raw-zh/manual.html
@@ -0,0 +1,13 @@
+启动模式
+
+ 标准模式
+ 在当前窗口中打开所有链接。点击返回按钮返回前一个浏览页面(如果存在)。
+ 新建窗口
+ 总是创建新的窗口。
+ 移除顶部
+ 只会在第一次需要的时候被创建,其它时候则会移除它上面的所有窗口。
+ 保持顶部
+ 只有当该类型的窗口没有在顶部时,才会创建新的窗口。
+ 全局唯一
+ 只会在第一次需要的时候被创建,其它时候则把它移动到顶部。
+
diff --git a/app/src/main/res/raw/licenses.txt b/app/src/main/res/raw/licenses.txt
new file mode 100644
index 0000000..71ba92b
--- /dev/null
+++ b/app/src/main/res/raw/licenses.txt
@@ -0,0 +1,418 @@
+com.android.support:support-annotations
+com.android.support:appcompat-v7
+com.android.support:design
+com.android.support:cardview-v7
+com.android.support:preference-v7
+com.android.support:preference-v14
+com.android.support.test:runner
+com.android.support.test:rules
+
+Copyright (C) 2011 The Android Open Source Project
+
+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.
+
+
+
+org.jetbrains.kotlin
+
+Copyright 2010-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.
+
+
+
+com.thebluealliance:spectrum
+
+The MIT License (MIT)
+
+Copyright (c) 2016 The Blue Alliance
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+
+
+com.github.rahatarmanahmed:circularprogressview
+
+The MIT License (MIT)
+
+Copyright (c) 2015 Rahat Ahmed
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
+com.google.dagger:dagger
+
+Copyright 2012 The Dagger Authors
+
+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.
+
+
+
+io.reactivex:rxjava
+
+Copyright 2013 Netflix, Inc.
+
+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.
+
+
+
+io.reactivex:rxandroid
+
+Copyright 2015 The RxAndroid authors
+
+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.
+
+
+
+com.jakewharton.rxbinding:rxbinding-design
+com.jakewharton.rxbinding:rxbinding
+com.jakewharton.rxbinding:rxbinding-appcompat-v7
+
+Copyright (C) 2015 Jake Wharton
+
+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.
+
+
+
+org.jsoup:jsoup
+
+The MIT License
+
+© 2009-2016, Jonathan Hedley
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+
+junit:junit
+
+JUnit
+
+Eclipse Public License - v 1.0
+
+THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE PUBLIC
+LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION OF THE PROGRAM
+CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.
+
+1. DEFINITIONS
+
+"Contribution" means:
+
+ a) in the case of the initial Contributor, the initial code and
+ documentation distributed under this Agreement, and
+ b) in the case of each subsequent Contributor:
+
+ i) changes to the Program, and
+
+ ii) additions to the Program;
+
+ where such changes and/or additions to the Program originate from and are
+distributed by that particular Contributor. A Contribution 'originates' from a
+Contributor if it was added to the Program by such Contributor itself or anyone
+acting on such Contributor's behalf. Contributions do not include additions to
+the Program which: (i) are separate modules of software distributed in
+conjunction with the Program under their own license agreement, and (ii) are
+not derivative works of the Program.
+
+"Contributor" means any person or entity that distributes the Program.
+
+"Licensed Patents " mean patent claims licensable by a Contributor which are
+necessarily infringed by the use or sale of its Contribution alone or when
+combined with the Program.
+
+"Program" means the Contributions distributed in accordance with this Agreement.
+
+"Recipient" means anyone who receives the Program under this Agreement,
+including all Contributors.
+
+2. GRANT OF RIGHTS
+
+ a) Subject to the terms of this Agreement, each Contributor hereby grants
+Recipient a non-exclusive, worldwide, royalty-free copyright license to
+reproduce, prepare derivative works of, publicly display, publicly perform,
+distribute and sublicense the Contribution of such Contributor, if any, and
+such derivative works, in source code and object code form.
+
+ b) Subject to the terms of this Agreement, each Contributor hereby grants
+Recipient a non-exclusive, worldwide, royalty-free patent license under
+Licensed Patents to make, use, sell, offer to sell, import and otherwise
+transfer the Contribution of such Contributor, if any, in source code and
+object code form. This patent license shall apply to the combination of the
+Contribution and the Program if, at the time the Contribution is added by the
+Contributor, such addition of the Contribution causes such combination to be
+covered by the Licensed Patents. The patent license shall not apply to any
+other combinations which include the Contribution. No hardware per se is
+licensed hereunder.
+
+ c) Recipient understands that although each Contributor grants the
+licenses to its Contributions set forth herein, no assurances are provided by
+any Contributor that the Program does not infringe the patent or other
+intellectual property rights of any other entity. Each Contributor disclaims
+any liability to Recipient for claims brought by any other entity based on
+infringement of intellectual property rights or otherwise. As a condition to
+exercising the rights and licenses granted hereunder, each Recipient hereby
+assumes sole responsibility to secure any other intellectual property rights
+needed, if any. For example, if a third party patent license is required to
+allow Recipient to distribute the Program, it is Recipient's responsibility to
+acquire that license before distributing the Program.
+
+ d) Each Contributor represents that to its knowledge it has sufficient
+copyright rights in its Contribution, if any, to grant the copyright license
+set forth in this Agreement.
+
+3. REQUIREMENTS
+
+A Contributor may choose to distribute the Program in object code form under
+its own license agreement, provided that:
+
+ a) it complies with the terms and conditions of this Agreement; and
+
+ b) its license agreement:
+
+ i) effectively disclaims on behalf of all Contributors all warranties and
+conditions, express and implied, including warranties or conditions of title
+and non-infringement, and implied warranties or conditions of merchantability
+and fitness for a particular purpose;
+
+ ii) effectively excludes on behalf of all Contributors all liability for
+damages, including direct, indirect, special, incidental and consequential
+damages, such as lost profits;
+
+ iii) states that any provisions which differ from this Agreement are
+offered by that Contributor alone and not by any other party; and
+
+ iv) states that source code for the Program is available from such
+Contributor, and informs licensees how to obtain it in a reasonable manner on
+or through a medium customarily used for software exchange.
+
+When the Program is made available in source code form:
+
+ a) it must be made available under this Agreement; and
+
+ b) a copy of this Agreement must be included with each copy of the
+Program.
+
+Contributors may not remove or alter any copyright notices contained within the
+Program.
+
+Each Contributor must identify itself as the originator of its Contribution, if
+any, in a manner that reasonably allows subsequent Recipients to identify the
+originator of the Contribution.
+
+4. COMMERCIAL DISTRIBUTION
+
+Commercial distributors of software may accept certain responsibilities with
+respect to end users, business partners and the like. While this license is
+intended to facilitate the commercial use of the Program, the Contributor who
+includes the Program in a commercial product offering should do so in a manner
+which does not create potential liability for other Contributors. Therefore, if
+a Contributor includes the Program in a commercial product offering, such
+Contributor ("Commercial Contributor") hereby agrees to defend and indemnify
+every other Contributor ("Indemnified Contributor") against any losses, damages
+and costs (collectively "Losses") arising from claims, lawsuits and other legal
+actions brought by a third party against the Indemnified Contributor to the
+extent caused by the acts or omissions of such Commercial Contributor in
+connection with its distribution of the Program in a commercial product
+offering. The obligations in this section do not apply to any claims or Losses
+relating to any actual or alleged intellectual property infringement. In order
+to qualify, an Indemnified Contributor must: a) promptly notify the Commercial
+Contributor in writing of such claim, and b) allow the Commercial Contributor
+to control, and cooperate with the Commercial Contributor in, the defense and
+any related settlement negotiations. The Indemnified Contributor may
+participate in any such claim at its own expense.
+
+For example, a Contributor might include the Program in a commercial product
+offering, Product X. That Contributor is then a Commercial Contributor. If that
+Commercial Contributor then makes performance claims, or offers warranties
+related to Product X, those performance claims and warranties are such
+Commercial Contributor's responsibility alone. Under this section, the
+Commercial Contributor would have to defend claims against the other
+Contributors related to those performance claims and warranties, and if a court
+requires any other Contributor to pay any damages as a result, the Commercial
+Contributor must pay those damages.
+
+5. NO WARRANTY
+
+EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS PROVIDED ON AN
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR
+IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE,
+NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Each
+Recipient is solely responsible for determining the appropriateness of using
+and distributing the Program and assumes all risks associated with its exercise
+of rights under this Agreement, including but not limited to the risks and
+costs of program errors, compliance with applicable laws, damage to or loss of
+data, programs or equipment, and unavailability or interruption of operations.
+
+6. DISCLAIMER OF LIABILITY
+
+EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT NOR ANY
+CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST
+PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY
+WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE EXERCISE OF ANY RIGHTS
+GRANTED HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+
+7. GENERAL
+
+If any provision of this Agreement is invalid or unenforceable under applicable
+law, it shall not affect the validity or enforceability of the remainder of the
+terms of this Agreement, and without further action by the parties hereto, such
+provision shall be reformed to the minimum extent necessary to make such
+provision valid and enforceable.
+
+If Recipient institutes patent litigation against any
+entity (including a cross-claim or counterclaim in a lawsuit) alleging that the
+Program itself (excluding combinations of the Program with other software or
+hardware) infringes such Recipient's patent(s), then such Recipient's rights
+granted under Section 2(b) shall terminate as of the date such litigation is
+filed.
+
+All Recipient's rights under this Agreement shall terminate if it fails to
+comply with any of the material terms or conditions of this Agreement and does
+not cure such failure in a reasonable period of time after becoming aware of
+such noncompliance. If all Recipient's rights under this Agreement terminate,
+Recipient agrees to cease use and distribution of the Program as soon as
+reasonably practicable. However, Recipient's obligations under this Agreement
+and any licenses granted by Recipient relating to the Program shall continue
+and survive.
+
+Everyone is permitted to copy and distribute copies of this Agreement, but in
+order to avoid inconsistency the Agreement is copyrighted and may only be
+modified in the following manner. The Agreement Steward reserves the right to
+publish new versions (including revisions) of this Agreement from time to time.
+No one other than the Agreement Steward has the right to modify this Agreement.
+The Eclipse Foundation is the initial Agreement Steward. The Eclipse Foundation may assign the responsibility to
+serve as the Agreement Steward to a suitable separate entity. Each new version
+of the Agreement will be given a distinguishing version number. The Program
+(including Contributions) may always be distributed subject to the version of
+the Agreement under which it was received. In addition, after a new version of
+the Agreement is published, Contributor may elect to distribute the Program
+(including its Contributions) under the new version. Except as expressly stated
+in Sections 2(a) and 2(b) above, Recipient receives no rights or licenses to
+the intellectual property of any Contributor under this Agreement, whether
+expressly, by implication, estoppel or otherwise. All rights in the Program not
+expressly granted under this Agreement are reserved.
+
+This Agreement is governed by the laws of the State of New York and the
+intellectual property laws of the United States of America. No party to this
+Agreement will bring a legal action under this Agreement more than one year
+after the cause of action arose. Each party waives its rights to a jury trial
+in any resulting litigation.
+
+
+
+org.robolectric:robolectric
+
+The MIT License
+
+Copyright (c) 2010 Xtreme Labs, Pivotal Labs and Google Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
\ No newline at end of file
diff --git a/app/src/main/res/raw/manual.html b/app/src/main/res/raw/manual.html
new file mode 100644
index 0000000..ac105c7
--- /dev/null
+++ b/app/src/main/res/raw/manual.html
@@ -0,0 +1,13 @@
+Launch modes
+
+ Standard
+ Open the link in the current window. Press the Back key to go to the previous page.
+ New window
+ Always create a new window.
+ Clear top
+ If a window has been created, then instead of creating a new window, all of the other windows on top of it are removed.
+ Single top
+ A new window is created only if the window is not at the top.
+ Single task
+ Will only be created the first time when needed
+
diff --git a/app/src/main/res/raw/setup.sql b/app/src/main/res/raw/setup.sql
new file mode 100644
index 0000000..0ed26f2
--- /dev/null
+++ b/app/src/main/res/raw/setup.sql
@@ -0,0 +1,10 @@
+INSERT INTO "app" VALUES(1,'c20acded6aae47deb8e41bd70c8c14b9','http://browserquest.mozilla.org/','BrowserQuest',X'89504E470D0A1A0A0000000D4948445200000090000000900806000000E746E2B80000000473424954080808087C0864880000030849444154789CEDDD318A14411886E15F118C4CCC3C806226A8302CEC0136DA405826154F62EA25D4743133F200820CE805D4C0CCCCC40368605CD56B7D5B3B3DFA3C6933353DCD4BC3DF3D3D53050000000000000000000000C0DFBB366BE1CD66F36BC6BABBDD6EDA3EAFC58C6337EBB85D9FB128FF0F011111101101111110110111B9B18F377DFDE64373DBED5BFDD79E9E1C3547DC4319F197C6F4B7EFDAC7E7C7CFF6EB9E9E1D0DEFD328672022022222202202222220220222B29731BE37AAF7C6D4AAFE88BBA611BF37AAF73E4355FF182C5DE6B86ACE4044044444404404444440440444444044663E95F1A8B3F9636BC3AC6B24A727EDAF3A8C5E231ABDD6B374AD6BF47354D5E3D686DD6EF7A9FFAE639C8188088888808808888880880888C85E9E62181DF1ABC6C7E36034BED27DB9C0FE5CF9A8DEE30C444440440444444044044444404456F763040B237ED5E09DFCD11F25D8D36583558DEA3DCE4044044444404404444440440444647563FC92E44E7ECB1E46F5E6985EB5BE51BDC71988888088088888808808888880881CDC18DF33E359F5195FC63F94BF64B808672022022222202202222220220222B2AF67E387FF92604DA3FAF317AF9ADB1E3EB83FB46655FF1824C76E0667202202222220220222222022022232ED1F0B7BE3E6F9F979F375DBEDB6F9BAAAAAF72FEF34B77DBDE2BBEABD51BD77D9A0F719AAAA8E9F5DFEB19B35E23B0311111011011111101101111110916963FC2C9FBF7C6B6EBB77F74973DBF1D9F7DEB2DD67D55B4E4F8E9ACFE2F746F5DE67F8E3E6C8EEEC85331011011111101101111110110111111091D53D95D1FBBA4255D576BB1D7DDB4BFFFB8019BFD758B5F8958DE6364F657070044444404404444440440444648D4F6574D75D1AF33BEBF6C6EAD1F1B7B9E6E87E56F58F81A732F8A7088888808808888880880888C8B4BBB7BD317EC1D21312A377B90FE66E7CF58FC1D0BAC678564940440444444044044444404466FEB8C2D00F162C8DD49BCD66CABA97BDE6E87ECE5C770667202202222220220222222022020200000000000000807FC76FD939D87FB6B8B4A40000000049454E44AE426082','ASK');
+INSERT INTO "app" VALUES(2,'a5c0ec0c29c64054885dbe80b27801ae','https://regex101.com/','Regex101',X'89504E470D0A1A0A0000000D4948445200000090000000900806000000E746E2B80000000473424954080808087C0864880000087C49444154789CEDDD4F6C14D701C7F1DF9BD97F78D7F6AE59DBEBBF14D30A30C6E02682AA09A14158511B45FD73680E515BF5D25B5A55BD3552A34AA9A256BDF552553DA645546D53AA360D4569C16D64120821C65027040C366B6CB318EFAEF7FFCEBC1E5CDA34CCF8CFBEF50033BFCF0109CD2C8CE1ABB76FD6F39E01222222222222222222222222222222227A800895170FBF7042D6EB42E8FE39F1A3E19A3BD0EA7921E43D0C8894302052C28048090322250C8894302052C28048090322250C8894302052C28048090322250C8894302052C28048090322250C8894302052C28048090322250C8894302052C2804809032225BEFB7D01F56498F55D282BFEF38B8080505AC3EB5EAE0A68FFF63874ADF6FF6929010909C390C8162A28960DE44B060AE52A7245031292317D8C6B029252E2F967B62314A8FD4B929080044C09942A064A6503B95215E95C0563937790BC9D47F2761E0BD932F2E52A63829B0202108B04110AE81BF2E73FBEABEDFF7E3F359FC3BB571770FECA02266E6490CA96D476AA7848B92620A7F5B685D1D31AC61303EDB89CCCE08747C650AA18D03C3624F12E4C8110402C12C0BEED717CF5F12D883705213DB6E18D6746A0AA61AEE92EEDEEBC46D7C5BA46936F1CDE86E79EECC3DBEFA7F0835F9DF7CC48E489800C53E2E4D82CDEF97061C5F38410F0EB0241BF86CECD0D688904D1D214444B2480CECD0DABFE3D3E5D60FF8E385A2201DC592A437820224F04649A12EFDFC8E0C4F99B6B3A5F00F0E91A023E0D91900FF1E6109EFA7427767437A1BB358C80CFFE9D5FD7040EEFEDC0ABA3D375FF5CEA41E4898080E5D1653D6F2B8629912F2DDFC6CF2E163139BB848E964DF8CC8E380EEE4EA02F11B17DEDE71FEDC2C5A9342E4DA55D7F9BCF49F40A84003421A06B02C58A81C9B9251C39750DDFFAD969FCF2F865E48A55CBD7F5B486F1FD670730B42D06D3E5A31003AA812680D7CE26F1CF4BF3B681C49B42D8DBD78280DFDDFFC4EEFEEA3650265FC1B1D1695C9EC95A1ED735816D1D8D8884FC0E5F99B318508D8410B83ABB8453176651AA1896E7B447438845020E5F99B318508D049627DAAF9D9DC15B1329CB737AE2610C6E8D397B610E63400A84000AE52A26E7962C8FEBBA40BC29E8F055398B0129324C89D93B05DBE38D0D9C03D12A32F98AEDB1A07F639E0E785030A03AC897AD27D100A0F383445A4DC0675F49B96A3A7825CE634075100EDACF73B205EB4FABDD8201291210686DB6BED3324D89DB99A2C357E42C06A4C09412EDB1100E0CB45B1EBF91CAE3DDAB771CBE2A673120459FEA6C4297CDB342738B052C644B0E5F91B318508D4C29110D07F0C4AE3644C3F77EBBC230253E9CC9225BB0BFC57703CF3C0F546F5B5AC3F8DE57FAD1DF1BBDE79829254E9C9BC1D191EBA854A5AB9F09E208B44ECB8B0F81AF1DEAC3F6EE66CB736EDCCAE3F57766902B555D1D0FC011685D4C0984021A1EF9E4661C1868B75D05FBC7B7A63191CCB83E1E8001D9BABBCCF9AE905F4777BC01870613F8DC60C2369EAA21F1F7B1391886BBDFBAEEF244409A26D0DFDB8CD22A9F0A6B02F06902019F8EF65808D148000D411F363706B1758567A001A05831F0E6C5791CFDC735640B154FC4037824205D1378ACBF0DFB77B4AE789EC0FF9E83F6AFB0F2C2CAB1D1691C3B3D8DF974D1336BC2008F0404007E9F868D78B0C23025A6E673F8F5C949E44A554FC5037828A07AAB1A1253B772387F6501272FCC225FF6DEBA788001AD5B3A57C6D1916B7875741A15C3FCEF5268EFA5B38C01AD934FD7902F555135A527479C8FF344405202997C194B360B01ADB44743F0E9F74EA4C3211F9EDED78D3F9D497A62EDFB6A3C1190619AF8F39924DE58E3DA785302DFFDD24EDB1515DB128D68DAE447B6E0FE4F9A57E38980A4041697CA984EE5D7FC9A1FFF761CDF7E6627F6EF88DF734CD3045E7876377EFAFB4B48654A9E8E88DF0BB3319F2EE10FA7A730BF68FD40D8AE2D511C186853DAD4D30D18D00AC6AF2F62647C0E55E3DEF5EFA1808EE1A14EF47544607A6D5BB28F604036048042D9C01BEFDDC4E49CF5FAF7BE4404C37B3B100E7A6226608901AD4013029767B278E9C805CBE3BA26F0E5CFF6E2A5AFEFF5DCDE887731A055684260F64EC1762F2000D8DEDD0C8B3B7E4FF0E897BD3E2680331FA46CE73A019F862D6DDE9C0B31A03510007EF2BB4B78E56F576DB77279F99B433838D0EEB9B73206B446E58A81936373B872D37A42DD1209E2E97D5D688B861CBEB2FB8B01AD91A6092417F218199F47D1662DFCAEDE280EEF4DC04B8310035A07530223E373B870CD7AB160D0AFE3F05087EB37D6FC2806B40E02C0AD74092FFF66DC760FE89E78188FF5B77A662EC480D649082053A8E0BACDAE6400F0C5FD3D48C442901EA88801D5401302C7CFCDD8DE910D6E8D6178A803019FE6FAF91003AAD1E989142667AD4721BF4FC3938309ECEC6976FD28C4806A945CC8E3C557DEC3A9B159CB0F109777ABDF8D83363B77B80503AA9126046E2F9571FCDC4DDB473E5A1A83F8C2A35DAE9E5033200502C085EB8B78F3E22DDB73063E11854F17AE9D0B3120054200C57215AF9F4BDA9E13F4EBD8D5DB0CB70E430C4891100293734B189BB4DF89ECC5E7F6E0D09E0E5736C480EA4013027F3D378374AE6C79BC71931F87F6245CF9335519509D9C9E486174E296EDB731F66C8D61782801FF0A5B023F8C5C1590DDF3ED9A26367CE5C462BE8CBF9C9DC14D9B1F7B100AE878EA912EF4F75A6F4AF5B072CDC3BC9A10F8CE2FCE5AAE16959048A53776B34B4D08FC6B3A8DE77FFE363A62363FA057008592BBF68D764D4000F04132637B4C1370642569365F453A97B63D2EB03C22BA85AB027A10D6680901E81E5A69E8AA3910398F01911206444A1810296140A48401911206444A1810296140A48401911206444A1810296140A48401911206444A1810296140A48401911206444A1810296140A48401911206444A18102961404444444444444444444444444444444444EEF16FA1F58650714071140000000049454E44AE426082','ASK');
+INSERT INTO "pattern" VALUES(1,1,'https?://browserquest\.mozilla\.org/.*',1);
+INSERT INTO "user_agent" VALUES(1,'Android Phone','Mozilla/5.0 (Linux; Android 7.0; Nexus 6 Build/NPD90G) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.98 Mobile Safari/537.36');
+INSERT INTO "user_agent" VALUES(2,'Android Tablet','Mozilla/5.0 (Linux; Android 7.0; Nexus 9 Build/NPD90G; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/51.0.2704.90 Safari/537.36 Sleipnir/3.5.4');
+INSERT INTO "user_agent" VALUES(3,'iPhone','Mozilla/5.0 (iPhone; CPU iPhone OS 10_0 like Mac OS X) AppleWebKit/602.1.50 (KHTML, like Gecko) Version/10.0 Mobile/14A5341a Safari/602.1');
+INSERT INTO "user_agent" VALUES(4,'iPad','Mozilla/5.0 (iPad; CPU OS 10_0 like Mac OS X) AppleWebKit/601.1 (KHTML, like Gecko) CriOS/53.0.2785.86 Mobile/14A5335b Safari/601.1.46');
+INSERT INTO "rule" VALUES(1,1,'.+',1,-16777216,'SINGLE_TOP','LANDSCAPE',1,0,1,1);
+INSERT INTO "rule" VALUES(2,2,'https://(www\.)?regex101\.com/.*',1,-12627531,'CLEAR_TOP','NORMAL',0,0,1,2);
+INSERT INTO "rule" VALUES(3,2,'.+',1,-16777216,'STANDARD','NORMAL',0,0,1,2);
diff --git a/app/src/main/res/values-sw600dp/dimens.xml b/app/src/main/res/values-sw600dp/dimens.xml
new file mode 100644
index 0000000..4097824
--- /dev/null
+++ b/app/src/main/res/values-sw600dp/dimens.xml
@@ -0,0 +1,4 @@
+
+
+ 26dp
+
diff --git a/app/src/main/res/values-v17/styles.xml b/app/src/main/res/values-v17/styles.xml
new file mode 100644
index 0000000..31ca3fc
--- /dev/null
+++ b/app/src/main/res/values-v17/styles.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/app/src/main/res/values-v19/styles.xml b/app/src/main/res/values-v19/styles.xml
new file mode 100644
index 0000000..dcd0940
--- /dev/null
+++ b/app/src/main/res/values-v19/styles.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/app/src/main/res/values-v21/dimens.xml b/app/src/main/res/values-v21/dimens.xml
new file mode 100644
index 0000000..e922860
--- /dev/null
+++ b/app/src/main/res/values-v21/dimens.xml
@@ -0,0 +1,5 @@
+
+
+ 0dp
+ 0dp
+
diff --git a/app/src/main/res/values-v21/styles.xml b/app/src/main/res/values-v21/styles.xml
new file mode 100644
index 0000000..1bd7776
--- /dev/null
+++ b/app/src/main/res/values-v21/styles.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/app/src/main/res/values-w480dp/values.xml b/app/src/main/res/values-w480dp/values.xml
new file mode 100644
index 0000000..c7746ce
--- /dev/null
+++ b/app/src/main/res/values-w480dp/values.xml
@@ -0,0 +1,4 @@
+
+
+ 6
+
\ No newline at end of file
diff --git a/app/src/main/res/values-w720dp/values.xml b/app/src/main/res/values-w720dp/values.xml
new file mode 100644
index 0000000..3c5a5c5
--- /dev/null
+++ b/app/src/main/res/values-w720dp/values.xml
@@ -0,0 +1,4 @@
+
+
+ 9
+
\ No newline at end of file
diff --git a/app/src/main/res/values-w820dp/dimens.xml b/app/src/main/res/values-w820dp/dimens.xml
new file mode 100644
index 0000000..c7167ca
--- /dev/null
+++ b/app/src/main/res/values-w820dp/dimens.xml
@@ -0,0 +1,3 @@
+
+ 192dp
+
diff --git a/app/src/main/res/values-w820dp/values.xml b/app/src/main/res/values-w820dp/values.xml
new file mode 100644
index 0000000..9d6c066
--- /dev/null
+++ b/app/src/main/res/values-w820dp/values.xml
@@ -0,0 +1,4 @@
+
+
+ 12
+
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
new file mode 100644
index 0000000..e60fbba
--- /dev/null
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -0,0 +1,105 @@
+
+
+ 网络应用
+ 关于
+ 帐号复制完成
+ 添加
+ 添加模式
+ 添加规则
+ 添加快捷方式
+ 添加浏览器标识
+ 允许
+ 网络应用不存在
+ 应用
+ 禁止
+ 缓存清理完成
+ 取消
+ 更改图标
+ 清除数据
+ 清除缓存
+ 完成
+ 复制
+ 复制到剪切板
+ 数据
+ 数据清除完成
+ 默认规则
+ 删除
+ 开发者
+ 捐赠
+ 支付宝/PayPal:drunlin@outlook.com
+ 下载失败
+ 正在下载图片……
+ 修改
+ 修改模式
+ 修改规则
+ 修改浏览器标识
+ 修改应用
+ 启用脚本
+ 退出
+ 名称已存在
+ 模式已存在
+ 规则已存在
+ 浏览器标识已存在
+ 网址已存在
+ 全屏
+ 没有找到图库应用
+ 错误的正则表达式
+ 错误的网址
+ 名称
+ 启动模式
+ 开源许可
+ 位置
+ 当前网站想使用你的设备位置信息。
+ 帮助手册
+ 新建
+ 新建模式
+ 新建规则
+ 新建应用
+ 确定
+ 模式*
+ 没有被匹配到的链接在其它应用中打开
+ 选择图片
+ 请等待
+ 预览
+ 正则表达式
+ 名称*
+ 浏览器标识
+ 重启应用
+ 给网页设置特殊的规则
+ 规则
+ 屏幕方向
+ 搜索
+ 设置为网址
+ 设置
+ 状态栏颜色
+ 打开
+ 在系统设置中打开定位服务
+ 模式
+ 浏览器标识
+ 浏览器标识
+ 版本号
+ 网址
+
+ - 先询问
+ - 允许
+ - 拒绝
+
+
+ - 使用默认图标
+ - 从网站中加载
+ - @string/pick_image
+
+
+ - 自动
+ - 竖屏
+ - 横屏
+
+
+ - 标准模式
+ - 新建窗口
+ - 移除顶部
+ - 保持顶部
+ - 全局唯一
+
+ 网络应用盒子
+
diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml
new file mode 100644
index 0000000..98dcbd3
--- /dev/null
+++ b/app/src/main/res/values/attrs.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..c580270
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,28 @@
+
+
+ #03a9f4
+ #0288d1
+ #ffeb3b
+
+ - #d32f2f
+ - #c2185b
+ - #7b1fa2
+ - #512da8
+ - #303f9f
+ - #1976d2
+ - #0288d1
+ - #0097a7
+ - #00796b
+ - #388e3c
+ - #689f38
+ - #afb42b
+ - #fbc02d
+ - #ffa000
+ - #f57c00
+ - #e64a19
+ - #5d4037
+ - #616161
+ - #455a64
+ - @android:color/black
+
+
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
new file mode 100644
index 0000000..c49d54f
--- /dev/null
+++ b/app/src/main/res/values/dimens.xml
@@ -0,0 +1,8 @@
+
+
+ 16dp
+ 16dp
+ -4dp
+ -4dp
+ 56dp
+
diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml
new file mode 100644
index 0000000..f59bb99
--- /dev/null
+++ b/app/src/main/res/values/ids.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..8c9eaa5
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,109 @@
+
+ WebappBox
+ Web App
+ Apps
+ Search
+ New app
+ Settings
+ About
+ Edit
+ Delete
+ Add shortcut
+ Pick image
+
+ - Use default icon
+ - Load from website
+ - @string/pick_image
+
+
+ - Ask
+ - On
+ - Off
+
+ Add new webapp
+ Edit webapp
+ Confirm
+ URL*
+ Label
+ Rule already exists
+ Change icon
+ Please wait
+ Downloading image…
+ Download failed
+ URL patterns
+ Rules
+ Edit pattern
+ Add new pattern
+ Pattern*
+ Edit rule
+ Add new rule
+ Invalid URL
+ URL already exists
+ Status bar color
+ Full screen
+ Screen orientation
+ Name*
+ User agent string*
+
+ - Auto
+ - Portrait
+ - Landscape
+
+ Launch mode
+
+ - Standard
+ - New window
+ - Clear top
+ - Single top
+ - Single task
+
+ Enable JavaScript
+ User agent
+ Edit user agent
+ Add new user agent
+ User agents
+ URL
+ Clear data
+ Data cleared
+ Clear cache
+ Cache cleared
+ Default rule
+ Restart
+ New rule
+ New pattern
+ Set as URL
+ Exit
+ Data
+ Cancel
+ OK
+ Add
+ Version
+ 1.0
+ Github
+ https://github.com/drunlin/webappbox
+ Developer
+ drunlin@outlook.com
+ Open source licenses
+ Manual
+ Donate
+ PayPal/AliPay: drunlin@outlook.com
+ Account copied
+ Copy to clipboard
+ Copy
+ Pattern already exists
+ Invalid Regex
+ Name already exists
+ UA already exists
+ Regex
+ Location
+ This website wants to use your device\'s location.
+ allow
+ block
+ Turn on location in Android Settings.
+ turn on
+ The web app does not exist
+ No Gallery apps found
+ Links that are not matched are opened in other apps
+ Set special rules for pages
+ Preview
+
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..99106ba
--- /dev/null
+++ b/app/src/main/res/values/styles.xml
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/values.xml b/app/src/main/res/values/values.xml
new file mode 100644
index 0000000..275e56e
--- /dev/null
+++ b/app/src/main/res/values/values.xml
@@ -0,0 +1,26 @@
+
+
+ 4
+
+
+ - STANDARD
+ - NEW_WINDOW
+ - CLEAR_TOP
+ - SINGLE_TOP
+ - SINGLE_TASK
+
+ STANDARD
+
+
+ - ASK
+ - ALLOW
+ - DENY
+
+
+
+ - NORMAL
+ - PORTRAIT
+ - LANDSCAPE
+
+ NORMAL
+
diff --git a/app/src/main/res/xml/backups.xml b/app/src/main/res/xml/backups.xml
new file mode 100644
index 0000000..d278e66
--- /dev/null
+++ b/app/src/main/res/xml/backups.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/xml/settings.xml b/app/src/main/res/xml/settings.xml
new file mode 100644
index 0000000..b5361f5
--- /dev/null
+++ b/app/src/main/res/xml/settings.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/test/java/com/github/drunlin/webappbox/model/DatabaseManagerTest.kt b/app/src/test/java/com/github/drunlin/webappbox/model/DatabaseManagerTest.kt
new file mode 100644
index 0000000..2f62cfa
--- /dev/null
+++ b/app/src/test/java/com/github/drunlin/webappbox/model/DatabaseManagerTest.kt
@@ -0,0 +1,35 @@
+package com.github.drunlin.webappbox.model
+
+import android.os.Build
+import com.github.drunlin.webappbox.BuildConfig
+import org.junit.After
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricGradleTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+
+@Config(constants = BuildConfig::class, sdk = intArrayOf(Build.VERSION_CODES.LOLLIPOP))
+@RunWith(RobolectricGradleTestRunner::class)
+class DatabaseManagerTest {
+ lateinit var databaseManager: DatabaseManager
+
+ @Before
+ fun setUp() {
+ databaseManager = DatabaseManager(RuntimeEnvironment.application)
+ }
+
+ @After
+ fun teardown() {
+ databaseManager.close()
+ }
+
+ @Test
+ fun onCreate() {
+ assertNotNull(databaseManager.readableDatabase)
+ assertTrue(databaseManager.getShortcuts().size > 0)
+ }
+}
diff --git a/app/src/test/java/com/github/drunlin/webappbox/model/IconLoaderTest.kt b/app/src/test/java/com/github/drunlin/webappbox/model/IconLoaderTest.kt
new file mode 100644
index 0000000..f0749ba
--- /dev/null
+++ b/app/src/test/java/com/github/drunlin/webappbox/model/IconLoaderTest.kt
@@ -0,0 +1,24 @@
+package com.github.drunlin.webappbox.model
+
+import org.junit.Assert.assertNotNull
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+
+@RunWith(RobolectricTestRunner::class)
+class IconLoaderTest {
+ private lateinit var loader: IconLoader
+
+ @Before
+ fun setUp() {
+ loader = IconLoader()
+ loader.context = RuntimeEnvironment.application
+ }
+
+ @Test
+ fun load() {
+ assertNotNull(loader.load("https://www.baidu.com/"))
+ }
+}
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..f081b9b
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,20 @@
+buildscript {
+ repositories {
+ jcenter()
+ }
+
+ dependencies {
+ classpath 'com.android.tools.build:gradle:2.2.2'
+ }
+}
+
+allprojects {
+ repositories {
+ jcenter()
+ mavenCentral()
+ }
+}
+
+task clean(type: Delete) {
+ delete rootProject.buildDir
+}
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..13372ae
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..04e285f
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Mon Dec 28 10:00:20 PST 2015
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-2.14.1-all.zip
diff --git a/gradlew b/gradlew
new file mode 100644
index 0000000..9d82f78
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,160 @@
+#!/usr/bin/env bash
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS=""
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn ( ) {
+ echo "$*"
+}
+
+die ( ) {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+esac
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=$((i+1))
+ done
+ case $i in
+ (0) set -- ;;
+ (1) set -- "$args0" ;;
+ (2) set -- "$args0" "$args1" ;;
+ (3) set -- "$args0" "$args1" "$args2" ;;
+ (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
+function splitJvmOpts() {
+ JVM_OPTS=("$@")
+}
+eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
+JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
+
+exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..8a0b282
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,90 @@
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windowz variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+if "%@eval[2+2]" == "4" goto 4NT_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+goto execute
+
+:4NT_args
+@rem Get arguments from the 4NT Shell from JP Software
+set CMD_LINE_ARGS=%$
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/res/ic_launcher.xcf b/res/ic_launcher.xcf
new file mode 100644
index 0000000..a6e3ce2
Binary files /dev/null and b/res/ic_launcher.xcf differ
diff --git a/res/ic_webapp.xcf b/res/ic_webapp.xcf
new file mode 100644
index 0000000..09fcd48
Binary files /dev/null and b/res/ic_webapp.xcf differ
diff --git a/res/screenshot0.png b/res/screenshot0.png
new file mode 100644
index 0000000..cdd9d3d
Binary files /dev/null and b/res/screenshot0.png differ
diff --git a/res/screenshot1.png b/res/screenshot1.png
new file mode 100644
index 0000000..045b3a5
Binary files /dev/null and b/res/screenshot1.png differ
diff --git a/res/screenshot2.png b/res/screenshot2.png
new file mode 100644
index 0000000..cc6e640
Binary files /dev/null and b/res/screenshot2.png differ
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..e7b4def
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1 @@
+include ':app'